Skip to content

Commit dc2bd7d

Browse files
authored
Actions: start accepting a config as input (#1991)
GitGitGadget's GitHub Actions all implicitly use the [`defaultConfig`](https://github.com/gitgitgadget/gitgitgadget/blob/153badfc78153bb25d10e0e0d40c2cb3924d7f1b/lib/gitgitgadget-config.ts#L3-L45) that hard-codes the configuration of the OG GitGitGadget that supports the Git project (and the Git project _only_). However, for a long time there have been feature requests and musings towards using GitGitGadget also for other projects. GitGitGadget already _does_ have some beginnings of a support for other projects, e.g. the `IConfig` interface. With these changes, that interface gets extended a little, a validator is added that can verify whether or not an arbitrary object conforms to that interface, and then new inputs are introduced, accepting this (JSON-encoded) configuration as a user-provided string. The idea is that a project-specific fork of https://github.com/gitgitgadget/gitgitgadget-workflows/ contains this configuration in the `gitgitgadget-config.json` file in a dedicated `config` branch, from where it is synchronized via a GitHub workflow to the repository variable called `CONFIG`. This somewhat non-trivial setup allows the config to be conveniently tracked via Git, updated via Pull Requests, validated via GitHub workflows, and still to be used in a trivial manner in the main workflows via `${{ vars.CONFIG }}` (as opposed to having to play games with multi-branch checkouts or `curl`'ing a file from a different branch). This PR is step number 2 in that direction. Step number 1 was to register the [`cygwingitgadget` GitHub org](https://github.com/cygwingitgadget/) for experimenting with GitGitGadget support for a different mailing-list-based project than Git: Cygwin. That org is where I experimented with this change as well as with all the others leading up to gitgitgadget/gitgitgadget-github-app#7. This PR is stacked on top of #1993 and #1994
2 parents a6d30ea + 27ff7c6 commit dc2bd7d

File tree

16 files changed

+839
-75
lines changed

16 files changed

+839
-75
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"tbdiff",
8484
"Thái",
8585
"Truthy",
86+
"typia",
8687
"unportable",
8788
"vger",
8889
"VSTS",

handle-new-mails/action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ name: 'Handle new mails'
22
description: 'Processes new mails on the Git mailing list'
33
author: 'Johannes Schindelin'
44
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
58
pr-repo-token:
69
description: 'The access token to work on the repository that holds PRs and state'
710
required: true

handle-pr-comment/action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ name: 'Handle PR Comment'
22
description: 'Handles slash commands such as /submit and /preview'
33
author: 'Johannes Schindelin'
44
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
58
pr-repo-token:
69
description: 'The access token to work on the repository that holds PRs and state'
710
required: true

handle-pr-push/action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ name: 'Handle PR Pushes'
22
description: 'Handles when a PR was pushed'
33
author: 'Johannes Schindelin'
44
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
58
pr-repo-token:
69
description: 'The access token to work on the repository that holds PRs and state'
710
required: true

lib/ci-helper.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as core from "@actions/core";
22
import * as fs from "fs";
33
import * as os from "os";
4+
import typia from "typia";
45
import * as util from "util";
56
import { spawnSync } from "child_process";
67
import addressparser from "nodemailer/lib/addressparser/index.js";
@@ -53,8 +54,24 @@ export class CIHelper {
5354
return configFile ? await getExternalConfig(configFile) : getConfig();
5455
}
5556

57+
public static validateConfig = typia.createValidate<IConfig>();
58+
59+
protected static getConfigAsGitHubActionInput(): IConfig | undefined {
60+
if (process.env.GITHUB_ACTIONS !== "true") return undefined;
61+
const json = core.getInput("config");
62+
if (!json) return undefined;
63+
const config = JSON.parse(json) as IConfig | undefined;
64+
const result = CIHelper.validateConfig(config);
65+
if (result.success) return config;
66+
throw new Error(
67+
`Invalid config:\n- ${result.errors
68+
.map((e) => `${e.path} (value: ${e.value}, expected: ${e.expected}): ${e.description}`)
69+
.join("\n- ")}`,
70+
);
71+
}
72+
5673
public constructor(workDir: string = "pr-repo.git", config?: IConfig, skipUpdate?: boolean, gggConfigDir = ".") {
57-
this.config = config !== undefined ? setConfig(config) : getConfig();
74+
this.config = config !== undefined ? setConfig(config) : CIHelper.getConfigAsGitHubActionInput() || getConfig();
5875
this.gggConfigDir = gggConfigDir;
5976
this.workDir = workDir;
6077
this.notes = new GitNotes(workDir);
@@ -101,7 +118,7 @@ export class CIHelper {
101118

102119
// get the access tokens via the inputs of the GitHub Action
103120
this.setAccessToken(this.config.repo.owner, core.getInput("pr-repo-token"));
104-
this.setAccessToken(this.config.repo.baseOwner, core.getInput("upstream-repo-token"));
121+
this.setAccessToken(this.config.repo.upstreamOwner, core.getInput("upstream-repo-token"));
105122
if (this.config.repo.testOwner) {
106123
this.setAccessToken(this.config.repo.testOwner, core.getInput("test-repo-token"));
107124
}
@@ -128,7 +145,7 @@ export class CIHelper {
128145
["remote.origin.url", `https://github.com/${this.config.repo.owner}/${this.config.repo.name}`],
129146
["remote.origin.promisor", "true"],
130147
["remote.origin.partialCloneFilter", "blob:none"],
131-
["remote.upstream.url", `https://github.com/${this.config.repo.baseOwner}/${this.config.repo.name}`],
148+
["remote.upstream.url", `https://github.com/${this.config.repo.upstreamOwner}/${this.config.repo.name}`],
132149
["remote.upstream.promisor", "true"],
133150
["remote.upstream.partialCloneFilter", "blob:none"],
134151
]) {
@@ -175,7 +192,7 @@ export class CIHelper {
175192
console.time("get open PR head commits");
176193
const openPRCommits = (
177194
await Promise.all(
178-
this.config.repo.owners.map(async (repositoryOwner) => {
195+
this.config.app.installedOn.map(async (repositoryOwner) => {
179196
return await this.github.getOpenPRs(repositoryOwner);
180197
}),
181198
)
@@ -256,7 +273,7 @@ export class CIHelper {
256273
const prCommentUrl = core.getInput("pr-comment-url");
257274
const [, owner, repo, prNumber, commentId] =
258275
prCommentUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)#issuecomment-(\d+)$/) || [];
259-
if (!this.config.repo.owners.includes(owner) || repo !== this.config.repo.name) {
276+
if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) {
260277
throw new Error(`Invalid PR comment URL: ${prCommentUrl}`);
261278
}
262279
return { owner, repo, prNumber: parseInt(prNumber, 10), commentId: parseInt(commentId, 10) };
@@ -266,7 +283,7 @@ export class CIHelper {
266283
const prUrl = core.getInput("pr-url");
267284

268285
const [, owner, repo, prNumber] = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/) || [];
269-
if (!this.config.repo.owners.includes(owner) || repo !== this.config.repo.name) {
286+
if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) {
270287
throw new Error(`Invalid PR URL: ${prUrl}`);
271288
}
272289
return { owner, repo, prNumber: parseInt(prNumber, 10) };
@@ -411,7 +428,7 @@ export class CIHelper {
411428
mailMeta.originalCommit,
412429
upstreamCommit,
413430
this.config.repo.owner,
414-
this.config.repo.baseOwner,
431+
this.config.repo.upstreamOwner,
415432
);
416433
}
417434

@@ -654,7 +671,7 @@ export class CIHelper {
654671

655672
// Add comment on GitHub
656673
const comment = `This patch series was integrated into ${branch} via https://github.com/${
657-
this.config.repo.baseOwner
674+
this.config.repo.upstreamOwner
658675
}/${this.config.repo.name}/commit/${mergeCommit}.`;
659676
const url = await this.github.addPRComment(prKey, comment);
660677
console.log(`Added comment ${url.id} about ${branch}: ${url.url}`);
@@ -892,7 +909,7 @@ export class CIHelper {
892909
await addComment(
893910
`Submitted as [${
894911
metadata?.coverLetterMessageId
895-
}](https://${this.config.mailrepo.host}/${this.config.mailrepo.name}/${
912+
}](${this.config.mailrepo.url.replace(/\/+$/, "")}/${
896913
metadata?.coverLetterMessageId
897914
})\n\nTo fetch this version into \`FETCH_HEAD\`:${
898915
code
@@ -1020,7 +1037,7 @@ export class CIHelper {
10201037

10211038
if (result) {
10221039
const results = commits.map((commit: IPRCommit) => {
1023-
const linter = new LintCommit(commit);
1040+
const linter = new LintCommit(commit, this.config.lint.commitLintOptions);
10241041
return linter.lint();
10251042
});
10261043

@@ -1137,7 +1154,7 @@ export class CIHelper {
11371154
const handledPRs = new Set<string>();
11381155
const handledMessageIDs = new Set<string>();
11391156

1140-
for (const repositoryOwner of this.config.repo.owners) {
1157+
for (const repositoryOwner of this.config.app.installedOn) {
11411158
const pullRequests = await this.github.getOpenPRs(repositoryOwner);
11421159

11431160
for (const pr of pullRequests) {
@@ -1195,7 +1212,7 @@ export class CIHelper {
11951212
private async getPRInfo(prKey: pullRequestKey): Promise<IPullRequestInfo> {
11961213
const pr = await this.github.getPRInfo(prKey);
11971214

1198-
if (!this.config.repo.owners.includes(pr.baseOwner) || pr.baseRepo !== this.config.repo.name) {
1215+
if (!this.config.app.installedOn.includes(pr.baseOwner) || pr.baseRepo !== this.config.repo.name) {
11991216
throw new Error(`Unsupported repository: ${pr.pullRequestURL}`);
12001217
}
12011218

lib/commit-lint.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import { IPRCommit } from "./github-glue.js";
2+
import { ILintCommitConfig } from "./project-config.js";
23

34
export interface ILintError {
45
checkFailed: boolean; // true if check failed
56
message: string;
67
}
78

8-
export interface ILintOptions {
9-
maxColumns?: number | undefined; // max line length
10-
}
11-
129
/*
1310
* Simple single use class to drive lint tests on commit messages.
1411
*/
@@ -19,7 +16,7 @@ export class LintCommit {
1916
private messages: string[] = [];
2017
private maxColumns = 76;
2118

22-
public constructor(patch: IPRCommit, options?: ILintOptions) {
19+
public constructor(patch: IPRCommit, options?: ILintCommitConfig) {
2320
this.blocked = false;
2421
this.lines = patch.message.split("\n");
2522
this.patch = patch;

lib/gitgitgadget-config.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ const defaultConfig: IConfig = {
44
repo: {
55
name: "git",
66
owner: "gitgitgadget",
7-
baseOwner: "git",
7+
upstreamOwner: "git",
88
testOwner: "dscho",
9-
owners: ["gitgitgadget", "git", "dscho"],
109
branches: ["maint", "seen"],
1110
closingBranches: ["maint", "master"],
1211
trackingBranches: ["maint", "seen", "master", "next"],
@@ -27,13 +26,16 @@ const defaultConfig: IConfig = {
2726
mail: {
2827
author: "GitGitGadget",
2928
sender: "GitGitGadget",
29+
smtpUser: "gitgitgadget@gmail.com",
30+
smtpHost: "smtp.gmail.com",
3031
},
3132
app: {
3233
appID: 12836,
3334
installationID: 195971,
3435
name: "gitgitgadget",
3536
displayName: "GitGitGadget",
3637
altname: "gitgitgadget-git",
38+
installedOn: ["gitgitgadget", "git", "dscho"],
3739
},
3840
lint: {
3941
maxCommitsIgnore: ["https://github.com/gitgitgadget/git/pull/923"],
@@ -42,6 +44,18 @@ const defaultConfig: IConfig = {
4244
user: {
4345
allowUserAsLogin: false,
4446
},
47+
syncUpstreamBranches: [
48+
{
49+
sourceRepo: "gitster/git",
50+
targetRepo: "gitgitgadget/git",
51+
sourceRefRegex: "^refs/heads/(maint-\\d|[a-z][a-z]/)",
52+
},
53+
{
54+
sourceRepo: "j6t/git-gui",
55+
targetRepo: "gitgitgadget/git",
56+
targetRefNamespace: "git-gui/",
57+
},
58+
],
4559
};
4660

4761
export default defaultConfig;

lib/project-config.ts

Lines changed: 70 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,49 +8,76 @@ export type projectInfo = {
88
urlPrefix: string; // url to 'listserv' of mail (should it be in mailrepo?)
99
};
1010

11+
export interface IRepoConfig {
12+
name: string; // name of the repo
13+
owner: string; // owner of repo holding the notes (tracking data)
14+
upstreamOwner: string; // owner of upstream ("base") repo
15+
testOwner?: string; // owner of the test repo (if any)
16+
branches: string[]; // remote branches to fetch - just use trackingBranches?
17+
closingBranches: string[]; // close if the pr is added to this branch
18+
trackingBranches: string[]; // comment if the pr is added to this branch
19+
maintainerBranch?: string; // branch/owner manually implementing changes
20+
host: string;
21+
}
22+
23+
export interface IMailRepoConfig {
24+
name: string;
25+
owner: string;
26+
branch: string;
27+
host: string;
28+
url: string;
29+
public_inbox_epoch?: number;
30+
mirrorURL?: string;
31+
mirrorRef?: string;
32+
descriptiveName: string;
33+
}
34+
35+
export interface IMailConfig {
36+
author: string;
37+
sender: string;
38+
smtpUser: string;
39+
smtpHost: string;
40+
}
41+
42+
export interface IAppConfig {
43+
appID: number;
44+
installationID: number;
45+
name: string;
46+
displayName: string; // name to use in comments to identify app
47+
installedOn: string[]; // owners of clones being monitored (PR checking)
48+
altname: string | undefined; // is this even needed?
49+
}
50+
51+
export interface ILintCommitConfig {
52+
maxColumns?: number | undefined; // max line length
53+
}
54+
55+
export interface ILintConfig {
56+
maxCommitsIgnore?: string[]; // array of pull request urls to skip check
57+
maxCommits: number; // limit on number of commits in a pull request
58+
commitLintOptions?: ILintCommitConfig; // options to pass to commit linter
59+
}
60+
61+
export interface IUserConfig {
62+
allowUserAsLogin: boolean; // use GitHub login as name if name is private
63+
}
64+
65+
export interface ISyncUpstreamBranchesConfig {
66+
sourceRepo: string; // e.g. "gitster/git"
67+
targetRepo: string; // e.g. "gitgitgadget/git"
68+
sourceRefRegex?: string; // e.g. "^refs/heads/(maint-\\d|[a-z][a-z]/)"
69+
targetRefNamespace?: string; // e.g. "git-gui/"
70+
}
71+
1172
export interface IConfig {
12-
repo: {
13-
name: string; // name of the repo
14-
owner: string; // owner of repo holding the notes (tracking data)
15-
baseOwner: string; // owner of upstream ("base") repo
16-
testOwner?: string; // owner of the test repo (if any)
17-
owners: string[]; // owners of clones being monitored (PR checking)
18-
branches: string[]; // remote branches to fetch - just use trackingBranches?
19-
closingBranches: string[]; // close if the pr is added to this branch
20-
trackingBranches: string[]; // comment if the pr is added to this branch
21-
maintainerBranch?: string; // branch/owner manually implementing changes
22-
host: string;
23-
};
24-
mailrepo: {
25-
name: string;
26-
owner: string;
27-
branch: string;
28-
host: string;
29-
url: string;
30-
public_inbox_epoch?: number;
31-
mirrorURL?: string;
32-
mirrorRef?: string;
33-
descriptiveName: string;
34-
};
35-
mail: {
36-
author: string;
37-
sender: string;
38-
};
73+
repo: IRepoConfig;
74+
mailrepo: IMailRepoConfig;
75+
mail: IMailConfig;
3976
project?: projectInfo | undefined; // project-options values
40-
app: {
41-
appID: number;
42-
installationID: number;
43-
name: string;
44-
displayName: string; // name to use in comments to identify app
45-
altname: string | undefined; // is this even needed?
46-
};
47-
lint: {
48-
maxCommitsIgnore?: string[]; // array of pull request urls to skip check
49-
maxCommits: number; // limit on number of commits in a pull request
50-
};
51-
user: {
52-
allowUserAsLogin: boolean; // use GitHub login as name if name is private
53-
};
77+
app: IAppConfig;
78+
lint: ILintConfig;
79+
user: IUserConfig;
80+
syncUpstreamBranches: ISyncUpstreamBranchesConfig[]; // branches to sync from upstream to our repo
5481
}
5582

5683
let config: IConfig; // singleton
@@ -80,8 +107,8 @@ export async function getExternalConfig(file: string): Promise<IConfig> {
80107
throw new Error(`Invalid 'owner' ${newConfig.repo.owner} in ${filePath}`);
81108
}
82109

83-
if (!newConfig.repo.baseOwner.match(/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i)) {
84-
throw new Error(`Invalid 'baseOwner' ${newConfig.repo.baseOwner} in ${filePath}`);
110+
if (!newConfig.repo.upstreamOwner.match(/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i)) {
111+
throw new Error(`Invalid 'upstreamOwner' ${newConfig.repo.upstreamOwner} in ${filePath}`);
85112
}
86113

87114
return newConfig;

0 commit comments

Comments
 (0)