Skip to content

Commit d7e28c9

Browse files
committed
Merge branch 'check-run-action'
This Action allows "mirroring" a workflow run into a PR Check Run in a different repository. Signed-off-by: Johannes Schindelin <[email protected]>
2 parents 21aca98 + 081b17f commit d7e28c9

File tree

8 files changed

+286
-41
lines changed

8 files changed

+286
-41
lines changed

check-run/action.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: 'Create or update a check-run'
2+
description: 'Mirrors a workflow-run into a Pull Request in a different repository'
3+
author: 'Johannes Schindelin'
4+
inputs:
5+
config:
6+
description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)'
7+
default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing
8+
pr-repo-token:
9+
description: 'The access token to work on the repository that holds PRs and state'
10+
required: true
11+
upstream-repo-token:
12+
description: 'The access token to work on PRs in the upstream repository'
13+
required: false
14+
test-repo-token:
15+
description: 'The access token to work on PRs in the test repository'
16+
required: false
17+
pr-url:
18+
description: 'The URL of the Pull Request (or Pull Request comment)'
19+
required: false
20+
check-run-id:
21+
description: 'The Check Run to update (if empty, a new one will be created)'
22+
default: ''
23+
name:
24+
description: 'The name of the CheckRun (required if check-run-id is empty)'
25+
default: ''
26+
title:
27+
description: 'The Check Run title (required when creating a new one)'
28+
default: ''
29+
summary:
30+
description: 'The Check Run summary (required when creating a new one)'
31+
default: ''
32+
text:
33+
description: 'The Check Run text (required when creating a new one)'
34+
default: ''
35+
details-url:
36+
description: 'The details URL of the Check Run (required when creating a new one)'
37+
default: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
38+
conclusion:
39+
description: 'If set, the Check Run will be marked as completed'
40+
default: ''
41+
job-status:
42+
description: 'Needed at the end of the job'
43+
default: ${{ job.status }}
44+
outputs:
45+
check-run-id:
46+
description: 'The ID of the created or updated Check Run'
47+
runs:
48+
using: 'node20'
49+
main: './index.js'
50+
post: './post.js'
51+
branding:
52+
icon: 'git-commit'
53+
color: 'orange'

check-run/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
async function run() {
2+
const { CIHelper } = await import("../dist/index.js")
3+
4+
try {
5+
const ci = new CIHelper()
6+
ci.setupGitHubAction({ createOrUpdateCheckRun: true })
7+
} catch (e) {
8+
console.error(e)
9+
process.exitCode = 1
10+
}
11+
}
12+
13+
run()

check-run/post.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
async function run() {
2+
const { CIHelper } = await import("../dist/index.js")
3+
4+
try {
5+
const ci = new CIHelper()
6+
ci.setupGitHubAction({ createOrUpdateCheckRun: "post" })
7+
} catch (e) {
8+
console.error(e)
9+
process.exitCode = 1
10+
}
11+
}
12+
13+
run()

handle-pr-comment/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ async function run() {
22
const { CIHelper } = await import("../dist/index.js")
33

44
const ci = new CIHelper()
5-
const { owner, commentId } = ci.parsePRCommentURLInput()
5+
const { owner, comment_id } = ci.parsePRCommentURLInput()
66

77
await ci.setupGitHubAction()
8-
await ci.handleComment(owner, commentId)
8+
await ci.handleComment(owner, comment_id)
99
}
1010

1111
run()

handle-pr-push/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ async function run() {
22
const { CIHelper } = await import("../dist/index.js")
33

44
const ci = new CIHelper()
5-
const { owner, prNumber } = ci.parsePRURLInput()
5+
const { owner, pull_number } = ci.parsePRURLInput()
66

77
await ci.setupGitHubAction()
8-
await ci.handlePush(owner, prNumber)
8+
await ci.handlePush(owner, pull_number)
99
}
1010

1111
run()

lib/ci-helper.ts

Lines changed: 106 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,27 @@ import { commitExists, git, emptyTreeName, revParse } from "./git.js";
1111
import { GitNotes } from "./git-notes.js";
1212
import { GitGitGadget, IGitGitGadgetOptions } from "./gitgitgadget.js";
1313
import { getConfig } from "./gitgitgadget-config.js";
14-
import { GitHubGlue, IGitHubUser, IPRComment, IPRCommit, IPullRequestInfo, RequestError } from "./github-glue.js";
14+
import {
15+
ConclusionType,
16+
GitHubGlue,
17+
IGitHubUser,
18+
IPRComment,
19+
IPRCommit,
20+
IPullRequestInfo,
21+
RequestError,
22+
} from "./github-glue.js";
1523
import { toPrettyJSON } from "./json-util.js";
1624
import { MailArchiveGitHelper } from "./mail-archive-helper.js";
1725
import { MailCommitMapping } from "./mail-commit-mapping.js";
1826
import { IMailMetadata } from "./mail-metadata.js";
1927
import { IPatchSeriesMetadata } from "./patch-series-metadata.js";
2028
import { IConfig, getExternalConfig, setConfig } from "./project-config.js";
21-
import { getPullRequestKeyFromURL, pullRequestKey } from "./pullRequestKey.js";
29+
import {
30+
getPullRequestCommentKeyFromURL,
31+
getPullRequestKeyFromURL,
32+
getPullRequestOrCommentKeyFromURL,
33+
pullRequestKey,
34+
} from "./pullRequestKey.js";
2235
import { ISMTPOptions } from "./send-mail.js";
2336
import { fileURLToPath } from "url";
2437

@@ -89,24 +102,8 @@ export class CIHelper {
89102
needsMailingListMirror?: boolean;
90103
needsUpstreamBranches?: boolean;
91104
needsMailToCommitNotes?: boolean;
105+
createOrUpdateCheckRun?: boolean | "post";
92106
}): Promise<void> {
93-
// help dugite realize where `git` is...
94-
const gitExecutable = os.type() === "Windows_NT" ? "git.exe" : "git";
95-
const stripSuffix = `bin${path.sep}${gitExecutable}`;
96-
for (const gitPath of (process.env.PATH || "/")
97-
.split(path.delimiter)
98-
.map((p) => path.normalize(`${p}${path.sep}${gitExecutable}`))
99-
// eslint-disable-next-line security/detect-non-literal-fs-filename
100-
.filter((p) => p.endsWith(`${path.sep}${stripSuffix}`) && fs.existsSync(p))) {
101-
process.env.LOCAL_GIT_DIRECTORY = gitPath.substring(0, gitPath.length - stripSuffix.length);
102-
// need to override GIT_EXEC_PATH, so that Dugite can find the `git-remote-https` executable,
103-
// see https://github.com/desktop/dugite/blob/v2.7.1/lib/git-environment.ts#L44-L64
104-
// Also: We cannot use `await git(["--exec-path"]);` because that would use Dugite, which would
105-
// override `GIT_EXEC_PATH` and then `git --exec-path` would report _that_...
106-
process.env.GIT_EXEC_PATH = spawnSync(gitPath, ["--exec-path"]).stdout.toString("utf-8").trimEnd();
107-
break;
108-
}
109-
110107
// configure the Git committer information
111108
process.env.GIT_CONFIG_PARAMETERS = [
112109
process.env.GIT_CONFIG_PARAMETERS,
@@ -138,6 +135,27 @@ export class CIHelper {
138135
// Ignore, for now
139136
}
140137

138+
if (setupOptions?.createOrUpdateCheckRun) {
139+
return await this.createOrUpdateCheckRun(setupOptions.createOrUpdateCheckRun === "post");
140+
}
141+
142+
// help dugite realize where `git` is...
143+
const gitExecutable = os.type() === "Windows_NT" ? "git.exe" : "git";
144+
const stripSuffix = `bin${path.sep}${gitExecutable}`;
145+
for (const gitPath of (process.env.PATH || "/")
146+
.split(path.delimiter)
147+
.map((p) => path.normalize(`${p}${path.sep}${gitExecutable}`))
148+
// eslint-disable-next-line security/detect-non-literal-fs-filename
149+
.filter((p) => p.endsWith(`${path.sep}${stripSuffix}`) && fs.existsSync(p))) {
150+
process.env.LOCAL_GIT_DIRECTORY = gitPath.substring(0, gitPath.length - stripSuffix.length);
151+
// need to override GIT_EXEC_PATH, so that Dugite can find the `git-remote-https` executable,
152+
// see https://github.com/desktop/dugite/blob/v2.7.1/lib/git-environment.ts#L44-L64
153+
// Also: We cannot use `await git(["--exec-path"]);` because that would use Dugite, which would
154+
// override `GIT_EXEC_PATH` and then `git --exec-path` would report _that_...
155+
process.env.GIT_EXEC_PATH = spawnSync(gitPath, ["--exec-path"]).stdout.toString("utf-8").trimEnd();
156+
break;
157+
}
158+
141159
// eslint-disable-next-line security/detect-non-literal-fs-filename
142160
if (!fs.existsSync(this.workDir)) await git(["init", "--bare", "--initial-branch", "unused", this.workDir]);
143161
for (const [key, value] of [
@@ -269,24 +287,79 @@ export class CIHelper {
269287
}
270288
}
271289

272-
public parsePRCommentURLInput(): { owner: string; repo: string; prNumber: number; commentId: number } {
273-
const prCommentUrl = core.getInput("pr-comment-url");
274-
const [, owner, repo, prNumber, commentId] =
275-
prCommentUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)#issuecomment-(\d+)$/) || [];
276-
if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) {
277-
throw new Error(`Invalid PR comment URL: ${prCommentUrl}`);
290+
protected async createOrUpdateCheckRun(runPost: boolean): Promise<void> {
291+
type CheckRunParameters = {
292+
owner: string;
293+
repo: string;
294+
pull_number: number;
295+
check_run_id?: number;
296+
name: string;
297+
output?: {
298+
title: string;
299+
summary: string;
300+
text?: string;
301+
};
302+
details_url?: string;
303+
conclusion?: ConclusionType;
304+
job_status?: ConclusionType;
305+
};
306+
const params = JSON.parse(core.getState("check-run") || "{}") as CheckRunParameters;
307+
308+
const validateCheckRunParameters = () => {
309+
const result = typia.createValidate<CheckRunParameters>()(params);
310+
if (!result.success) {
311+
throw new Error(
312+
`Invalid check-run state:\n- ${result.errors
313+
.map((e) => `${e.path} (value: ${e.value}, expected: ${e.expected}): ${e.description}`)
314+
.join("\n- ")}`,
315+
);
316+
}
317+
};
318+
if (Object.keys(params).length) validateCheckRunParameters();
319+
320+
["pr-url", "check-run-id", "name", "title", "summary", "text", "details-url", "conclusion", "job-status"]
321+
.map((name) => [name.replaceAll("-", "_"), core.getInput(name)] as const)
322+
.forEach(([key, value]) => {
323+
if (!value) return;
324+
if (key === "pr_url") Object.assign(params, getPullRequestOrCommentKeyFromURL(value));
325+
else if (key === "check_run_id") params.check_run_id = Number.parseInt(value, 10);
326+
else if (key === "title" || key === "summary" || key === "text") {
327+
if (!params.output) Object.assign(params, { output: {} });
328+
(params.output as { [key: string]: string })[key] = value;
329+
} else (params as unknown as { [key: string]: string })[key] = value;
330+
});
331+
validateCheckRunParameters();
332+
333+
if (runPost) {
334+
if (!params.check_run_id) {
335+
core.info("No Check Run ID found in state; doing nothing");
336+
return;
337+
}
338+
if (!params.conclusion) {
339+
Object.assign(params, { conclusion: params.job_status });
340+
validateCheckRunParameters();
341+
}
342+
}
343+
344+
if (params.check_run_id === undefined) {
345+
({ id: params.check_run_id } = await this.github.createCheckRun(params));
346+
core.setOutput("check-run-id", params.check_run_id);
347+
} else {
348+
await this.github.updateCheckRun({
349+
...params,
350+
// needed to pacify TypeScript's concerns about the ID being potentially undefined
351+
check_run_id: params.check_run_id,
352+
});
278353
}
279-
return { owner, repo, prNumber: parseInt(prNumber, 10), commentId: parseInt(commentId, 10) };
354+
core.exportVariable("STATE_check-run", JSON.stringify(params));
280355
}
281356

282-
public parsePRURLInput(): { owner: string; repo: string; prNumber: number } {
283-
const prUrl = core.getInput("pr-url");
357+
public parsePRCommentURLInput(): { owner: string; repo: string; pull_number: number; comment_id: number } {
358+
return getPullRequestCommentKeyFromURL(core.getInput("pr-comment-url"));
359+
}
284360

285-
const [, owner, repo, prNumber] = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/) || [];
286-
if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) {
287-
throw new Error(`Invalid PR URL: ${prUrl}`);
288-
}
289-
return { owner, repo, prNumber: parseInt(prNumber, 10) };
361+
public parsePRURLInput(): { owner: string; repo: string; pull_number: number } {
362+
return getPullRequestCommentKeyFromURL(core.getInput("pr-url"));
290363
}
291364

292365
public setAccessToken(repositoryOwner: string, token: string): void {

lib/github-glue.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ export interface IGitHubUser {
5151
type: string;
5252
}
5353

54+
export type ConclusionType =
55+
| "action_required"
56+
| "cancelled"
57+
| "failure"
58+
| "neutral"
59+
| "success"
60+
| "skipped"
61+
| "stale"
62+
| "timed_out"
63+
| undefined;
64+
5465
export class GitHubGlue {
5566
public workDir: string;
5667
protected client: Octokit = new Octokit(); // add { log: console } to debug
@@ -477,6 +488,60 @@ export class GitHubGlue {
477488
this.tokens.set(repositoryOwner, token);
478489
}
479490

491+
public async createCheckRun(options: {
492+
owner: string;
493+
repo: string;
494+
pull_number: number;
495+
name: string;
496+
output?: {
497+
title: string;
498+
summary: string;
499+
text?: string;
500+
};
501+
details_url?: string;
502+
conclusion?: ConclusionType;
503+
}): Promise<{ id: number }> {
504+
if (process.env.GITGITGADGET_DRY_RUN) {
505+
console.log(`Would create Check Run with options ${JSON.stringify(options, null, 2)}`);
506+
return { id: -1 }; // debug mode does not actually do anything
507+
}
508+
509+
await this.ensureAuthenticated(options.owner);
510+
const prInfo = await this.getPRInfo(options);
511+
const { data } = await this.client.checks.create({
512+
...options,
513+
head_sha: prInfo.headCommit,
514+
status: options.conclusion ? "completed" : "in_progress",
515+
});
516+
return data;
517+
}
518+
519+
public async updateCheckRun(options: {
520+
owner: string;
521+
repo: string;
522+
check_run_id: number;
523+
output?: {
524+
title?: string;
525+
summary: string;
526+
text?: string;
527+
};
528+
detailsURL?: string;
529+
conclusion?: ConclusionType;
530+
}): Promise<{ id: number }> {
531+
if (process.env.GITGITGADGET_DRY_RUN) {
532+
console.log(`Would create Check Run with options ${JSON.stringify(options, null, 2)}`);
533+
return { id: -1 }; // debug mode does not actually do anything
534+
}
535+
536+
await this.ensureAuthenticated(options.owner);
537+
const { data } = await this.client.checks.update({
538+
...options,
539+
conclusion: options.conclusion,
540+
status: options.conclusion ? "completed" : "in_progress",
541+
});
542+
return data;
543+
}
544+
480545
protected async ensureAuthenticated(repositoryOwner: string): Promise<void> {
481546
if (repositoryOwner !== this.authenticated) {
482547
let token = this.tokens.get(repositoryOwner);

0 commit comments

Comments
 (0)