added diff endpoint which returns a parsed diff as json

This commit is contained in:
Sebastian Sdorra
2020-01-22 15:49:50 +01:00
parent fd15c68ca0
commit fe8e4db10b
10 changed files with 424 additions and 16 deletions

View File

@@ -29,6 +29,7 @@ public class VndMediaType {
public static final String BRANCH = PREFIX + "branch" + SUFFIX; public static final String BRANCH = PREFIX + "branch" + SUFFIX;
public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX; public static final String BRANCH_REQUEST = PREFIX + "branchRequest" + SUFFIX;
public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX; public static final String DIFF = PLAIN_TEXT_PREFIX + "diff" + PLAIN_TEXT_SUFFIX;
public static final String DIFF_PARSED = PREFIX + "diffParsed" + SUFFIX;;
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX; public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX; public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX; public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;

View File

@@ -50,10 +50,15 @@ class LoadingDiff extends React.Component<Props, State> {
this.setState({ loading: true }); this.setState({ loading: true });
apiClient apiClient
.get(url) .get(url)
.then(response => response.text()) .then(response => {
.then(parser.parse) const contentType = response.headers.get("Content-Type");
// $FlowFixMe if (contentType && contentType.toLowerCase() === "application/vnd.scmm-diffparsed+json;v=2") {
.then((diff: any) => { return response.json().then(data => data.files);
} else {
return response.text().then(parser.parse);
}
})
.then((diff: File[]) => {
this.setState({ this.setState({
loading: false, loading: false,
diff: diff diff: diff

View File

@@ -46,10 +46,17 @@ type Props = {
className?: string; className?: string;
}; };
const determineLanguage = (file: File) => {
if (file.language) {
return file.language.toLowerCase();
}
return "text";
};
const TokenizedDiffView: FC<Props> = ({ file, viewType, className, children }) => { const TokenizedDiffView: FC<Props> = ({ file, viewType, className, children }) => {
const { tokens } = useTokenizeWorker(tokenize, { const { tokens } = useTokenizeWorker(tokenize, {
hunks: file.hunks, hunks: file.hunks,
language: file.language || "text" language: determineLanguage(file)
}); });
return ( return (

View File

@@ -11,13 +11,14 @@ type Props = WithTranslation & {
class ChangesetDiff extends React.Component<Props> { class ChangesetDiff extends React.Component<Props> {
isDiffSupported(changeset: Changeset) { isDiffSupported(changeset: Changeset) {
return !!changeset._links.diff; return changeset._links.diff || !!changeset._links.diffParsed;
} }
createUrl(changeset: Changeset) { createUrl(changeset: Changeset) {
if (changeset._links.diff) { if (changeset._links.diffParsed) {
const link = changeset._links.diff as Link; return (changeset._links.diffParsed as Link).href;
return link.href + "?format=GIT"; } else if (changeset._links.diff) {
return (changeset._links.diff as Link).href + "?format=GIT";
} }
throw new Error("diff link is missing"); throw new Error("diff link is missing");
} }

View File

@@ -53,6 +53,12 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
Embedded.Builder embeddedBuilder = embeddedBuilder(); Embedded.Builder embeddedBuilder = embeddedBuilder();
Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), source.getId()))
.single(link("diff", resourceLinks.diff().self(namespace, name, source.getId())))
.single(link("sources", resourceLinks.source().self(namespace, name, source.getId())))
.single(link("modifications", resourceLinks.modifications().self(namespace, name, source.getId())));
try (RepositoryService repositoryService = serviceFactory.create(repository)) { try (RepositoryService repositoryService = serviceFactory.create(repository)) {
if (repositoryService.isSupported(Command.TAGS)) { if (repositoryService.isSupported(Command.TAGS)) {
embeddedBuilder.with("tags", tagCollectionToDtoMapper.getTagDtoList(namespace, name, embeddedBuilder.with("tags", tagCollectionToDtoMapper.getTagDtoList(namespace, name,
@@ -62,16 +68,13 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name, embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name,
getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId())))); getListOfObjects(source.getBranches(), branchName -> Branch.normalBranch(branchName, source.getId()))));
} }
if (repositoryService.isSupported(Command.DIFF_RESULT)) {
linksBuilder.single(link("diffParsed", resourceLinks.diff().parsed(namespace, name, source.getId())));
}
} }
embeddedBuilder.with("parents", getListOfObjects(source.getParents(), parent -> changesetToParentDtoMapper.map(new Changeset(parent, 0L, null), repository))); embeddedBuilder.with("parents", getListOfObjects(source.getParents(), parent -> changesetToParentDtoMapper.map(new Changeset(parent, 0L, null), repository)));
Links.Builder linksBuilder = linkingTo()
.self(resourceLinks.changeset().self(repository.getNamespace(), repository.getName(), source.getId()))
.single(link("diff", resourceLinks.diff().self(namespace, name, source.getId())))
.single(link("sources", resourceLinks.source().self(namespace, name, source.getId())))
.single(link("modifications", resourceLinks.modifications().self(namespace, name, source.getId())));
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), source, repository); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), source, repository);
return new ChangesetDto(linksBuilder.build(), embeddedBuilder.build()); return new ChangesetDto(linksBuilder.build(), embeddedBuilder.build());

View File

@@ -0,0 +1,64 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.otto.edison.hal.HalRepresentation;
import lombok.Data;
import java.util.List;
@Data
public class DiffResultDto extends HalRepresentation {
private List<FileDto> files;
@Data
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public static class FileDto {
private String oldPath;
private String newPath;
private boolean oldEndingNewLine;
private boolean newEndingNewLine;
private String oldRevision;
private String newRevision;
private String newMode;
private String oldMode;
private String type;
private String language;
private List<HunkDto> hunks;
}
@Data
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public static class HunkDto {
private String content;
private int oldStart;
private int newStart;
private int oldLines;
private int newLines;
private List<ChangeDto> changes;
}
@Data
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public static class ChangeDto {
private String content;
private String type;
@JsonProperty("isNormal")
private boolean isNormal;
@JsonProperty("isInsert")
private boolean isInsert;
@JsonProperty("isDelete")
private boolean isDelete;
private int lineNumber;
private int oldLineNumber;
private int newLineNumber;
}
}

View File

@@ -0,0 +1,132 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentTypes;
import com.github.sdorra.spotter.Language;
import com.google.common.base.Strings;
import sonia.scm.repository.api.DiffFile;
import sonia.scm.repository.api.DiffLine;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.Hunk;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
/**
* TODO conflicts, copy and rename
*/
final class DiffResultToDiffResultDtoMapper {
static final DiffResultToDiffResultDtoMapper INSTANCE = new DiffResultToDiffResultDtoMapper();
private DiffResultToDiffResultDtoMapper() {
}
public DiffResultDto map(DiffResult result) {
List<DiffResultDto.FileDto> files = new ArrayList<>();
for (DiffFile file : result) {
files.add(mapFile(file));
}
DiffResultDto dto = new DiffResultDto();
dto.setFiles(files);
return dto;
}
private DiffResultDto.FileDto mapFile(DiffFile file) {
DiffResultDto.FileDto dto = new DiffResultDto.FileDto();
// ???
dto.setOldEndingNewLine(true);
dto.setNewEndingNewLine(true);
String newPath = file.getNewPath();
String oldPath = file.getOldPath();
String path;
if (isFilePath(newPath) && isFileNull(oldPath)) {
path = newPath;
dto.setType("add");
} else if (isFileNull(newPath) && isFilePath(oldPath)) {
path = oldPath;
dto.setType("delete");
} else if (isFilePath(newPath) && isFilePath(oldPath)) {
path = newPath;
dto.setType("modify");
} else {
// TODO copy and rename?
throw new IllegalStateException("no file without path");
}
dto.setNewPath(newPath);
dto.setNewRevision(file.getNewRevision());
dto.setOldPath(oldPath);
dto.setOldRevision(file.getOldRevision());
Optional<Language> language = ContentTypes.detect(path).getLanguage();
language.ifPresent(value -> dto.setLanguage(value.getName()));
List<DiffResultDto.HunkDto> hunks = new ArrayList<>();
for (Hunk hunk : file) {
hunks.add(mapHunk(hunk));
}
dto.setHunks(hunks);
return dto;
}
private boolean isFilePath(String path) {
return !isFileNull(path);
}
private boolean isFileNull(String path) {
return Strings.isNullOrEmpty(path) || "/dev/null".equals(path);
}
private DiffResultDto.HunkDto mapHunk(Hunk hunk) {
DiffResultDto.HunkDto dto = new DiffResultDto.HunkDto();
dto.setContent(hunk.getRawHeader());
dto.setNewStart(hunk.getNewStart());
dto.setNewLines(hunk.getNewLineCount());
dto.setOldStart(hunk.getOldStart());
dto.setOldLines(hunk.getOldLineCount());
List<DiffResultDto.ChangeDto> changes = new ArrayList<>();
for (DiffLine line : hunk) {
changes.add(mapLine(line));
}
dto.setChanges(changes);
return dto;
}
private DiffResultDto.ChangeDto mapLine(DiffLine line) {
DiffResultDto.ChangeDto dto = new DiffResultDto.ChangeDto();
dto.setContent(line.getContent());
OptionalInt newLineNumber = line.getNewLineNumber();
OptionalInt oldLineNumber = line.getOldLineNumber();
if (newLineNumber.isPresent() && !oldLineNumber.isPresent()) {
dto.setType("insert");
dto.setInsert(true);
dto.setLineNumber(newLineNumber.getAsInt());
} else if (!newLineNumber.isPresent() && oldLineNumber.isPresent()) {
dto.setType("delete");
dto.setDelete(true);
dto.setLineNumber(oldLineNumber.getAsInt());
} else if (newLineNumber.isPresent() && oldLineNumber.isPresent()) {
dto.setType("normal");
dto.setNormal(true);
dto.setNewLineNumber(newLineNumber.getAsInt());
dto.setOldLineNumber(oldLineNumber.getAsInt());
} else {
throw new IllegalStateException("line without line number");
}
return dto;
}
}

View File

@@ -6,6 +6,7 @@ import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffCommandBuilder;
import sonia.scm.repository.api.DiffFormat; import sonia.scm.repository.api.DiffFormat;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
@@ -70,4 +71,23 @@ public class DiffRootResource {
.build(); .build();
} }
} }
@GET
@Path("{revision}.json")
@Produces(VndMediaType.DIFF_PARSED)
@StatusCodes({
@ResponseCode(code = 200, condition = "success"),
@ResponseCode(code = 400, condition = "Bad Request"),
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the diff"),
@ResponseCode(code = 404, condition = "not found, no revision with the specified param for the repository available or repository not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
public Response getParsed(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException {
HttpUtil.checkForCRLFInjection(revision);
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
DiffResult diffResult = repositoryService.getDiffResultCommand().setRevision(revision).getDiffResult();
return Response.ok(DiffResultToDiffResultDtoMapper.INSTANCE.map(diffResult)).build();
}
}
} }

View File

@@ -362,6 +362,10 @@ class ResourceLinks {
return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("get").parameters(id).href(); return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("get").parameters(id).href();
} }
String parsed(String namespace, String name, String id) {
return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("getParsed").parameters(id).href();
}
String all(String namespace, String name) { String all(String namespace, String name) {
return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("getAll").parameters().href(); return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("getAll").parameters().href();
} }

View File

@@ -0,0 +1,171 @@
package sonia.scm.api.v2.resources;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.api.DiffFile;
import sonia.scm.repository.api.DiffLine;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.Hunk;
import java.util.Arrays;
import java.util.List;
import java.util.OptionalInt;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DiffResultToDiffResultDtoMapperTest {
@Test
void shouldMapDiffResult() {
DiffResult result = result(
addedFile("A.java", "abc"),
modifiedFile("B.tsx", "def", "abc",
hunk("@@ -3,4 1,2 @@", 1, 2, 3, 4,
insertedLine("a", 1),
modifiedLine("b", 2),
deletedLine("c", 3)
)
),
deletedFile("C.go", "ghi")
);
DiffResultDto dto = DiffResultToDiffResultDtoMapper.INSTANCE.map(result);
List<DiffResultDto.FileDto> files = dto.getFiles();
assertAddedFile(files.get(0), "A.java", "abc", "Java");
assertModifiedFile(files.get(1), "B.tsx", "abc", "def", "TypeScript");
assertDeletedFile(files.get(2), "C.go", "ghi", "Go");
DiffResultDto.HunkDto hunk = files.get(1).getHunks().get(0);
assertHunk(hunk, "@@ -3,4 1,2 @@", 1, 2, 3, 4);
List<DiffResultDto.ChangeDto> changes = hunk.getChanges();
assertInsertedLine(changes.get(0), "a", 1);
assertModifiedLine(changes.get(1), "b", 2);
assertDeletedLine(changes.get(2), "c", 3);
}
public void assertInsertedLine(DiffResultDto.ChangeDto change, String content, int lineNumber) {
assertThat(change.getContent()).isEqualTo(content);
assertThat(change.getLineNumber()).isEqualTo(lineNumber);
assertThat(change.getType()).isEqualTo("insert");
assertThat(change.isInsert()).isTrue();
}
private void assertModifiedLine(DiffResultDto.ChangeDto change, String content, int lineNumber) {
assertThat(change.getContent()).isEqualTo(content);
assertThat(change.getNewLineNumber()).isEqualTo(lineNumber);
assertThat(change.getOldLineNumber()).isEqualTo(lineNumber);
assertThat(change.getType()).isEqualTo("normal");
assertThat(change.isNormal()).isTrue();
}
private void assertDeletedLine(DiffResultDto.ChangeDto change, String content, int lineNumber) {
assertThat(change.getContent()).isEqualTo(content);
assertThat(change.getLineNumber()).isEqualTo(lineNumber);
assertThat(change.getType()).isEqualTo("delete");
assertThat(change.isDelete()).isTrue();
}
private void assertHunk(DiffResultDto.HunkDto hunk, String content, int newStart, int newLineCount, int oldStart, int oldLineCount) {
assertThat(hunk.getContent()).isEqualTo(content);
assertThat(hunk.getNewStart()).isEqualTo(newStart);
assertThat(hunk.getNewLines()).isEqualTo(newLineCount);
assertThat(hunk.getOldStart()).isEqualTo(oldStart);
assertThat(hunk.getOldLines()).isEqualTo(oldLineCount);
}
private void assertAddedFile(DiffResultDto.FileDto file, String path, String revision, String language) {
assertThat(file.getNewPath()).isEqualTo(path);
assertThat(file.getNewRevision()).isEqualTo(revision);
assertThat(file.getType()).isEqualTo("add");
assertThat(file.getLanguage()).isEqualTo(language);
}
private void assertModifiedFile(DiffResultDto.FileDto file, String path, String oldRevision, String newRevision, String language) {
assertThat(file.getNewPath()).isEqualTo(path);
assertThat(file.getNewRevision()).isEqualTo(newRevision);
assertThat(file.getOldPath()).isEqualTo(path);
assertThat(file.getOldRevision()).isEqualTo(oldRevision);
assertThat(file.getType()).isEqualTo("modify");
assertThat(file.getLanguage()).isEqualTo(language);
}
private void assertDeletedFile(DiffResultDto.FileDto file, String path, String revision, String language) {
assertThat(file.getOldPath()).isEqualTo(path);
assertThat(file.getOldRevision()).isEqualTo(revision);
assertThat(file.getType()).isEqualTo("delete");
assertThat(file.getLanguage()).isEqualTo(language);
}
private DiffResult result(DiffFile... files) {
DiffResult result = mock(DiffResult.class);
when(result.iterator()).thenReturn(Arrays.asList(files).iterator());
return result;
}
private DiffFile addedFile(String path, String revision, Hunk... hunks) {
DiffFile file = mock(DiffFile.class);
when(file.getNewPath()).thenReturn(path);
when(file.getNewRevision()).thenReturn(revision);
when(file.iterator()).thenReturn(Arrays.asList(hunks).iterator());
return file;
}
private DiffFile deletedFile(String path, String revision, Hunk... hunks) {
DiffFile file = mock(DiffFile.class);
when(file.getOldPath()).thenReturn(path);
when(file.getOldRevision()).thenReturn(revision);
when(file.iterator()).thenReturn(Arrays.asList(hunks).iterator());
return file;
}
private DiffFile modifiedFile(String path, String newRevision, String oldRevision, Hunk... hunks) {
DiffFile file = mock(DiffFile.class);
when(file.getNewPath()).thenReturn(path);
when(file.getNewRevision()).thenReturn(newRevision);
when(file.getOldPath()).thenReturn(path);
when(file.getOldRevision()).thenReturn(oldRevision);
when(file.iterator()).thenReturn(Arrays.asList(hunks).iterator());
return file;
}
private Hunk hunk(String rawHeader, int newStart, int newLineCount, int oldStart, int oldLineCount, DiffLine... lines) {
Hunk hunk = mock(Hunk.class);
when(hunk.getRawHeader()).thenReturn(rawHeader);
when(hunk.getNewStart()).thenReturn(newStart);
when(hunk.getNewLineCount()).thenReturn(newLineCount);
when(hunk.getOldStart()).thenReturn(oldStart);
when(hunk.getOldLineCount()).thenReturn(oldLineCount);
when(hunk.iterator()).thenReturn(Arrays.asList(lines).iterator());
return hunk;
}
private DiffLine insertedLine(String content, int lineNumber) {
DiffLine line = mock(DiffLine.class);
when(line.getContent()).thenReturn(content);
when(line.getNewLineNumber()).thenReturn(OptionalInt.of(lineNumber));
when(line.getOldLineNumber()).thenReturn(OptionalInt.empty());
return line;
}
private DiffLine modifiedLine(String content, int lineNumber) {
DiffLine line = mock(DiffLine.class);
when(line.getContent()).thenReturn(content);
when(line.getNewLineNumber()).thenReturn(OptionalInt.of(lineNumber));
when(line.getOldLineNumber()).thenReturn(OptionalInt.of(lineNumber));
return line;
}
private DiffLine deletedLine(String content, int lineNumber) {
DiffLine line = mock(DiffLine.class);
when(line.getContent()).thenReturn(content);
when(line.getNewLineNumber()).thenReturn(OptionalInt.empty());
when(line.getOldLineNumber()).thenReturn(OptionalInt.of(lineNumber));
return line;
}
}