Hyperlink Git commits to GitHub (#887)
This modifies the `nodeDescription` code for the Git plugin so that when given a Git commit, it will hyperlink to that commit on GitHub. It does this by looking up the corresponding `RepoId`s from the newly-added `commitToRepoId` field in the `Repository` (#884). Per a [suggestion in review], rather than hardcoding the GitHub url logic in the Git plugin, we provide them via a `GitGateway`. [suggestion in review]: https://github.com/sourcecred/sourcecred/pull/887#issuecomment-424059649 When no `RepoId` is found, it errors to console and does not include a hyperlink. When multiple `RepoId`s are available, it chooses to link to one arbitrarily. (In the future, we could amend this behavior to add links to every valid repo). This behavior is tested. Test plan: I ran the application on newly-generated data and verified that it sets up commit hyperlinks appropriately. Also, see unit tests.
This commit is contained in:
parent
65d811fb44
commit
4a374d755e
|
@ -1,6 +1,7 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
- Hyperlink Git commits to GitHub (#887)
|
||||||
- Relicense from MIT to MIT + Apache-2 (#812)
|
- Relicense from MIT to MIT + Apache-2 (#812)
|
||||||
- Display short hash + summary for commits (#879)
|
- Display short hash + summary for commits (#879)
|
||||||
- Hyperlink to GitHub entities (#860)
|
- Hyperlink to GitHub entities (#860)
|
||||||
|
|
|
@ -3,7 +3,11 @@
|
||||||
import {StaticAdapterSet} from "./adapterSet";
|
import {StaticAdapterSet} from "./adapterSet";
|
||||||
import {StaticPluginAdapter as GithubAdapter} from "../../plugins/github/pluginAdapter";
|
import {StaticPluginAdapter as GithubAdapter} from "../../plugins/github/pluginAdapter";
|
||||||
import {StaticPluginAdapter as GitAdapter} from "../../plugins/git/pluginAdapter";
|
import {StaticPluginAdapter as GitAdapter} from "../../plugins/git/pluginAdapter";
|
||||||
|
import {GithubGitGateway} from "../../plugins/github/githubGitGateway";
|
||||||
|
|
||||||
export function defaultStaticAdapters(): StaticAdapterSet {
|
export function defaultStaticAdapters(): StaticAdapterSet {
|
||||||
return new StaticAdapterSet([new GithubAdapter(), new GitAdapter()]);
|
return new StaticAdapterSet([
|
||||||
|
new GithubAdapter(),
|
||||||
|
new GitAdapter(new GithubGitGateway()),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`plugins/git/render commit snapshots as expected 1`] = `"3715ddf: This is an example commit"`;
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import type {RepoId} from "../../core/repoId";
|
||||||
|
import type {Hash} from "./types";
|
||||||
|
|
||||||
|
export type URL = string;
|
||||||
|
|
||||||
|
// Interface for adapting to a Git hosting provider, e.g. GitHub
|
||||||
|
export interface GitGateway {
|
||||||
|
// URL to permalink to an individual commit
|
||||||
|
commitUrl(repo: RepoId, hash: Hash): URL;
|
||||||
|
}
|
|
@ -10,8 +10,14 @@ import {description} from "./render";
|
||||||
import type {Assets} from "../../app/assets";
|
import type {Assets} from "../../app/assets";
|
||||||
import type {RepoId} from "../../core/repoId";
|
import type {RepoId} from "../../core/repoId";
|
||||||
import type {Repository} from "./types";
|
import type {Repository} from "./types";
|
||||||
|
import type {GitGateway} from "./gitGateway";
|
||||||
|
|
||||||
export class StaticPluginAdapter implements IStaticPluginAdapter {
|
export class StaticPluginAdapter implements IStaticPluginAdapter {
|
||||||
|
_gitGateway: GitGateway;
|
||||||
|
|
||||||
|
constructor(gg: GitGateway): void {
|
||||||
|
this._gitGateway = gg;
|
||||||
|
}
|
||||||
name() {
|
name() {
|
||||||
return "Git";
|
return "Git";
|
||||||
}
|
}
|
||||||
|
@ -65,16 +71,22 @@ export class StaticPluginAdapter implements IStaticPluginAdapter {
|
||||||
loadGraph(),
|
loadGraph(),
|
||||||
loadRepository(),
|
loadRepository(),
|
||||||
]);
|
]);
|
||||||
return new DynamicPluginAdapter(graph, repository);
|
return new DynamicPluginAdapter(this._gitGateway, graph, repository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DynamicPluginAdapter implements IDynamicPluginAdapter {
|
class DynamicPluginAdapter implements IDynamicPluginAdapter {
|
||||||
+_graph: Graph;
|
+_graph: Graph;
|
||||||
+_repository: Repository;
|
+_repository: Repository;
|
||||||
constructor(graph: Graph, repository: Repository) {
|
+_gitGateway: GitGateway;
|
||||||
|
constructor(
|
||||||
|
gitGateway: GitGateway,
|
||||||
|
graph: Graph,
|
||||||
|
repository: Repository
|
||||||
|
): void {
|
||||||
this._graph = graph;
|
this._graph = graph;
|
||||||
this._repository = repository;
|
this._repository = repository;
|
||||||
|
this._gitGateway = gitGateway;
|
||||||
}
|
}
|
||||||
graph() {
|
graph() {
|
||||||
return this._graph;
|
return this._graph;
|
||||||
|
@ -83,9 +95,9 @@ class DynamicPluginAdapter implements IDynamicPluginAdapter {
|
||||||
// This cast is unsound, and might throw at runtime, but won't have
|
// This cast is unsound, and might throw at runtime, but won't have
|
||||||
// silent failures or cause problems down the road.
|
// silent failures or cause problems down the road.
|
||||||
const address = N.fromRaw((node: any));
|
const address = N.fromRaw((node: any));
|
||||||
return description(address, this._repository);
|
return description(address, this._repository, this._gitGateway);
|
||||||
}
|
}
|
||||||
static() {
|
static() {
|
||||||
return new StaticPluginAdapter();
|
return new StaticPluginAdapter(this._gitGateway);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "../../app/Link";
|
||||||
import * as N from "./nodes";
|
import * as N from "./nodes";
|
||||||
import type {Repository} from "./types";
|
import type {Repository} from "./types";
|
||||||
|
import {type RepoIdString, stringToRepoId} from "../../core/repoId";
|
||||||
|
import type {GitGateway} from "./gitGateway";
|
||||||
|
|
||||||
export function description(
|
export function description(
|
||||||
address: N.StructuredAddress,
|
address: N.StructuredAddress,
|
||||||
repository: Repository
|
repository: Repository,
|
||||||
|
gateway: GitGateway
|
||||||
) {
|
) {
|
||||||
switch (address.type) {
|
switch (address.type) {
|
||||||
case "COMMIT": {
|
case "COMMIT": {
|
||||||
|
@ -13,12 +18,40 @@ export function description(
|
||||||
const commit = repository.commits[hash];
|
const commit = repository.commits[hash];
|
||||||
if (commit == null) {
|
if (commit == null) {
|
||||||
console.error(`Unable to find data for commit ${hash}`);
|
console.error(`Unable to find data for commit ${hash}`);
|
||||||
return hash;
|
return <code>{hash}</code>;
|
||||||
}
|
}
|
||||||
const {shortHash, summary} = commit;
|
// This `any`-cast courtesy of facebook/flow#6927.
|
||||||
return `${shortHash}: ${summary}`;
|
const repoIdStrings: $ReadOnlyArray<RepoIdString> = (Object.keys(
|
||||||
|
repository.commitToRepoId[hash] || {}
|
||||||
|
): any);
|
||||||
|
if (repoIdStrings.length === 0) {
|
||||||
|
console.error(`Unable to find repoIds for commit ${hash}`);
|
||||||
|
// TODO(@wchargin): This shortHash is unambiguous for a single repo,
|
||||||
|
// but might be ambiguous across many repositories. Consider disambiguating
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<code>{commit.shortHash}</code>: {commit.summary}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const repoId = stringToRepoId(repoIdStrings[0]);
|
||||||
|
const url = gateway.commitUrl(repoId, commit.hash);
|
||||||
|
const hyperlinkedHash = hyperlink(url, commit.shortHash);
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<code>{hyperlinkedHash}</code>: {commit.summary}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`unknown type: ${(address.type: empty)}`);
|
throw new Error(`unknown type: ${(address.type: empty)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hyperlink(url, text) {
|
||||||
|
return (
|
||||||
|
<Link href={url} target="_blank" rel="nofollow noopener">
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,40 +1,132 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import {makeRepoId} from "../../core/repoId";
|
import * as N from "./nodes";
|
||||||
import * as GN from "./nodes";
|
import {shallow} from "enzyme";
|
||||||
import {description} from "./render";
|
import {description} from "./render";
|
||||||
import type {Repository} from "./types";
|
import {type RepoId, repoIdToString, makeRepoId} from "../../core/repoId";
|
||||||
|
import type {Repository, Hash, Commit} from "./types";
|
||||||
|
import type {GitGateway, URL} from "./gitGateway";
|
||||||
|
import Link from "../../app/Link";
|
||||||
|
|
||||||
|
require("../../app/testUtil").configureEnzyme();
|
||||||
|
|
||||||
describe("plugins/git/render", () => {
|
describe("plugins/git/render", () => {
|
||||||
const exampleHash = "3715ddfb8d4c4fd2a6f6af75488c82f84c92ec2f";
|
const repoId1 = makeRepoId("example-owner", "1");
|
||||||
const exampleCommit: GN.CommitAddress = Object.freeze({
|
const repoId2 = makeRepoId("example-owner", "2");
|
||||||
type: GN.COMMIT_TYPE,
|
const singleRepoCommit = {
|
||||||
hash: exampleHash,
|
hash: "singleRepoCommit",
|
||||||
});
|
shortHash: "singleRepo",
|
||||||
|
summary: "A simple example commit",
|
||||||
|
parentHashes: [],
|
||||||
|
};
|
||||||
|
const twoRepoCommit = {
|
||||||
|
hash: "twoRepoCommit",
|
||||||
|
shortHash: "twoRepo",
|
||||||
|
summary: "Two repos claim dominion over this commit",
|
||||||
|
parentHashes: [],
|
||||||
|
};
|
||||||
|
const noRepoCommit = {
|
||||||
|
hash: "noRepoCommit",
|
||||||
|
shortHash: "noRepo",
|
||||||
|
summary: "commitToRepoId has no memory of this commit ",
|
||||||
|
parentHashes: [],
|
||||||
|
};
|
||||||
|
const zeroRepoCommit = {
|
||||||
|
hash: "zeroRepoCommit",
|
||||||
|
shortHash: "zeroRepo",
|
||||||
|
summary: "This commit has exactly zero repoIds matching",
|
||||||
|
parentHashes: [],
|
||||||
|
};
|
||||||
|
const unregisteredCommit = {
|
||||||
|
hash: "unregisteredCommit",
|
||||||
|
shortHash: "unregistered",
|
||||||
|
summary: "This commit isn't in the Repository",
|
||||||
|
parentHashes: [],
|
||||||
|
};
|
||||||
const exampleRepository: Repository = Object.freeze({
|
const exampleRepository: Repository = Object.freeze({
|
||||||
commits: {
|
commits: {
|
||||||
[exampleHash]: {
|
zeroRepoCommit,
|
||||||
hash: exampleHash,
|
singleRepoCommit,
|
||||||
shortHash: exampleHash.slice(0, 7),
|
twoRepoCommit,
|
||||||
summary: "This is an example commit",
|
noRepoCommit,
|
||||||
parentHashes: [],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
commitToRepoId: {
|
commitToRepoId: {
|
||||||
[exampleHash]: {[(makeRepoId("sourcecred", "example-git"): any)]: true},
|
zeroRepoCommit: {},
|
||||||
|
singleRepoCommit: {[(repoIdToString(repoId1): any)]: true},
|
||||||
|
twoRepoCommit: {
|
||||||
|
[(repoIdToString(repoId1): any)]: true,
|
||||||
|
[(repoIdToString(repoId2): any)]: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
it("commit snapshots as expected", () => {
|
const exampleGitGateway: GitGateway = Object.freeze({
|
||||||
expect(description(exampleCommit, exampleRepository)).toMatchSnapshot();
|
commitUrl(repo: RepoId, hash: Hash): URL {
|
||||||
|
return repoIdToString(repo) + "/" + hash;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderExample(commit: Commit) {
|
||||||
|
const commitAddress = {type: N.COMMIT_TYPE, hash: commit.hash};
|
||||||
|
return shallow(
|
||||||
|
description(commitAddress, exampleRepository, exampleGitGateway)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("handles a commit in exactly one repo", () => {
|
||||||
|
const el = renderExample(singleRepoCommit);
|
||||||
|
const link = el.find(Link);
|
||||||
|
const expectedUrl = exampleGitGateway.commitUrl(
|
||||||
|
repoId1,
|
||||||
|
singleRepoCommit.hash
|
||||||
|
);
|
||||||
|
expect(link.props().href).toEqual(expectedUrl);
|
||||||
|
expect(link.props().children).toEqual(singleRepoCommit.shortHash);
|
||||||
|
expect(el.text()).toContain(singleRepoCommit.summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links to a single commit if it is in multiple repos", () => {
|
||||||
|
const el = renderExample(twoRepoCommit);
|
||||||
|
const link = el.find(Link);
|
||||||
|
const expectedUrl = exampleGitGateway.commitUrl(
|
||||||
|
repoId1,
|
||||||
|
twoRepoCommit.hash
|
||||||
|
);
|
||||||
|
expect(link.props().href).toEqual(expectedUrl);
|
||||||
|
expect(link.props().children).toEqual(twoRepoCommit.shortHash);
|
||||||
|
expect(el.text()).toContain(twoRepoCommit.summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error for a commit without any RepoIds", () => {
|
||||||
|
// noRepoCommit: It has no entry in the repo tracker
|
||||||
|
// zeroRepoCommit: it has an empty entry in the repo tracker
|
||||||
|
// (behavior should be the same)
|
||||||
|
for (const commit of [noRepoCommit, zeroRepoCommit]) {
|
||||||
|
const el = renderExample(commit);
|
||||||
|
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
`Unable to find repoIds for commit ${commit.hash}`
|
||||||
|
);
|
||||||
|
// $ExpectFlowError
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
|
expect(el.find(Link)).toHaveLength(0);
|
||||||
|
expect(el.find("code").text()).toEqual(commit.shortHash);
|
||||||
|
expect(el.text()).toContain(commit.summary);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
it("logs an error for a commit not in the repository", () => {
|
it("logs an error for a commit not in the repository", () => {
|
||||||
const badCommit = {type: GN.COMMIT_TYPE, hash: "1234"};
|
const el = renderExample(unregisteredCommit);
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
`Unable to find data for commit ${unregisteredCommit.hash}`
|
||||||
|
);
|
||||||
// $ExpectFlowError
|
// $ExpectFlowError
|
||||||
console.error = jest.fn();
|
console.error = jest.fn();
|
||||||
expect(description(badCommit, exampleRepository)).toBe("1234");
|
|
||||||
expect(console.error).toHaveBeenCalledWith(
|
expect(el.find(Link)).toHaveLength(0);
|
||||||
"Unable to find data for commit 1234"
|
// Has the full hash, b.c. short hash couldn't be found
|
||||||
);
|
expect(el.find("code").text()).toEqual(unregisteredCommit.hash);
|
||||||
|
// No summary, as the data wasnt available
|
||||||
|
expect(el.text()).not.toContain(unregisteredCommit.summary);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import {type RepoId} from "../../core/repoId";
|
||||||
|
import type {Hash} from "../git/types";
|
||||||
|
import type {GitGateway, URL} from "../git/gitGateway";
|
||||||
|
|
||||||
|
export class GithubGitGateway implements GitGateway {
|
||||||
|
commitUrl(repoId: RepoId, hash: Hash): URL {
|
||||||
|
return `https://github.com/${repoId.owner}/${repoId.name}/commit/${hash}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import {makeRepoId} from "../../core/repoId";
|
||||||
|
import {GithubGitGateway} from "./githubGitGateway";
|
||||||
|
|
||||||
|
describe("src/plugins/github/githubGitGateway", () => {
|
||||||
|
describe("commitUrl", () => {
|
||||||
|
it("works for a simple example", () => {
|
||||||
|
const repoId = makeRepoId("sourcecred", "example-github");
|
||||||
|
const hash = "ec91adb718a6";
|
||||||
|
const url = new GithubGitGateway().commitUrl(repoId, hash);
|
||||||
|
expect(url).toMatchInlineSnapshot(
|
||||||
|
`"https://github.com/sourcecred/example-github/commit/ec91adb718a6"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -154,7 +154,7 @@ export class StaticPluginAdapter implements IStaticPluginAdapter {
|
||||||
class DynamicPluginAdapter implements IDynamicPluginAdapater {
|
class DynamicPluginAdapter implements IDynamicPluginAdapater {
|
||||||
+_view: RelationalView;
|
+_view: RelationalView;
|
||||||
+_graph: Graph;
|
+_graph: Graph;
|
||||||
constructor(view: RelationalView, graph: Graph) {
|
constructor(view: RelationalView, graph: Graph): void {
|
||||||
this._view = view;
|
this._view = view;
|
||||||
this._graph = graph;
|
this._graph = graph;
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,6 @@ function userlike(x: R.Userlike) {
|
||||||
// because the commit has a Git plugin prefix and will therefore by
|
// because the commit has a Git plugin prefix and will therefore by
|
||||||
// handled by the git plugin adapter
|
// handled by the git plugin adapter
|
||||||
function commit(x: R.Commit) {
|
function commit(x: R.Commit) {
|
||||||
// TODO(@wchargin): Ensure this hash is unambiguous
|
|
||||||
const shortHash = x.address().hash.slice(0, 7);
|
const shortHash = x.address().hash.slice(0, 7);
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
|
|
Loading…
Reference in New Issue