Merged in feature/diff_syntax_hightlighting (pull request #398)

Feature/diff syntax hightlighting
This commit is contained in:
Rene Pfeuffer
2020-01-28 13:44:26 +00:00
37 changed files with 7033 additions and 1974 deletions

View File

@@ -20,7 +20,8 @@
},
"resolutions": {
"babel-core": "7.0.0-bridge.0",
"gitdiff-parser": "https://github.com/scm-manager/gitdiff-parser#ed3fe7de73dbb0a06c3e6adbbdf22dbae6e66351"
"gitdiff-parser": "https://github.com/scm-manager/gitdiff-parser#ed3fe7de73dbb0a06c3e6adbbdf22dbae6e66351",
"lowlight": "1.13.1"
},
"babel": {
"presets": [

View File

@@ -29,6 +29,7 @@ public class VndMediaType {
public static final String BRANCH = PREFIX + "branch" + 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_PARSED = PREFIX + "diffParsed" + SUFFIX;
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;

View File

@@ -17,35 +17,21 @@ const reportDirectory = path.join(target, "jest-reports");
module.exports = {
rootDir: root,
roots: [
root
],
testPathDirs: [
path.join(root, "src")
],
roots: [root],
testPathDirs: [path.join(root, "src")],
transform: {
"^.+\\.(ts|tsx|js)$": "@scm-manager/jest-preset"
},
transformIgnorePatterns: [
"node_modules/(?!(@scm-manager)/)"
],
transformIgnorePatterns: ["node_modules/(?!(@scm-manager)/)"],
moduleNameMapper: {
"\\.(png|svg|jpg|gif|woff2?|eot|ttf)$": path.join(
mockDirectory,
"fileMock.js"
),
"\\.(png|svg|jpg|gif|woff2?|eot|ttf)$": path.join(mockDirectory, "fileMock.js"),
"\\.(css|scss|sass)$": path.join(mockDirectory, "styleMock.js")
},
setupFiles: [path.resolve(__dirname, "src", "setup.js")],
collectCoverage: true,
collectCoverageFrom: [
"src/**/*.{ts,tsx,js,jsx}"
],
collectCoverageFrom: ["src/**/*.{ts,tsx,js,jsx}"],
coverageDirectory: path.join(reportDirectory, "coverage-" + name),
coveragePathIgnorePatterns: [
"src/tests/.*",
"src/testing/.*"
],
coveragePathIgnorePatterns: ["src/tests/.*", "src/testing/.*"],
reporters: [
"default",
[

View File

@@ -0,0 +1,7 @@
function WorkerMock() {}
WorkerMock.prototype.addEventListener = function() {};
WorkerMock.prototype.removeEventListener = function() {};
WorkerMock.prototype.postMessage = function() {};
module.exports = WorkerMock;

View File

@@ -1,2 +1,4 @@
import registerRequireContextHook from "babel-plugin-require-context-hook/register";
import Worker from "./__mocks__/workerMock";
registerRequireContextHook();
window.Worker = Worker;

View File

@@ -32,6 +32,22 @@
<finalName>scm-ui</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-clean-plugin</artifactId>
<configuration>
<filesets>
<!-- delete node cache to avoid problems with hmr or fast-refresh code -->
<fileset>
<directory>../node_modules/.cache</directory>
<includes>
<include>**</include>
</includes>
</fileset>
</filesets>
</configuration>
</plugin>
<plugin>
<groupId>com.github.sdorra</groupId>
<artifactId>buildfrontend-maven-plugin</artifactId>

View File

@@ -1,3 +1,5 @@
const WorkerPlugin = require("worker-plugin");
module.exports = {
module: {
rules: [
@@ -38,8 +40,9 @@ module.exports = {
]
},
resolve: {
extensions: [
".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"
]
}
extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"]
},
plugins: [
new WorkerPlugin()
]
};

View File

@@ -26,6 +26,7 @@
"@types/enzyme": "^3.10.3",
"@types/fetch-mock": "^7.3.1",
"@types/jest": "^24.0.19",
"@types/lowlight": "^0.0.0",
"@types/query-string": "5",
"@types/react": "^16.9.9",
"@types/react-dom": "^16.9.2",
@@ -40,7 +41,8 @@
"raf": "^3.4.0",
"react-test-renderer": "^16.10.2",
"storybook-addon-i18next": "^1.2.1",
"typescript": "^3.7.2"
"typescript": "^3.7.2",
"worker-plugin": "^3.2.0"
},
"dependencies": {
"@scm-manager/ui-extensions": "^2.0.0-SNAPSHOT",
@@ -48,6 +50,8 @@
"classnames": "^2.2.6",
"date-fns": "^2.4.1",
"event-source-polyfill": "^1.0.9",
"gitdiff-parser": "^0.1.2",
"lowlight": "^1.13.0",
"query-string": "5",
"react": "^16.8.6",
"react-diff-view": "^2.4.1",
@@ -56,8 +60,7 @@
"react-markdown": "^4.0.6",
"react-router-dom": "^5.1.2",
"react-select": "^2.1.2",
"react-syntax-highlighter": "^11.0.2",
"gitdiff-parser": "^0.1.2"
"react-syntax-highlighter": "^11.0.2"
},
"babel": {
"presets": [

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,9 @@ import simpleDiff from "../__resources__/Diff.simple";
import hunksDiff from "../__resources__/Diff.hunks";
import binaryDiff from "../__resources__/Diff.binary";
import Button from "../buttons/Button";
import { DiffEventContext } from "./DiffTypes";
import { DiffEventContext, File } from "./DiffTypes";
import Toast from "../toast/Toast";
import { getPath } from "./diffs";
const diffFiles = parser.parse(simpleDiff);
@@ -57,4 +58,16 @@ storiesOf("Diff", module)
.add("Binaries", () => {
const binaryDiffFiles = parser.parse(binaryDiff);
return <Diff diff={binaryDiffFiles} />;
})
.add("SyntaxHighlighting", () => {
const filesWithLanguage = diffFiles.map((file: File) => {
const ext = getPath(file).split(".")[1];
if (ext === "tsx") {
file.language = "typescript";
} else {
file.language = ext;
}
return file;
});
return <Diff diff={filesWithLanguage} />;
});

View File

@@ -3,11 +3,12 @@ import { withTranslation, WithTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
// @ts-ignore
import { Diff as DiffComponent, getChangeKey, Hunk, Decoration } from "react-diff-view";
import { getChangeKey, Hunk, Decoration } from "react-diff-view";
import { Button, ButtonGroup } from "../buttons";
import Tag from "../Tag";
import Icon from "../Icon";
import { ChangeEvent, Change, File, Hunk as HunkType, DiffObjectProps } from "./DiffTypes";
import TokenizedDiffView from "./TokenizedDiffView";
const EMPTY_ANNOTATION_FACTORY = {};
@@ -57,33 +58,6 @@ const ChangeTypeTag = styled(Tag)`
margin-left: 0.75rem;
`;
const ModifiedDiffComponent = styled(DiffComponent)`
/* align line numbers */
& .diff-gutter {
text-align: right;
}
/* column sizing */
> colgroup .diff-gutter-col {
width: 3.25rem;
}
/* prevent following content from moving down */
> .diff-gutter:empty:hover::after {
font-size: 0.7rem;
}
/* smaller font size for code */
& .diff-line {
font-size: 0.75rem;
}
/* comment padding for sidebyside view */
&.split .diff-widget-content .is-indented-line {
padding-left: 3.25rem;
}
/* comment padding for combined view */
&.unified .diff-widget-content .is-indented-line {
padding-left: 6.5rem;
}
`;
class DiffFile extends React.Component<Props, State> {
static defaultProps: Partial<Props> = {
defaultCollapse: false,
@@ -264,9 +238,9 @@ class DiffFile extends React.Component<Props, State> {
body = (
<div className="panel-block is-paddingless">
{fileAnnotations}
<ModifiedDiffComponent className={viewType} viewType={viewType} hunks={file.hunks} diffType={file.type}>
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
{(hunks: HunkType[]) => this.concat(hunks.map(this.renderHunk))}
</ModifiedDiffComponent>
</TokenizedDiffView>
</div>
);
}

View File

@@ -18,6 +18,7 @@ export type File = {
oldPath: string;
oldRevision?: string;
type: FileChangeType;
language?: string;
// TODO does this property exists?
isBinary?: boolean;
};

View File

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

View File

@@ -0,0 +1,39 @@
// @ts-ignore we have no types for react-diff-view
import { tokenize } from "react-diff-view";
import refractor from "./refractorAdapter";
// the WorkerGlobalScope is assigned to self
// see https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/self
declare const self: Worker;
self.addEventListener("message", ({ data: { id, payload } }) => {
const { hunks, language } = payload;
const options = {
highlight: language !== "text",
language: language,
refractor
};
const doTokenization = (worker: Worker) => {
try {
const tokens = tokenize(hunks, options);
const payload = {
success: true,
tokens: tokens
};
worker.postMessage({ id, payload });
} catch (ex) {
const payload = {
success: false,
reason: ex.message
};
worker.postMessage({ id, payload });
}
};
const createTokenizer = (worker: Worker) => () => doTokenization(worker);
if (options.highlight) {
refractor.loadLanguage(language, createTokenizer(self));
}
});

View File

@@ -0,0 +1,67 @@
import React, { FC } from "react";
import styled from "styled-components";
// @ts-ignore we have no typings for react-diff-view
import { Diff, useTokenizeWorker } from "react-diff-view";
import { File } from "./DiffTypes";
// styling for the diff tokens
// this must be aligned with th style, which is used in the SyntaxHighlighter component
import "highlight.js/styles/arduino-light.css";
const DiffView = styled(Diff)`
/* align line numbers */
& .diff-gutter {
text-align: right;
}
/* column sizing */
> colgroup .diff-gutter-col {
width: 3.25rem;
}
/* prevent following content from moving down */
> .diff-gutter:empty:hover::after {
font-size: 0.7rem;
}
/* smaller font size for code */
& .diff-line {
font-size: 0.75rem;
}
/* comment padding for sidebyside view */
&.split .diff-widget-content .is-indented-line {
padding-left: 3.25rem;
}
/* comment padding for combined view */
&.unified .diff-widget-content .is-indented-line {
padding-left: 6.5rem;
}
`;
// WebWorker which creates tokens for syntax highlighting
const tokenize = new Worker("./Tokenize.worker.ts", { name: "tokenizer", type: "module" });
type Props = {
file: File;
viewType: "split" | "unified";
className?: string;
};
const determineLanguage = (file: File) => {
if (file.language) {
return file.language.toLowerCase();
}
return "text";
};
const TokenizedDiffView: FC<Props> = ({ file, viewType, className, children }) => {
const { tokens } = useTokenizeWorker(tokenize, {
hunks: file.hunks,
language: determineLanguage(file)
});
return (
<DiffView className={className} viewType={viewType} tokens={tokens} hunks={file.hunks} diffType={file.type}>
{children}
</DiffView>
);
};
export default TokenizedDiffView;

View File

@@ -0,0 +1,84 @@
import { createUrl, isDiffSupported } from "./ChangesetDiff";
describe("isDiffSupported tests", () => {
it("should return true if diff link is defined", () => {
const supported = isDiffSupported({
_links: {
diff: {
href: "http://diff"
}
}
});
expect(supported).toBe(true);
});
it("should return true if parsed diff link is defined", () => {
const supported = isDiffSupported({
_links: {
diffParsed: {
href: "http://diff"
}
}
});
expect(supported).toBe(true);
});
it("should return false if not diff link was provided", () => {
const supported = isDiffSupported({
_links: {}
});
expect(supported).toBe(false);
});
});
describe("createUrl tests", () => {
it("should return the diff url, if only diff url is defined", () => {
const url = createUrl({
_links: {
diff: {
href: "http://diff"
}
}
});
expect(url).toBe("http://diff?format=GIT");
});
it("should return the diff parsed url, if only diff parsed url is defined", () => {
const url = createUrl({
_links: {
diffParsed: {
href: "http://diff-parsed"
}
}
});
expect(url).toBe("http://diff-parsed");
});
it("should return the diff parsed url, if both diff links are defined", () => {
const url = createUrl({
_links: {
diff: {
href: "http://diff"
},
diffParsed: {
href: "http://diff-parsed"
}
}
});
expect(url).toBe("http://diff-parsed");
});
it("should throw an error if no diff link is defined", () => {
expect(() =>
createUrl({
_links: {}
})
).toThrow();
});
});

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Changeset, Link } from "@scm-manager/ui-types";
import { Changeset, Link, Collection } from "@scm-manager/ui-types";
import LoadingDiff from "../LoadingDiff";
import Notification from "../../Notification";
import { WithTranslation, withTranslation } from "react-i18next";
@@ -9,26 +9,27 @@ type Props = WithTranslation & {
defaultCollapse?: boolean;
};
export const isDiffSupported = (changeset: Collection) => {
return !!changeset._links.diff || !!changeset._links.diffParsed;
};
export const createUrl = (changeset: Collection) => {
if (changeset._links.diffParsed) {
return (changeset._links.diffParsed as Link).href;
} else if (changeset._links.diff) {
return (changeset._links.diff as Link).href + "?format=GIT";
}
throw new Error("diff link is missing");
};
class ChangesetDiff extends React.Component<Props> {
isDiffSupported(changeset: Changeset) {
return !!changeset._links.diff;
}
createUrl(changeset: Changeset) {
if (changeset._links.diff) {
const link = changeset._links.diff as Link;
return link.href + "?format=GIT";
}
throw new Error("diff link is missing");
}
render() {
const { changeset, defaultCollapse, t } = this.props;
if (!this.isDiffSupported(changeset)) {
if (!isDiffSupported(changeset)) {
return <Notification type="danger">{t("changeset.diffNotSupported")}</Notification>;
} else {
const url = this.createUrl(changeset);
return <LoadingDiff url={url} defaultCollapse={defaultCollapse} sideBySide={false}/>;
const url = createUrl(changeset);
return <LoadingDiff url={url} defaultCollapse={defaultCollapse} sideBySide={false} />;
}
}
}

View File

@@ -0,0 +1,36 @@
import lowlight from "lowlight/lib/core";
// adapter to let lowlight look like refractor
// this is required because react-diff-view does only support refractor,
// but we want same highlighting as in the source code browser.
const isLanguageRegistered = (lang: string) => {
// @ts-ignore listLanguages seems unknown to type
const registeredLanguages = lowlight.listLanguages();
return !!registeredLanguages[lang];
};
const loadLanguage = (lang: string, callback: () => void) => {
if (isLanguageRegistered(lang)) {
callback();
} else {
import(
/* webpackChunkName: "tokenizer-lowlight-[request]" */
`highlight.js/lib/languages/${lang}`
).then(loadedLanguage => {
lowlight.registerLanguage(lang, loadedLanguage.default);
callback();
});
}
};
const refractorAdapter = {
...lowlight,
isLanguageRegistered,
loadLanguage,
highlight: (value: string, language: string) => {
return lowlight.highlight(language, value).value;
}
};
export default refractorAdapter;

View File

@@ -9,7 +9,6 @@ const createNodeMock = (element: any) => {
querySelector: (selector: string) => {}
};
}
return null;
};
initStoryshots({
@@ -17,6 +16,7 @@ initStoryshots({
// fix snapshot tests with react-diff-view which uses a ref on tr
// @see https://github.com/storybookjs/storybook/pull/1090
test: snapshotWithOptions({
// @ts-ignore types seems not to match
createNodeMock
})
});

View File

@@ -12,7 +12,6 @@
"dependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "^0.1.3",
"babel-loader": "^8.0.6",
"cache-loader": "^4.1.0",
"css-loader": "^3.2.0",
"file-loader": "^4.2.0",
"mini-css-extract-plugin": "^0.8.0",
@@ -23,10 +22,10 @@
"sass-loader": "^8.0.0",
"script-loader": "^0.7.2",
"style-loader": "^1.0.0",
"thread-loader": "^2.1.3",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.1"
"webpack-dev-server": "^3.10.1",
"worker-plugin": "^3.2.0"
},
"eslintConfig": {
"extends": "@scm-manager/eslint-config",
@@ -36,5 +35,6 @@
},
"publishConfig": {
"access": "public"
}
},
"devDependencies": {}
}

View File

@@ -3,14 +3,28 @@ const createIndexMiddleware = require("./middleware/IndexMiddleware");
const createContextPathMiddleware = require("./middleware/ContextPathMiddleware");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
const WorkerPlugin = require("worker-plugin");
const isDevelopment = process.env.NODE_ENV === "development";
const root = path.resolve(process.cwd(), "scm-ui");
const babelPlugins = [];
const webpackPlugins = [new WorkerPlugin()];
let mode = "production";
if (isDevelopment) {
mode = "development";
babelPlugins.push(require.resolve("react-refresh/babel"));
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
webpackPlugins.push(new ReactRefreshWebpackPlugin());
}
console.log(`build ${mode} bundles`);
module.exports = [
{
mode: isDevelopment ? "development" : "production",
mode,
context: root,
entry: {
webapp: [path.resolve(__dirname, "webpack-public-path.js"), "./ui-webapp/src/index.tsx"]
@@ -34,18 +48,12 @@ module.exports = [
test: /\.(js|ts|jsx|tsx)$/i,
exclude: /node_modules/,
use: [
{
loader: "cache-loader"
},
{
loader: "thread-loader"
},
{
loader: "babel-loader",
options: {
cacheDirectory: true,
presets: ["@scm-manager/babel-preset"],
plugins: [isDevelopment && require.resolve("react-refresh/babel")].filter(Boolean)
plugins: babelPlugins
}
}
]
@@ -72,7 +80,8 @@ module.exports = [
},
output: {
path: path.join(root, "target", "assets"),
filename: "[name].bundle.js"
filename: "[name].bundle.js",
chunkFilename: "[name].bundle.js"
},
devServer: {
contentBase: path.join(root, "ui-webapp", "public"),
@@ -94,6 +103,7 @@ module.exports = [
},
optimization: {
runtimeChunk: "single",
namedChunks: true,
splitChunks: {
chunks: "all",
cacheGroups: {
@@ -110,7 +120,7 @@ module.exports = [
}
}
},
plugins: [isDevelopment && new ReactRefreshWebpackPlugin()].filter(Boolean)
plugins: webpackPlugins
},
{
context: root,

View File

@@ -278,13 +278,13 @@
<dependency>
<groupId>com.github.sdorra</groupId>
<artifactId>spotter-core</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>1.22</version>
<version>1.23</version>
</dependency>
<!-- class loader leak prevention -->

View File

@@ -122,7 +122,9 @@ public class ContentResource {
private void appendContentHeader(String path, byte[] head, Response.ResponseBuilder responseBuilder) {
ContentType contentType = ContentTypes.detect(path, head);
responseBuilder.header("Content-Type", contentType.getRaw());
contentType.getLanguage().ifPresent(language -> responseBuilder.header("X-Programming-Language", language));
contentType.getLanguage().ifPresent(
language -> responseBuilder.header(ProgrammingLanguages.HEADER, ProgrammingLanguages.getValue(language))
);
}
private byte[] getHead(String revision, String path, RepositoryService repositoryService) throws IOException {

View File

@@ -53,6 +53,12 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
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)) {
if (repositoryService.isSupported(Command.TAGS)) {
embeddedBuilder.with("tags", tagCollectionToDtoMapper.getTagDtoList(namespace, name,
@@ -62,16 +68,13 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
embeddedBuilder.with("branches", branchCollectionToDtoMapper.getBranchDtoList(namespace, name,
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)));
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);
return new ChangesetDto(linksBuilder.build(), embeddedBuilder.build());

View File

@@ -0,0 +1,69 @@
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 de.otto.edison.hal.Links;
import lombok.Data;
import java.util.List;
@Data
public class DiffResultDto extends HalRepresentation {
public DiffResultDto(Links links) {
super(links);
}
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,148 @@
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 com.google.inject.Inject;
import sonia.scm.repository.Repository;
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;
import static de.otto.edison.hal.Links.linkingTo;
/**
* TODO conflicts, copy and rename
*/
class DiffResultToDiffResultDtoMapper {
private final ResourceLinks resourceLinks;
@Inject
DiffResultToDiffResultDtoMapper(ResourceLinks resourceLinks) {
this.resourceLinks = resourceLinks;
}
public DiffResultDto mapForIncoming(Repository repository, DiffResult result, String source, String target) {
DiffResultDto dto = new DiffResultDto(linkingTo().self(resourceLinks.incoming().diffParsed(repository.getNamespace(), repository.getName(), source, target)).build());
setFiles(result, dto);
return dto;
}
public DiffResultDto mapForRevision(Repository repository, DiffResult result, String revision) {
DiffResultDto dto = new DiffResultDto(linkingTo().self(resourceLinks.diff().parsed(repository.getNamespace(), repository.getName(), revision)).build());
setFiles(result, dto);
return dto;
}
private void setFiles(DiffResult result, DiffResultDto dto) {
List<DiffResultDto.FileDto> files = new ArrayList<>();
for (DiffFile file : result) {
files.add(mapFile(file));
}
dto.setFiles(files);
}
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(ProgrammingLanguages.getValue(value)));
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.api.DiffCommandBuilder;
import sonia.scm.repository.api.DiffFormat;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.HttpUtil;
@@ -30,10 +31,12 @@ public class DiffRootResource {
static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED";
private final RepositoryServiceFactory serviceFactory;
private final DiffResultToDiffResultDtoMapper parsedDiffMapper;
@Inject
public DiffRootResource(RepositoryServiceFactory serviceFactory) {
public DiffRootResource(RepositoryServiceFactory serviceFactory, DiffResultToDiffResultDtoMapper parsedDiffMapper) {
this.serviceFactory = serviceFactory;
this.parsedDiffMapper = parsedDiffMapper;
}
@@ -70,4 +73,23 @@ public class DiffRootResource {
.build();
}
}
@GET
@Path("{revision}/parsed")
@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 DiffResultDto 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 parsedDiffMapper.mapForRevision(repositoryService.getRepository(), diffResult, revision);
}
}
}

View File

@@ -12,6 +12,7 @@ import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.api.DiffCommandBuilder;
import sonia.scm.repository.api.DiffFormat;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.HttpUtil;
@@ -33,16 +34,16 @@ import static sonia.scm.api.v2.resources.DiffRootResource.HEADER_CONTENT_DISPOSI
public class IncomingRootResource {
private final RepositoryServiceFactory serviceFactory;
private final IncomingChangesetCollectionToDtoMapper mapper;
private final IncomingChangesetCollectionToDtoMapper changesetMapper;
private final DiffResultToDiffResultDtoMapper parsedDiffMapper;
@Inject
public IncomingRootResource(RepositoryServiceFactory serviceFactory, IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper) {
public IncomingRootResource(RepositoryServiceFactory serviceFactory, IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper, DiffResultToDiffResultDtoMapper parsedDiffMapper) {
this.serviceFactory = serviceFactory;
this.mapper = incomingChangesetCollectionToDtoMapper;
this.changesetMapper = incomingChangesetCollectionToDtoMapper;
this.parsedDiffMapper = parsedDiffMapper;
}
/**
@@ -109,7 +110,7 @@ public class IncomingRootResource {
.getChangesets();
if (changesets != null && changesets.getChangesets() != null) {
PageResult<Changeset> pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal());
return Response.ok(mapper.map(page, pageSize, pageResult, repository, source, target)).build();
return Response.ok(changesetMapper.map(page, pageSize, pageResult, repository, source, target)).build();
} else {
return Response.ok().build();
}
@@ -150,4 +151,30 @@ public class IncomingRootResource {
.build();
}
}
@GET
@Path("{source}/{target}/diff/parsed")
@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, source or target branch for the repository not available or repository not found"),
@ResponseCode(code = 500, condition = "internal server error")
})
public Response incomingDiffParsed(@PathParam("namespace") String namespace,
@PathParam("name") String name,
@PathParam("source") String source,
@PathParam("target") String target) throws IOException {
HttpUtil.checkForCRLFInjection(source);
HttpUtil.checkForCRLFInjection(target);
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
DiffResult diffResult = repositoryService.getDiffResultCommand()
.setRevision(source)
.setAncestorChangeset(target)
.getDiffResult();
return Response.ok(parsedDiffMapper.mapForIncoming(repositoryService.getRepository(), diffResult, source, target)).build();
}
}
}

View File

@@ -0,0 +1,24 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.Language;
import java.util.Optional;
final class ProgrammingLanguages {
static final String HEADER = "X-Programming-Language";
private static final String DEFAULT = "text";
private ProgrammingLanguages() {
}
static String getValue(Language language) {
Optional<String> aceMode = language.getAceMode();
if (!aceMode.isPresent()) {
Optional<String> codemirrorMode = language.getCodemirrorMode();
return codemirrorMode.orElse(DEFAULT);
}
return aceMode.get();
}
}

View File

@@ -62,6 +62,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
if (repositoryService.isSupported(Feature.INCOMING_REVISION)) {
linksBuilder.single(link("incomingChangesets", resourceLinks.incoming().changesets(repository.getNamespace(), repository.getName())));
linksBuilder.single(link("incomingDiff", resourceLinks.incoming().diff(repository.getNamespace(), repository.getName())));
linksBuilder.single(link("incomingDiffParsed", resourceLinks.incoming().diffParsed(repository.getNamespace(), repository.getName())));
}
}
linksBuilder.single(link("changesets", resourceLinks.changeset().all(repository.getNamespace(), repository.getName())));

View File

@@ -362,6 +362,10 @@ class ResourceLinks {
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) {
return diffLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("diff").parameters().method("getAll").parameters().href();
}
@@ -412,7 +416,21 @@ class ResourceLinks {
public String diff(String namespace, String name) {
return toTemplateParams(incomingLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("incoming").parameters().method("incomingDiff").parameters("source", "target").href());
}
public String diffParsed(String namespace, String name) {
return toTemplateParams(diffParsed(namespace, name, "source", "target"));
}
public String diffParsed(String namespace, String name, String source, String target) {
return incomingLinkBuilder
.method("getRepositoryResource")
.parameters(namespace, name)
.method("incoming")
.parameters()
.method("incomingDiffParsed")
.parameters(source, target)
.href();
}
public String toTemplateParams(String href) {

View File

@@ -92,7 +92,7 @@ public class ContentResourceTest {
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go");
assertEquals(200, response.getStatus());
assertEquals("GO", response.getHeaderString("X-Programming-Language"));
assertEquals("golang", response.getHeaderString("X-Programming-Language"));
assertEquals("text/x-go", response.getHeaderString("Content-Type"));
}
@@ -103,7 +103,7 @@ public class ContentResourceTest {
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile");
assertEquals(200, response.getStatus());
assertEquals("DOCKERFILE", response.getHeaderString("X-Programming-Language"));
assertEquals("dockerfile", response.getHeaderString("X-Programming-Language"));
assertEquals("text/plain", response.getHeaderString("Content-Type"));
}

View File

@@ -2,6 +2,7 @@ package sonia.scm.api.v2.resources;
import com.google.inject.util.Providers;
import de.otto.edison.hal.Links;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
@@ -13,6 +14,7 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.NotFoundException;
@@ -20,6 +22,8 @@ import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.DiffCommandBuilder;
import sonia.scm.repository.api.DiffFormat;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.DiffResultCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.util.CRLFInjectionException;
@@ -34,7 +38,6 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -45,6 +48,7 @@ public class DiffResourceTest extends RepositoryTestBase {
public static final String DIFF_PATH = "space/repo/diff/";
public static final String DIFF_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + DIFF_PATH;
public static final Repository REPOSITORY = new Repository("repoId", "git", "space", "repo");
private RestDispatcher dispatcher = new RestDispatcher();
@@ -54,8 +58,13 @@ public class DiffResourceTest extends RepositoryTestBase {
@Mock
private RepositoryService service;
@Mock
@Mock(answer = Answers.RETURNS_SELF)
private DiffCommandBuilder diffCommandBuilder;
@Mock(answer = Answers.RETURNS_SELF)
private DiffResultCommandBuilder diffResultCommandBuilder;
@Mock
private DiffResultToDiffResultDtoMapper diffResultToDiffResultDtoMapper;
private DiffRootResource diffRootResource;
@@ -66,15 +75,16 @@ public class DiffResourceTest extends RepositoryTestBase {
@Before
public void prepareEnvironment() {
diffRootResource = new DiffRootResource(serviceFactory);
diffRootResource = new DiffRootResource(serviceFactory, diffResultToDiffResultDtoMapper);
super.diffRootResource = Providers.of(diffRootResource);
dispatcher.addSingletonResource(getRepositoryRootResource());
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service);
when(serviceFactory.create(any(Repository.class))).thenReturn(service);
when(service.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo"));
when(service.getRepository()).thenReturn(REPOSITORY);
ExceptionWithContextToErrorDtoMapperImpl mapper = new ExceptionWithContextToErrorDtoMapperImpl();
dispatcher.registerException(CRLFInjectionException.class, Response.Status.BAD_REQUEST);
when(service.getDiffCommand()).thenReturn(diffCommandBuilder);
when(service.getDiffResultCommand()).thenReturn(diffResultCommandBuilder);
subjectThreadState.bind();
ThreadContext.bind(subject);
when(subject.isPermitted(any(String.class))).thenReturn(true);
@@ -87,8 +97,6 @@ public class DiffResourceTest extends RepositoryTestBase {
@Test
public void shouldGetDiffs() throws Exception {
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {});
MockHttpRequest request = MockHttpRequest
.get(DIFF_URL + "revision")
@@ -106,6 +114,25 @@ public class DiffResourceTest extends RepositoryTestBase {
.contains(expectedValue);
}
@Test
public void shouldGetParsedDiffs() throws Exception {
DiffResult diffResult = mock(DiffResult.class);
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
when(diffResultToDiffResultDtoMapper.mapForRevision(REPOSITORY, diffResult, "revision"))
.thenReturn(new DiffResultDto(Links.linkingTo().self("http://self").build()));
MockHttpRequest request = MockHttpRequest
.get(DIFF_URL + "revision/parsed")
.accept(VndMediaType.DIFF_PARSED);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertThat(response.getStatus())
.isEqualTo(200);
assertThat(response.getContentAsString())
.contains("\"self\":{\"href\":\"http://self\"}");
}
@Test
public void shouldGet404OnMissingRepository() throws URISyntaxException {
when(serviceFactory.create(any(NamespaceAndName.class))).thenThrow(new NotFoundException("Text", "x"));
@@ -119,8 +146,6 @@ public class DiffResourceTest extends RepositoryTestBase {
@Test
public void shouldGet404OnMissingRevision() throws Exception {
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x"));
MockHttpRequest request = MockHttpRequest
@@ -135,8 +160,6 @@ public class DiffResourceTest extends RepositoryTestBase {
@Test
public void shouldGet400OnCrlfInjection() throws Exception {
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x"));
MockHttpRequest request = MockHttpRequest
@@ -149,8 +172,6 @@ public class DiffResourceTest extends RepositoryTestBase {
@Test
public void shouldGet400OnUnknownFormat() throws Exception {
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Test", "test"));
MockHttpRequest request = MockHttpRequest
@@ -163,8 +184,6 @@ public class DiffResourceTest extends RepositoryTestBase {
@Test
public void shouldAcceptDiffFormats() throws Exception {
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {});
Arrays.stream(DiffFormat.values()).map(DiffFormat::name).forEach(

View File

@@ -0,0 +1,206 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Link;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
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.Optional;
import java.util.OptionalInt;
import static java.net.URI.create;
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 {
private static final Repository REPOSITORY = new Repository("1", "git", "space", "X");
ResourceLinks resourceLinks = ResourceLinksMock.createMock(create("/scm/api/v2"));
DiffResultToDiffResultDtoMapper mapper = new DiffResultToDiffResultDtoMapper(resourceLinks);
@Test
void shouldMapDiffResult() {
DiffResultDto dto = mapper.mapForRevision(REPOSITORY, createResult(), "123");
List<DiffResultDto.FileDto> files = dto.getFiles();
assertAddedFile(files.get(0), "A.java", "abc", "java");
assertModifiedFile(files.get(1), "B.ts", "abc", "def", "typescript");
assertDeletedFile(files.get(2), "C.go", "ghi", "golang");
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);
}
@Test
void shouldCreateSelfLinkForRevision() {
DiffResultDto dto = mapper.mapForRevision(REPOSITORY, createResult(), "123");
Optional<Link> selfLink = dto.getLinks().getLinkBy("self");
assertThat(selfLink)
.isPresent()
.get()
.extracting("href")
.contains("/scm/api/v2/repositories/space/X/diff/123/parsed");
}
@Test
void shouldCreateSelfLinkForIncoming() {
DiffResultDto dto = mapper.mapForIncoming(REPOSITORY, createResult(), "feature/some", "master");
Optional<Link> selfLink = dto.getLinks().getLinkBy("self");
assertThat(selfLink)
.isPresent()
.get()
.extracting("href")
.contains("/scm/api/v2/repositories/space/X/incoming/feature%2Fsome/master/diff/parsed");
}
private DiffResult createResult() {
return result(
addedFile("A.java", "abc"),
modifiedFile("B.ts", "def", "abc",
hunk("@@ -3,4 1,2 @@", 1, 2, 3, 4,
insertedLine("a", 1),
modifiedLine("b", 2),
deletedLine("c", 3)
)
),
deletedFile("C.go", "ghi")
);
}
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;
}
}

View File

@@ -2,6 +2,7 @@ package sonia.scm.api.v2.resources;
import com.google.inject.util.Providers;
import de.otto.edison.hal.Links;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
@@ -24,6 +25,8 @@ import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Person;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.DiffCommandBuilder;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.DiffResultCommandBuilder;
import sonia.scm.repository.api.LogCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -45,6 +48,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static sonia.scm.repository.api.DiffFormat.NATIVE;
@RunWith(MockitoJUnitRunner.Silent.class)
@Slf4j
@@ -54,6 +58,7 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
public static final String INCOMING_PATH = "space/repo/incoming/";
public static final String INCOMING_CHANGESETS_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH;
public static final String INCOMING_DIFF_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + INCOMING_PATH;
public static final Repository REPOSITORY = new Repository("repoId", "git", "space", "repo");
private RestDispatcher dispatcher = new RestDispatcher();
@@ -71,7 +76,11 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
@Mock
private DiffCommandBuilder diffCommandBuilder;
@Mock
private DiffResultCommandBuilder diffResultCommandBuilder;
@Mock
private DiffResultToDiffResultDtoMapper diffResultToDiffResultDtoMapper;
private IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper;
@@ -88,14 +97,15 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
@Before
public void prepareEnvironment() {
incomingChangesetCollectionToDtoMapper = new IncomingChangesetCollectionToDtoMapper(changesetToChangesetDtoMapper, resourceLinks);
incomingRootResource = new IncomingRootResource(serviceFactory, incomingChangesetCollectionToDtoMapper);
incomingRootResource = new IncomingRootResource(serviceFactory, incomingChangesetCollectionToDtoMapper, diffResultToDiffResultDtoMapper);
super.incomingRootResource = Providers.of(incomingRootResource);
dispatcher.addSingletonResource(getRepositoryRootResource());
when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService);
when(serviceFactory.create(any(Repository.class))).thenReturn(repositoryService);
when(repositoryService.getRepository()).thenReturn(new Repository("repoId", "git", "space", "repo"));
when(serviceFactory.create(REPOSITORY)).thenReturn(repositoryService);
when(repositoryService.getRepository()).thenReturn(REPOSITORY);
when(repositoryService.getLogCommand()).thenReturn(logCommandBuilder);
when(repositoryService.getDiffCommand()).thenReturn(diffCommandBuilder);
when(repositoryService.getDiffResultCommand()).thenReturn(diffResultCommandBuilder);
dispatcher.registerException(CRLFInjectionException.class, Response.Status.BAD_REQUEST);
subjectThreadState.bind();
ThreadContext.bind(subject);
@@ -170,9 +180,9 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
@Test
public void shouldGetDiffs() throws Exception {
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setRevision("src_changeset_id")).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setAncestorChangeset("target_changeset_id")).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.setFormat(NATIVE)).thenReturn(diffCommandBuilder);
when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {});
MockHttpRequest request = MockHttpRequest
.get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff")
@@ -190,6 +200,28 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
.contains(expectedValue);
}
@Test
public void shouldGetParsedDiffs() throws Exception {
when(diffResultCommandBuilder.setRevision("src_changeset_id")).thenReturn(diffResultCommandBuilder);
when(diffResultCommandBuilder.setAncestorChangeset("target_changeset_id")).thenReturn(diffResultCommandBuilder);
DiffResult diffResult = mock(DiffResult.class);
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
when(diffResultToDiffResultDtoMapper.mapForIncoming(REPOSITORY, diffResult, "src_changeset_id", "target_changeset_id"))
.thenReturn(new DiffResultDto(Links.linkingTo().self("http://self").build()));
MockHttpRequest request = MockHttpRequest
.get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff/parsed")
.accept(VndMediaType.DIFF_PARSED);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertThat(response.getStatus())
.isEqualTo(200);
assertThat(response.getContentAsString())
.contains("\"self\":{\"href\":\"http://self\"}");
}
@Test
public void shouldGet404OnMissingRepository() throws URISyntaxException {
when(serviceFactory.create(any(NamespaceAndName.class))).thenThrow(new NotFoundException("Text", "x"));

View File

@@ -0,0 +1,25 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.Language;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ProgrammingLanguagesTest {
@Test
void shouldReturnAceModeIfPresent() {
assertThat(ProgrammingLanguages.getValue(Language.GO)).isEqualTo("golang");
assertThat(ProgrammingLanguages.getValue(Language.JAVA)).isEqualTo("java");
}
@Test
void shouldReturnCodemirrorIfAceModeIsMissing() {
assertThat(ProgrammingLanguages.getValue(Language.HTML_ECR)).isEqualTo("htmlmixed");
}
@Test
void shouldReturnTextIfNoModeIsPresent() {
assertThat(ProgrammingLanguages.getValue(Language.HXML)).isEqualTo("text");
}
}

3314
yarn.lock

File diff suppressed because it is too large Load Diff