From 62fce7941781e887e358d4773ec7b12044d37379 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 31 Aug 2025 11:53:25 +0200 Subject: [PATCH 01/21] Extend the `IConfig` interface as needed for 3rd-party projects In my endeavor to support projects other than Git, I am currently adapting GitGitGadget to allow sending Cygwin PRs to the Cygwin-patches mailing list. I identified a couple of gaps in the project configuration when setting up stuff in https://github.com/cygwingitgadget. Let's close those gaps. Signed-off-by: Johannes Schindelin --- lib/gitgitgadget-config.ts | 14 ++++++++++++++ lib/project-config.ts | 8 ++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/gitgitgadget-config.ts b/lib/gitgitgadget-config.ts index 47ba1eb16d..d47216ea3b 100644 --- a/lib/gitgitgadget-config.ts +++ b/lib/gitgitgadget-config.ts @@ -27,6 +27,8 @@ const defaultConfig: IConfig = { mail: { author: "GitGitGadget", sender: "GitGitGadget", + smtpUser: "gitgitgadget@gmail.com", + smtpHost: "smtp.gmail.com", }, app: { appID: 12836, @@ -42,6 +44,18 @@ const defaultConfig: IConfig = { user: { allowUserAsLogin: false, }, + syncUpstreamBranches: [ + { + sourceRepo: "gitster/git", + targetRepo: "gitgitgadget/git", + sourceRefRegex: "^refs/heads/(maint-\\d|[a-z][a-z]/)", + }, + { + sourceRepo: "j6t/git-gui", + targetRepo: "gitgitgadget/git", + targetRefNamespace: "git-gui/", + }, + ], }; export default defaultConfig; diff --git a/lib/project-config.ts b/lib/project-config.ts index c96a732224..e33b7f38f9 100644 --- a/lib/project-config.ts +++ b/lib/project-config.ts @@ -35,6 +35,8 @@ export interface IConfig { mail: { author: string; sender: string; + smtpUser: string; + smtpHost: string; }; project?: projectInfo | undefined; // project-options values app: { @@ -51,6 +53,12 @@ export interface IConfig { user: { allowUserAsLogin: boolean; // use GitHub login as name if name is private }; + syncUpstreamBranches: Array<{ + sourceRepo: string; // e.g. "gitster/git" + targetRepo: string; // e.g. "gitgitgadget/git" + sourceRefRegex?: string; // e.g. "^refs/heads/(maint-\\d|[a-z][a-z]/)" + targetRefNamespace?: string; // e.g. "git-gui/" + }>; // branches to sync from upstream to our repo } let config: IConfig; // singleton From 23c6d23c530e6526d852ae3f2f2e8842463d9cdf Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 8 Sep 2025 23:02:51 +0200 Subject: [PATCH 02/21] IConfig: rename the attribute defining the `upstream-repo`'s org We've settled on the nomenclature `upstream-repo` to refer to the original repository of the project, as opposed to the `pr-repo` which is a fork that exists exclusively to let GitGitGadget handle PRs in (and to store its global state in the Git notes). So let's call the owner of the `upstream-repo` the `upstreamOwner`, not the `baseOwner`. Besides, with GitHub's naming conventions referring to the branch a PR targets as the "base", it is a bit confusing to have `baseOwner` to refer to anything except the owner of the repository in which the PR lives. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 8 ++++---- lib/gitgitgadget-config.ts | 2 +- lib/project-config.ts | 6 +++--- script/misc-helper.ts | 2 +- tests-config/ci-helper.test.ts | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 43b763fe5c..31aed5e6f0 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -101,7 +101,7 @@ export class CIHelper { // get the access tokens via the inputs of the GitHub Action this.setAccessToken(this.config.repo.owner, core.getInput("pr-repo-token")); - this.setAccessToken(this.config.repo.baseOwner, core.getInput("upstream-repo-token")); + this.setAccessToken(this.config.repo.upstreamOwner, core.getInput("upstream-repo-token")); if (this.config.repo.testOwner) { this.setAccessToken(this.config.repo.testOwner, core.getInput("test-repo-token")); } @@ -128,7 +128,7 @@ export class CIHelper { ["remote.origin.url", `https://github.com/${this.config.repo.owner}/${this.config.repo.name}`], ["remote.origin.promisor", "true"], ["remote.origin.partialCloneFilter", "blob:none"], - ["remote.upstream.url", `https://github.com/${this.config.repo.baseOwner}/${this.config.repo.name}`], + ["remote.upstream.url", `https://github.com/${this.config.repo.upstreamOwner}/${this.config.repo.name}`], ["remote.upstream.promisor", "true"], ["remote.upstream.partialCloneFilter", "blob:none"], ]) { @@ -411,7 +411,7 @@ export class CIHelper { mailMeta.originalCommit, upstreamCommit, this.config.repo.owner, - this.config.repo.baseOwner, + this.config.repo.upstreamOwner, ); } @@ -654,7 +654,7 @@ export class CIHelper { // Add comment on GitHub const comment = `This patch series was integrated into ${branch} via https://github.com/${ - this.config.repo.baseOwner + this.config.repo.upstreamOwner }/${this.config.repo.name}/commit/${mergeCommit}.`; const url = await this.github.addPRComment(prKey, comment); console.log(`Added comment ${url.id} about ${branch}: ${url.url}`); diff --git a/lib/gitgitgadget-config.ts b/lib/gitgitgadget-config.ts index d47216ea3b..148dff61b0 100644 --- a/lib/gitgitgadget-config.ts +++ b/lib/gitgitgadget-config.ts @@ -4,7 +4,7 @@ const defaultConfig: IConfig = { repo: { name: "git", owner: "gitgitgadget", - baseOwner: "git", + upstreamOwner: "git", testOwner: "dscho", owners: ["gitgitgadget", "git", "dscho"], branches: ["maint", "seen"], diff --git a/lib/project-config.ts b/lib/project-config.ts index e33b7f38f9..be1e72f611 100644 --- a/lib/project-config.ts +++ b/lib/project-config.ts @@ -12,7 +12,7 @@ export interface IConfig { repo: { name: string; // name of the repo owner: string; // owner of repo holding the notes (tracking data) - baseOwner: string; // owner of upstream ("base") repo + upstreamOwner: string; // owner of upstream ("base") repo testOwner?: string; // owner of the test repo (if any) owners: string[]; // owners of clones being monitored (PR checking) branches: string[]; // remote branches to fetch - just use trackingBranches? @@ -88,8 +88,8 @@ export async function getExternalConfig(file: string): Promise { throw new Error(`Invalid 'owner' ${newConfig.repo.owner} in ${filePath}`); } - if (!newConfig.repo.baseOwner.match(/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i)) { - throw new Error(`Invalid 'baseOwner' ${newConfig.repo.baseOwner} in ${filePath}`); + if (!newConfig.repo.upstreamOwner.match(/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i)) { + throw new Error(`Invalid 'baseOwner' ${newConfig.repo.upstreamOwner} in ${filePath}`); } return newConfig; diff --git a/script/misc-helper.ts b/script/misc-helper.ts index 7d16254c5d..00e4319ee6 100644 --- a/script/misc-helper.ts +++ b/script/misc-helper.ts @@ -200,7 +200,7 @@ const commandOptions = commander.opts(); originalCommit, gitGitCommit, config.repo.owner, - config.repo.baseOwner, + config.repo.upstreamOwner, ); console.log(`Created check with id ${id}`); }); diff --git a/tests-config/ci-helper.test.ts b/tests-config/ci-helper.test.ts index 3ce98c5623..d4a118234e 100644 --- a/tests-config/ci-helper.test.ts +++ b/tests-config/ci-helper.test.ts @@ -16,7 +16,7 @@ const testConfig: IConfig = { repo: { name: "telescope", owner: "webb", - baseOwner: "galileo", + upstreamOwner: "galileo", owners: ["webb", "galileo"], branches: ["maint"], closingBranches: ["maint", "main"], @@ -172,7 +172,7 @@ async function setupRepos(instance: string): await gggLocal.git([ "config", `url.${gggRemote.workDir}.insteadOf`, url ]); // pretend there are two remotes await gggLocal.git([ "config", `url.${gggRemote.workDir}.insteadOf`, - `https://github.com/${config.repo.baseOwner}/${config.repo.name}` ]); + `https://github.com/${config.repo.upstreamOwner}/${config.repo.name}` ]); // set needed config await worktree.git([ "config", "--add", "gitgitgadget.workDir", gggLocal.workDir, ]); From 989059649cfa15042cba9ab0ed5c1498fc802933 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 8 Sep 2025 23:17:07 +0200 Subject: [PATCH 03/21] IConfig: move `repo.owners` to a better place The `owners` array refers to a list of orgs/owners where the GitHub App is installed, i.e. where GitGitGadget can operate. Therefore, a much better place is `app.installedOn`. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 10 +++++----- lib/gitgitgadget-config.ts | 2 +- lib/project-config.ts | 2 +- tests-config/ci-helper.test.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 31aed5e6f0..0e75258ed1 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -175,7 +175,7 @@ export class CIHelper { console.time("get open PR head commits"); const openPRCommits = ( await Promise.all( - this.config.repo.owners.map(async (repositoryOwner) => { + this.config.app.installedOn.map(async (repositoryOwner) => { return await this.github.getOpenPRs(repositoryOwner); }), ) @@ -256,7 +256,7 @@ export class CIHelper { const prCommentUrl = core.getInput("pr-comment-url"); const [, owner, repo, prNumber, commentId] = prCommentUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)#issuecomment-(\d+)$/) || []; - if (!this.config.repo.owners.includes(owner) || repo !== this.config.repo.name) { + if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) { throw new Error(`Invalid PR comment URL: ${prCommentUrl}`); } return { owner, repo, prNumber: parseInt(prNumber, 10), commentId: parseInt(commentId, 10) }; @@ -266,7 +266,7 @@ export class CIHelper { const prUrl = core.getInput("pr-url"); const [, owner, repo, prNumber] = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/) || []; - if (!this.config.repo.owners.includes(owner) || repo !== this.config.repo.name) { + if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) { throw new Error(`Invalid PR URL: ${prUrl}`); } return { owner, repo, prNumber: parseInt(prNumber, 10) }; @@ -1137,7 +1137,7 @@ export class CIHelper { const handledPRs = new Set(); const handledMessageIDs = new Set(); - for (const repositoryOwner of this.config.repo.owners) { + for (const repositoryOwner of this.config.app.installedOn) { const pullRequests = await this.github.getOpenPRs(repositoryOwner); for (const pr of pullRequests) { @@ -1195,7 +1195,7 @@ export class CIHelper { private async getPRInfo(prKey: pullRequestKey): Promise { const pr = await this.github.getPRInfo(prKey); - if (!this.config.repo.owners.includes(pr.baseOwner) || pr.baseRepo !== this.config.repo.name) { + if (!this.config.app.installedOn.includes(pr.baseOwner) || pr.baseRepo !== this.config.repo.name) { throw new Error(`Unsupported repository: ${pr.pullRequestURL}`); } diff --git a/lib/gitgitgadget-config.ts b/lib/gitgitgadget-config.ts index 148dff61b0..5144094aba 100644 --- a/lib/gitgitgadget-config.ts +++ b/lib/gitgitgadget-config.ts @@ -6,7 +6,6 @@ const defaultConfig: IConfig = { owner: "gitgitgadget", upstreamOwner: "git", testOwner: "dscho", - owners: ["gitgitgadget", "git", "dscho"], branches: ["maint", "seen"], closingBranches: ["maint", "master"], trackingBranches: ["maint", "seen", "master", "next"], @@ -36,6 +35,7 @@ const defaultConfig: IConfig = { name: "gitgitgadget", displayName: "GitGitGadget", altname: "gitgitgadget-git", + installedOn: ["gitgitgadget", "git", "dscho"], }, lint: { maxCommitsIgnore: ["https://github.com/gitgitgadget/git/pull/923"], diff --git a/lib/project-config.ts b/lib/project-config.ts index be1e72f611..d133613816 100644 --- a/lib/project-config.ts +++ b/lib/project-config.ts @@ -14,7 +14,6 @@ export interface IConfig { owner: string; // owner of repo holding the notes (tracking data) upstreamOwner: string; // owner of upstream ("base") repo testOwner?: string; // owner of the test repo (if any) - owners: string[]; // owners of clones being monitored (PR checking) branches: string[]; // remote branches to fetch - just use trackingBranches? closingBranches: string[]; // close if the pr is added to this branch trackingBranches: string[]; // comment if the pr is added to this branch @@ -44,6 +43,7 @@ export interface IConfig { installationID: number; name: string; displayName: string; // name to use in comments to identify app + installedOn: string[]; // owners of clones being monitored (PR checking) altname: string | undefined; // is this even needed? }; lint: { diff --git a/tests-config/ci-helper.test.ts b/tests-config/ci-helper.test.ts index d4a118234e..752de945a8 100644 --- a/tests-config/ci-helper.test.ts +++ b/tests-config/ci-helper.test.ts @@ -17,7 +17,6 @@ const testConfig: IConfig = { name: "telescope", owner: "webb", upstreamOwner: "galileo", - owners: ["webb", "galileo"], branches: ["maint"], closingBranches: ["maint", "main"], trackingBranches: ["maint", "main", "hubble"], @@ -41,7 +40,8 @@ const testConfig: IConfig = { installationID: 195971, name: "gitgitgadget", displayName: "BigScopes", - altname: "gitgitgadget-git" + altname: "gitgitgadget-git", + installedOn: ["webb", "galileo"], }, lint: { maxCommitsIgnore: [], From 211dbb25d5540a4c5dc58c8d323d591ce530635b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 31 Aug 2025 11:58:50 +0200 Subject: [PATCH 04/21] Actions: accept the `config` input The idea is to configure GitGitGadget via a `gitgitgadget-config.json` file that contains the project-specific instance of the `IConfig` interface, tracked in the `config` branch of a fork of the `gitgitgadget-workflows` repository, from where it is automatically synchronized via a GitHub workflow to the repository variable `CONFIG`, and then passed to all of GitGitGadget's Actions via: ```yml config: '${{ vars.CONFIG }}' ``` For now, this input is optional, to ease the transition of GitGitGadget; Eventually, this config will be required, so that several projects can be served using identical source code in forks of the `gitgitgadget-github-app` and `gitgitgadget-workflows` repositories. Signed-off-by: Johannes Schindelin --- handle-new-mails/action.yml | 3 +++ handle-pr-comment/action.yml | 3 +++ handle-pr-push/action.yml | 3 +++ lib/ci-helper.ts | 11 ++++++++++- update-mail-to-commit-notes/action.yml | 3 +++ update-prs/action.yml | 3 +++ 6 files changed, 25 insertions(+), 1 deletion(-) diff --git a/handle-new-mails/action.yml b/handle-new-mails/action.yml index 23dc2676cb..7f35716860 100644 --- a/handle-new-mails/action.yml +++ b/handle-new-mails/action.yml @@ -2,6 +2,9 @@ name: 'Handle new mails' description: 'Processes new mails on the Git mailing list' author: 'Johannes Schindelin' inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing pr-repo-token: description: 'The access token to work on the repository that holds PRs and state' required: true diff --git a/handle-pr-comment/action.yml b/handle-pr-comment/action.yml index 564bd7dfd1..c9b65bc961 100644 --- a/handle-pr-comment/action.yml +++ b/handle-pr-comment/action.yml @@ -2,6 +2,9 @@ name: 'Handle PR Comment' description: 'Handles slash commands such as /submit and /preview' author: 'Johannes Schindelin' inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing pr-repo-token: description: 'The access token to work on the repository that holds PRs and state' required: true diff --git a/handle-pr-push/action.yml b/handle-pr-push/action.yml index e5b74c8e0c..15eab3879f 100644 --- a/handle-pr-push/action.yml +++ b/handle-pr-push/action.yml @@ -2,6 +2,9 @@ name: 'Handle PR Pushes' description: 'Handles when a PR was pushed' author: 'Johannes Schindelin' inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing pr-repo-token: description: 'The access token to work on the repository that holds PRs and state' required: true diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 0e75258ed1..4fd38a646b 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -53,8 +53,17 @@ export class CIHelper { return configFile ? await getExternalConfig(configFile) : getConfig(); } + protected static getConfigAsGitHubActionInput(): IConfig | undefined { + if (process.env.GITHUB_ACTIONS !== "true") return undefined; + const json = core.getInput("config"); + if (!json) return undefined; + const config = JSON.parse(json) as IConfig | undefined; + if (typeof config === "object" && config.project !== undefined) return config; + return undefined; + } + public constructor(workDir: string = "pr-repo.git", config?: IConfig, skipUpdate?: boolean, gggConfigDir = ".") { - this.config = config !== undefined ? setConfig(config) : getConfig(); + this.config = config !== undefined ? setConfig(config) : CIHelper.getConfigAsGitHubActionInput() || getConfig(); this.gggConfigDir = gggConfigDir; this.workDir = workDir; this.notes = new GitNotes(workDir); diff --git a/update-mail-to-commit-notes/action.yml b/update-mail-to-commit-notes/action.yml index 76e2b3b1a4..30166453c0 100644 --- a/update-mail-to-commit-notes/action.yml +++ b/update-mail-to-commit-notes/action.yml @@ -2,6 +2,9 @@ name: 'Update the mail <-> commit notes' description: 'Updates the mapping between commits and patch emails, stored in the `mail-to-commit` and `commit-to-mail` Git notes refs.' author: 'Johannes Schindelin' inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing pr-repo-token: description: 'The access token to work on the repository that holds PRs and state' required: true diff --git a/update-prs/action.yml b/update-prs/action.yml index 34dbaecf0b..f5cfd056a6 100644 --- a/update-prs/action.yml +++ b/update-prs/action.yml @@ -2,6 +2,9 @@ name: 'Update the Pull Requests' description: 'Updates the Pull Requests in response to upstream commits' author: 'Johannes Schindelin' inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing pr-repo-token: description: 'The access token to work on the repository that holds PRs and state' required: true From 24a0e2ab066dcb0d6500c5fa5c1e30546160ba37 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 31 Aug 2025 13:13:52 +0200 Subject: [PATCH 05/21] Install `typia` GitGitGadget now accepts the project configuration as a `config` Action input, in the form of a string that contains the JSON-encoded `IConfig` object. That is a bit fragile, though, as it is all-too-easy to have a typo in that object. Let's install `typia` to use the `IConfig` interface as a source of truth when validating the user input. Signed-off-by: Johannes Schindelin --- package-lock.json | 586 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 +- 2 files changed, 587 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e89fb73f3..a6666058f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,8 @@ "ts-jest-resolver": "^2.0.1", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "typescript-eslint": "8.46.0" + "typescript-eslint": "8.46.0", + "typia": "^9.7.2" }, "engines": { "node": ">= 18.16.1" @@ -1607,6 +1608,28 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", + "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3580,6 +3603,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@samchon/openapi": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@samchon/openapi/-/openapi-4.7.1.tgz", + "integrity": "sha512-+rkMlSKMt7l3KGWJVWUle1CXEm0vA8FIF2rufHl+T1gN/gGrTEhL1gDK3FHYf8Nl5XReK0r1vL6Q2QTMwQN7xQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "license": "MIT", @@ -4253,6 +4283,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.4.0.tgz", @@ -5286,6 +5323,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -5400,12 +5444,45 @@ "node": ">=0.12.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bowser": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.0.tgz", @@ -5488,6 +5565,31 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "license": "BSD-3-Clause" @@ -5609,6 +5711,13 @@ "node": ">=10" } }, + "node_modules/chardet": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "dev": true, + "license": "MIT" + }, "node_modules/chownr": { "version": "2.0.0", "license": "ISC", @@ -5637,6 +5746,42 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5692,6 +5837,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5735,6 +5890,23 @@ "node": ">=20" } }, + "node_modules/comment-json": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1", + "has-own-prop": "^2.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/comment-parser": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", @@ -5755,6 +5927,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "dev": true, @@ -5819,6 +5998,19 @@ "node": ">=0.10.0" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5900,6 +6092,16 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/dugite": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/dugite/-/dugite-2.7.1.tgz", @@ -6615,6 +6817,32 @@ "bser": "2.1.1" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6953,6 +7181,16 @@ "node": ">=8" } }, + "node_modules/has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -7068,6 +7306,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -7141,6 +7400,70 @@ "dev": true, "license": "ISC" }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/inquirer/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ipv6-normalize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz", @@ -7205,6 +7528,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7227,6 +7560,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "license": "MIT" @@ -9987,6 +10333,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "license": "MIT" @@ -10025,6 +10378,23 @@ "version": "4.1.1", "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10225,6 +10595,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", @@ -10367,6 +10744,30 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "3.1.0", "dev": true, @@ -10412,6 +10813,16 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10745,6 +11156,23 @@ ], "license": "MIT" }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10766,6 +11194,20 @@ ], "license": "MIT" }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -10773,6 +11215,21 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -10783,6 +11240,16 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10850,6 +11317,37 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -10875,6 +11373,16 @@ "node": ">=0.8.0" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10899,6 +11407,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -11098,6 +11616,16 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -11365,6 +11893,13 @@ "node": ">= 18.16.1" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tlds": { "version": "1.259.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", @@ -11604,6 +12139,38 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typia": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/typia/-/typia-9.7.2.tgz", + "integrity": "sha512-eLIKd0KHZtSvbsA+FYwX+Y0ZBt0BwVGz3GgODQX+6GfGL4DOzKW02LEx62oUZg6vCQX1BL5xyiPXAIdW+Hc51g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@samchon/openapi": "^4.7.1", + "@standard-schema/spec": "^1.0.0", + "commander": "^10.0.0", + "comment-json": "^4.2.3", + "inquirer": "^8.2.5", + "package-manager-detector": "^0.2.0", + "randexp": "^0.5.3" + }, + "bin": { + "typia": "lib/executable/typia.js" + }, + "peerDependencies": { + "typescript": ">=4.8.0 <5.10.0" + } + }, + "node_modules/typia/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/uc.micro": { "version": "2.0.0", "license": "MIT" @@ -11728,6 +12295,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "8.3.2", "dev": true, @@ -11764,6 +12338,16 @@ "makeerror": "1.0.12" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/package.json b/package.json index 356109dd36..2782c8362e 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "ts-jest-resolver": "^2.0.1", "ts-node": "^10.9.2", "typescript": "^5.9.3", - "typescript-eslint": "8.46.0" + "typescript-eslint": "8.46.0", + "typia": "^9.7.2" }, "dependencies": { "@actions/core": "^1.11.1", From 255ece3a396b8b705a914dac109f67ad63408642 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 31 Aug 2025 13:23:52 +0200 Subject: [PATCH 06/21] npx typia setup Signed-off-by: Johannes Schindelin --- package-lock.json | 83 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 6 ++-- tsconfig.json | 9 ++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6666058f0..9c0acf6d8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,8 @@ "ts-jest": "^29.4.4", "ts-jest-resolver": "^2.0.1", "ts-node": "^10.9.2", - "typescript": "^5.9.3", + "ts-patch": "^3.3.0", + "typescript": "~5.9.3", "typescript-eslint": "8.46.0", "typia": "^9.7.2" }, @@ -7106,6 +7107,47 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -7400,6 +7442,16 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/inquirer": { "version": "8.2.7", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", @@ -10254,6 +10306,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leac": { "version": "0.6.0", "license": "MIT", @@ -12051,6 +12113,25 @@ } } }, + "node_modules/ts-patch": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/ts-patch/-/ts-patch-3.3.0.tgz", + "integrity": "sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "global-prefix": "^4.0.0", + "minimist": "^1.2.8", + "resolve": "^1.22.2", + "semver": "^7.6.3", + "strip-ansi": "^6.0.1" + }, + "bin": { + "ts-patch": "bin/ts-patch.js", + "tspc": "bin/tspc.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 2782c8362e..7325eb6cb0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test:clean": "jest --clearCache && npm test", "test:config": "npm run test -- --testRegex=/tests-config/.*\\.test\\.ts", "test:watch": "jest --watch --notify --notifyMode=change --coverage", - "ci": "npm run lint && npm run test -- --ci --reporters=default --reporters=jest-junit" + "ci": "npm run lint && npm run test -- --ci --reporters=default --reporters=jest-junit", + "prepare": "ts-patch install" }, "bugs": { "url": "https://github.com/gitgitgadget/gitgitgadget/issues" @@ -70,7 +71,8 @@ "ts-jest": "^29.4.4", "ts-jest-resolver": "^2.0.1", "ts-node": "^10.9.2", - "typescript": "^5.9.3", + "ts-patch": "^3.3.0", + "typescript": "~5.9.3", "typescript-eslint": "8.46.0", "typia": "^9.7.2" }, diff --git a/tsconfig.json b/tsconfig.json index 71cbfe8ffd..97bc0e57fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,7 @@ "types": [ "node" ], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ /* Source Map Options */ @@ -54,6 +54,13 @@ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + "plugins": [ + { + "transform": "typia/lib/transform" + } + ], + "skipLibCheck": true, + "strictNullChecks": true }, "include": [ "lib/**/*.ts", From a6035135c1f2031b8d6377ee4e3d3777e8ace42a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 31 Aug 2025 13:24:39 +0200 Subject: [PATCH 07/21] CIHelper: validate the user-provided `config` Action input This uses the freshly-installed `typia` module to create a validator for the `IConfig` interface at compile-time, and uses it to validate user-provided JSON against that interface. Signed-off-by: Johannes Schindelin --- .vscode/settings.json | 1 + lib/ci-helper.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e51062ffc9..8bcacc2e87 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -83,6 +83,7 @@ "tbdiff", "Thái", "Truthy", + "typia", "unportable", "vger", "VSTS", diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 4fd38a646b..fe6a792074 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -1,6 +1,7 @@ import * as core from "@actions/core"; import * as fs from "fs"; import * as os from "os"; +import typia from "typia"; import * as util from "util"; import { spawnSync } from "child_process"; import addressparser from "nodemailer/lib/addressparser/index.js"; @@ -53,13 +54,20 @@ export class CIHelper { return configFile ? await getExternalConfig(configFile) : getConfig(); } + public static validateConfig = typia.createValidate(); + protected static getConfigAsGitHubActionInput(): IConfig | undefined { if (process.env.GITHUB_ACTIONS !== "true") return undefined; const json = core.getInput("config"); if (!json) return undefined; const config = JSON.parse(json) as IConfig | undefined; - if (typeof config === "object" && config.project !== undefined) return config; - return undefined; + const result = CIHelper.validateConfig(config); + if (result.success) return config; + throw new Error( + `Invalid config:\n- ${result.errors + .map((e) => `${e.path} (value: ${e.value}, expected: ${e.expected}): ${e.description}`) + .join("\n- ")}`, + ); } public constructor(workDir: string = "pr-repo.git", config?: IConfig, skipUpdate?: boolean, gggConfigDir = ".") { From e68da6a3805b754f994e1e7f6519ac282844b9d0 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Sun, 31 Aug 2025 16:21:22 +0200 Subject: [PATCH 08/21] IConfig: avoid "anonymous types" For the `typia`-based validator, it is good to label each and every attribute's type so that the error messages are helpful. This commit is best viewed with `--ignore-space-change`. Signed-off-by: Johannes Schindelin --- lib/project-config.ts | 111 +++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 49 deletions(-) diff --git a/lib/project-config.ts b/lib/project-config.ts index d133613816..8006ff37d6 100644 --- a/lib/project-config.ts +++ b/lib/project-config.ts @@ -8,57 +8,70 @@ export type projectInfo = { urlPrefix: string; // url to 'listserv' of mail (should it be in mailrepo?) }; +export interface IRepoConfig { + name: string; // name of the repo + owner: string; // owner of repo holding the notes (tracking data) + upstreamOwner: string; // owner of upstream ("base") repo + testOwner?: string; // owner of the test repo (if any) + branches: string[]; // remote branches to fetch - just use trackingBranches? + closingBranches: string[]; // close if the pr is added to this branch + trackingBranches: string[]; // comment if the pr is added to this branch + maintainerBranch?: string; // branch/owner manually implementing changes + host: string; +} + +export interface IMailRepoConfig { + name: string; + owner: string; + branch: string; + host: string; + url: string; + public_inbox_epoch?: number; + mirrorURL?: string; + mirrorRef?: string; + descriptiveName: string; +} +export interface IMailConfig { + author: string; + sender: string; + smtpUser: string; + smtpHost: string; +} + +export interface IAppConfig { + appID: number; + installationID: number; + name: string; + displayName: string; // name to use in comments to identify app + installedOn: string[]; // owners of clones being monitored (PR checking) + altname: string | undefined; // is this even needed? +} + +export interface ILintConfig { + maxCommitsIgnore?: string[]; // array of pull request urls to skip check + maxCommits: number; // limit on number of commits in a pull request +} + +export interface IUserConfig { + allowUserAsLogin: boolean; // use GitHub login as name if name is private +} + +export interface ISyncUpstreamBranchesConfig { + sourceRepo: string; // e.g. "gitster/git" + targetRepo: string; // e.g. "gitgitgadget/git" + sourceRefRegex?: string; // e.g. "^refs/heads/(maint-\\d|[a-z][a-z]/)" + targetRefNamespace?: string; // e.g. "git-gui/" +} + export interface IConfig { - repo: { - name: string; // name of the repo - owner: string; // owner of repo holding the notes (tracking data) - upstreamOwner: string; // owner of upstream ("base") repo - testOwner?: string; // owner of the test repo (if any) - branches: string[]; // remote branches to fetch - just use trackingBranches? - closingBranches: string[]; // close if the pr is added to this branch - trackingBranches: string[]; // comment if the pr is added to this branch - maintainerBranch?: string; // branch/owner manually implementing changes - host: string; - }; - mailrepo: { - name: string; - owner: string; - branch: string; - host: string; - url: string; - public_inbox_epoch?: number; - mirrorURL?: string; - mirrorRef?: string; - descriptiveName: string; - }; - mail: { - author: string; - sender: string; - smtpUser: string; - smtpHost: string; - }; + repo: IRepoConfig; + mailrepo: IMailRepoConfig; + mail: IMailConfig; project?: projectInfo | undefined; // project-options values - app: { - appID: number; - installationID: number; - name: string; - displayName: string; // name to use in comments to identify app - installedOn: string[]; // owners of clones being monitored (PR checking) - altname: string | undefined; // is this even needed? - }; - lint: { - maxCommitsIgnore?: string[]; // array of pull request urls to skip check - maxCommits: number; // limit on number of commits in a pull request - }; - user: { - allowUserAsLogin: boolean; // use GitHub login as name if name is private - }; - syncUpstreamBranches: Array<{ - sourceRepo: string; // e.g. "gitster/git" - targetRepo: string; // e.g. "gitgitgadget/git" - sourceRefRegex?: string; // e.g. "^refs/heads/(maint-\\d|[a-z][a-z]/)" - targetRefNamespace?: string; // e.g. "git-gui/" - }>; // branches to sync from upstream to our repo + app: IAppConfig; + lint: ILintConfig; + user: IUserConfig; + syncUpstreamBranches: ISyncUpstreamBranchesConfig[]; // branches to sync from upstream to our repo } let config: IConfig; // singleton From d25eafa5d136125098e8f5e466ecf86aba9a6fe0 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 6 Oct 2025 22:57:41 +0200 Subject: [PATCH 09/21] Include the `LintCommit` configuration in `IConfig` This way, the maximal number of columns can be configured freely per project, via the project-specific config. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 2 +- lib/commit-lint.ts | 4 ++-- lib/project-config.ts | 2 ++ tests/commit-lint.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index fe6a792074..a3d7e1397d 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -1037,7 +1037,7 @@ export class CIHelper { if (result) { const results = commits.map((commit: IPRCommit) => { - const linter = new LintCommit(commit); + const linter = new LintCommit(commit, this.config.lint.commitLintOptions); return linter.lint(); }); diff --git a/lib/commit-lint.ts b/lib/commit-lint.ts index 3eab1f22cf..7e046d7538 100644 --- a/lib/commit-lint.ts +++ b/lib/commit-lint.ts @@ -5,7 +5,7 @@ export interface ILintError { message: string; } -export interface ILintOptions { +export interface ILintCommitConfig { maxColumns?: number | undefined; // max line length } @@ -19,7 +19,7 @@ export class LintCommit { private messages: string[] = []; private maxColumns = 76; - public constructor(patch: IPRCommit, options?: ILintOptions) { + public constructor(patch: IPRCommit, options?: ILintCommitConfig) { this.blocked = false; this.lines = patch.message.split("\n"); this.patch = patch; diff --git a/lib/project-config.ts b/lib/project-config.ts index 8006ff37d6..3299c60e12 100644 --- a/lib/project-config.ts +++ b/lib/project-config.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import path from "path"; +import { ILintCommitConfig } from "./commit-lint.js"; export type projectInfo = { to: string; // email to send patches to @@ -50,6 +51,7 @@ export interface IAppConfig { export interface ILintConfig { maxCommitsIgnore?: string[]; // array of pull request urls to skip check maxCommits: number; // limit on number of commits in a pull request + commitLintOptions?: ILintCommitConfig; // options to pass to commit linter } export interface IUserConfig { diff --git a/tests/commit-lint.test.ts b/tests/commit-lint.test.ts index 37f3b6eb34..da6ec70b20 100644 --- a/tests/commit-lint.test.ts +++ b/tests/commit-lint.test.ts @@ -1,5 +1,5 @@ import { expect, jest, test } from "@jest/globals"; -import { ILintError, ILintOptions, LintCommit } from "../lib/commit-lint.js"; +import { ILintError, ILintCommitConfig, LintCommit } from "../lib/commit-lint.js"; import { IPRCommit } from "../lib/github-glue.js"; jest.setTimeout(180000); @@ -15,7 +15,7 @@ jest.setTimeout(180000); * @param check a function to verify the lint result * @param options extra linter options, if any */ -function lintCheck(commit: IPRCommit, check?: (error: ILintError) => void, options?: ILintOptions) { +function lintCheck(commit: IPRCommit, check?: (error: ILintError) => void, options?: ILintCommitConfig) { const linter = new LintCommit(commit, options); const lintError = linter.lint(); if (!check) { From 07b2da3e4a28c5f0a16aaaa87e9dc84f1b7d9d8b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 2 Sep 2025 14:01:00 +0200 Subject: [PATCH 10/21] /submit: use correct URL in the "Submitted as" message Currently this URL is constructed from the `host` and the `name` attributes of the project config setting's `mailrepo` attribute. However, the `name` is supposed to refer to the mailing list _mirror repository_, while we are interested in the URL where the web UI of the public-inbox instance lives. Luckily, we already have that in the project configuration: It's the `url` attribute. I noticed the need for this patch in https://github.com/cygwingitgadget/cygwin/pull/1, where the URL displayed after submitting v1 pointed to an incorrect location. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index a3d7e1397d..8582478cd9 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -909,7 +909,7 @@ export class CIHelper { await addComment( `Submitted as [${ metadata?.coverLetterMessageId - }](https://${this.config.mailrepo.host}/${this.config.mailrepo.name}/${ + }](${this.config.mailrepo.url.replace(/\/+$/, "")}/${ metadata?.coverLetterMessageId })\n\nTo fetch this version into \`FETCH_HEAD\`:${ code From d182e11af742baabb085b877974b6e4e40e43053 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 9 Sep 2025 22:21:13 +0200 Subject: [PATCH 11/21] parsePRCommentURLInput/parsePRURLInput: reuse pullRequestKey machinery There was prior art that I should have used, as pointed out in https://github.com/gitgitgadget/gitgitgadget/pull/1991#issuecomment-3252053407 Signed-off-by: Johannes Schindelin --- handle-pr-comment/index.js | 4 ++-- handle-pr-push/index.js | 4 ++-- lib/ci-helper.ts | 22 +++++----------------- lib/pullRequestKey.ts | 34 ++++++++++++++++++++++++++++++---- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/handle-pr-comment/index.js b/handle-pr-comment/index.js index d330752030..0667313504 100644 --- a/handle-pr-comment/index.js +++ b/handle-pr-comment/index.js @@ -2,10 +2,10 @@ async function run() { const { CIHelper } = await import("../dist/index.js") const ci = new CIHelper() - const { owner, commentId } = ci.parsePRCommentURLInput() + const { owner, comment_id } = ci.parsePRCommentURLInput() await ci.setupGitHubAction() - await ci.handleComment(owner, commentId) + await ci.handleComment(owner, comment_id) } run() diff --git a/handle-pr-push/index.js b/handle-pr-push/index.js index 91f594e976..446181eb12 100644 --- a/handle-pr-push/index.js +++ b/handle-pr-push/index.js @@ -2,10 +2,10 @@ async function run() { const { CIHelper } = await import("../dist/index.js") const ci = new CIHelper() - const { owner, prNumber } = ci.parsePRURLInput() + const { owner, pull_number } = ci.parsePRURLInput() await ci.setupGitHubAction() - await ci.handlePush(owner, prNumber) + await ci.handlePush(owner, pull_number) } run() diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 8582478cd9..e08d04f6f8 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -18,7 +18,7 @@ import { MailCommitMapping } from "./mail-commit-mapping.js"; import { IMailMetadata } from "./mail-metadata.js"; import { IPatchSeriesMetadata } from "./patch-series-metadata.js"; import { IConfig, getExternalConfig, setConfig } from "./project-config.js"; -import { getPullRequestKeyFromURL, pullRequestKey } from "./pullRequestKey.js"; +import { getPullRequestCommentKeyFromURL, getPullRequestKeyFromURL, pullRequestKey } from "./pullRequestKey.js"; import { ISMTPOptions } from "./send-mail.js"; import { fileURLToPath } from "url"; @@ -269,24 +269,12 @@ export class CIHelper { } } - public parsePRCommentURLInput(): { owner: string; repo: string; prNumber: number; commentId: number } { - const prCommentUrl = core.getInput("pr-comment-url"); - const [, owner, repo, prNumber, commentId] = - prCommentUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)#issuecomment-(\d+)$/) || []; - if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) { - throw new Error(`Invalid PR comment URL: ${prCommentUrl}`); - } - return { owner, repo, prNumber: parseInt(prNumber, 10), commentId: parseInt(commentId, 10) }; + public parsePRCommentURLInput(): { owner: string; repo: string; pull_number: number; comment_id: number } { + return getPullRequestCommentKeyFromURL(core.getInput("pr-comment-url")); } - public parsePRURLInput(): { owner: string; repo: string; prNumber: number } { - const prUrl = core.getInput("pr-url"); - - const [, owner, repo, prNumber] = prUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/) || []; - if (!this.config.app.installedOn.includes(owner) || repo !== this.config.repo.name) { - throw new Error(`Invalid PR URL: ${prUrl}`); - } - return { owner, repo, prNumber: parseInt(prNumber, 10) }; + public parsePRURLInput(): { owner: string; repo: string; pull_number: number } { + return getPullRequestKeyFromURL(core.getInput("pr-url")); } public setAccessToken(repositoryOwner: string, token: string): void { diff --git a/lib/pullRequestKey.ts b/lib/pullRequestKey.ts index b480cecfc6..40f5c45839 100644 --- a/lib/pullRequestKey.ts +++ b/lib/pullRequestKey.ts @@ -15,11 +15,37 @@ export function getPullRequestKey(pullRequest: pullRequestKeyInfo): pullRequestK return typeof pullRequest === "string" ? getPullRequestKeyFromURL(pullRequest) : pullRequest; } -export function getPullRequestKeyFromURL(pullRequestURL: string): pullRequestKey { - const match = pullRequestURL.match(/^https:\/\/github.com\/(.*)\/(.*)\/pull\/(\d+)$/); +export type pullRequestCommentKey = pullRequestKey & { comment_id: number }; + +function getPullRequestOrCommentKeyFromURL(pullRequestOrCommentURL: string): pullRequestKey & { comment_id?: number } { + const match = pullRequestOrCommentURL.match(/^https:\/\/github.com\/(.*)\/(.*)\/pull\/(\d+)(.*)$/); if (!match) { - throw new Error(`Unrecognized PR URL: "${pullRequestURL}`); + throw new Error(`Unrecognized PR URL: "${pullRequestOrCommentURL}`); + } + const match2 = match[4]?.match(/^#issuecomment-(\d+)$/); + if (match[4] && !match2) { + throw new Error(`Unrecognized PR URL: "${pullRequestOrCommentURL}`); } + return { + owner: match[1], + repo: match[2], + pull_number: parseInt(match[3], 10), + comment_id: match2 ? parseInt(match2[1], 10) : undefined, + }; +} - return { owner: match[1], repo: match[2], pull_number: parseInt(match[3], 10) }; +export function getPullRequestKeyFromURL(pullRequestURL: string): pullRequestKey { + const { comment_id, ...prKey } = getPullRequestOrCommentKeyFromURL(pullRequestURL); + if (comment_id) { + throw new Error(`Expected PR URL, not a PR comment URL: `); + } + return prKey; +} + +export function getPullRequestCommentKeyFromURL(pullRequestURL: string): pullRequestCommentKey { + const result = getPullRequestOrCommentKeyFromURL(pullRequestURL); + if (result.comment_id === undefined) { + throw new Error(`Expected PR comment URL, not a PR URL: `); + } + return result as pullRequestCommentKey; } From ee241344ba6f324f20ad9dd495daf6cfd9401022 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 9 Sep 2025 22:25:14 +0200 Subject: [PATCH 12/21] CIHelper: move the Dugite prep a bit later In the next commit, I want to add a code path to the `setupGitHubAction()` method that does not require Git at all, hence it is unnecessary to prepare for calling Dugite. So delay that prep code. This commit is best viewed with `--color-moved`. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index e08d04f6f8..594e4cd493 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -90,23 +90,6 @@ export class CIHelper { needsUpstreamBranches?: boolean; needsMailToCommitNotes?: boolean; }): Promise { - // help dugite realize where `git` is... - const gitExecutable = os.type() === "Windows_NT" ? "git.exe" : "git"; - const stripSuffix = `bin${path.sep}${gitExecutable}`; - for (const gitPath of (process.env.PATH || "/") - .split(path.delimiter) - .map((p) => path.normalize(`${p}${path.sep}${gitExecutable}`)) - // eslint-disable-next-line security/detect-non-literal-fs-filename - .filter((p) => p.endsWith(`${path.sep}${stripSuffix}`) && fs.existsSync(p))) { - process.env.LOCAL_GIT_DIRECTORY = gitPath.substring(0, gitPath.length - stripSuffix.length); - // need to override GIT_EXEC_PATH, so that Dugite can find the `git-remote-https` executable, - // see https://github.com/desktop/dugite/blob/v2.7.1/lib/git-environment.ts#L44-L64 - // Also: We cannot use `await git(["--exec-path"]);` because that would use Dugite, which would - // override `GIT_EXEC_PATH` and then `git --exec-path` would report _that_... - process.env.GIT_EXEC_PATH = spawnSync(gitPath, ["--exec-path"]).stdout.toString("utf-8").trimEnd(); - break; - } - // configure the Git committer information process.env.GIT_CONFIG_PARAMETERS = [ process.env.GIT_CONFIG_PARAMETERS, @@ -138,6 +121,23 @@ export class CIHelper { // Ignore, for now } + // help dugite realize where `git` is... + const gitExecutable = os.type() === "Windows_NT" ? "git.exe" : "git"; + const stripSuffix = `bin${path.sep}${gitExecutable}`; + for (const gitPath of (process.env.PATH || "/") + .split(path.delimiter) + .map((p) => path.normalize(`${p}${path.sep}${gitExecutable}`)) + // eslint-disable-next-line security/detect-non-literal-fs-filename + .filter((p) => p.endsWith(`${path.sep}${stripSuffix}`) && fs.existsSync(p))) { + process.env.LOCAL_GIT_DIRECTORY = gitPath.substring(0, gitPath.length - stripSuffix.length); + // need to override GIT_EXEC_PATH, so that Dugite can find the `git-remote-https` executable, + // see https://github.com/desktop/dugite/blob/v2.7.1/lib/git-environment.ts#L44-L64 + // Also: We cannot use `await git(["--exec-path"]);` because that would use Dugite, which would + // override `GIT_EXEC_PATH` and then `git --exec-path` would report _that_... + process.env.GIT_EXEC_PATH = spawnSync(gitPath, ["--exec-path"]).stdout.toString("utf-8").trimEnd(); + break; + } + // eslint-disable-next-line security/detect-non-literal-fs-filename if (!fs.existsSync(this.workDir)) await git(["init", "--bare", "--initial-branch", "unused", this.workDir]); for (const [key, value] of [ From a61a27c061a23839e7edfec5f315665a02c4a28b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 9 Sep 2025 22:27:15 +0200 Subject: [PATCH 13/21] Add a way to create/update Check Runs In `gitgitgadget-workflows`' 81a4f4d (handle-pr-comment/handle-pr-push: create a Check Run in the PR, 2025-08-19), I added logic to two workflows that create and update Check Runs. This logic is necessarily a bit repetitive (and disrupts the flow when reading those workflow definitions). So let's move the logic into the `gitgitgadget/gitgitgadget/check-run` GitHub Action. Signed-off-by: Johannes Schindelin --- check-run/action.yml | 49 ++++++++++++++++++++++++++ check-run/index.js | 13 +++++++ lib/ci-helper.ts | 82 +++++++++++++++++++++++++++++++++++++++++-- lib/github-glue.ts | 65 ++++++++++++++++++++++++++++++++++ lib/pullRequestKey.ts | 4 ++- 5 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 check-run/action.yml create mode 100644 check-run/index.js diff --git a/check-run/action.yml b/check-run/action.yml new file mode 100644 index 0000000000..dbff03d93e --- /dev/null +++ b/check-run/action.yml @@ -0,0 +1,49 @@ +name: 'Create or update a Check Run' +description: 'Mirrors a workflow run to a Check Run of a Pull Request in a different repository' +author: 'Johannes Schindelin' +inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing + pr-repo-token: + description: 'The access token to work on the repository that holds PRs and state' + required: true + upstream-repo-token: + description: 'The access token to work on PRs in the upstream repository' + required: false + test-repo-token: + description: 'The access token to work on PRs in the test repository' + required: false + pr-url: + description: 'The URL of the Pull Request (or Pull Request comment)' + required: false + check-run-id: + description: 'The Check Run to update (if empty, a new one will be created)' + default: '' + name: + description: 'The name of the CheckRun (required if check-run-id is empty)' + default: '' + title: + description: 'The Check Run title (required when creating a new one)' + default: '' + summary: + description: 'The Check Run summary (required when creating a new one)' + default: '' + text: + description: 'The Check Run text (required when creating a new one)' + default: '' + details-url: + description: 'The details URL of the Check Run (required when creating a new one)' + default: '' + conclusion: + description: 'If set, the Check Run will be marked as completed' + default: '' +outputs: + check-run-id: + description: 'The ID of the created or updated Check Run' +runs: + using: 'node20' + main: './index.js' +branding: + icon: 'git-commit' + color: 'orange' \ No newline at end of file diff --git a/check-run/index.js b/check-run/index.js new file mode 100644 index 0000000000..5706cf3c1f --- /dev/null +++ b/check-run/index.js @@ -0,0 +1,13 @@ +async function run() { + const { CIHelper } = await import("../dist/index.js") + + try { + const ci = new CIHelper() + ci.setupGitHubAction({ createOrUpdateCheckRun: true }) + } catch (e) { + console.error(e) + process.exitCode = 1 + } +} + +run() diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 594e4cd493..aae707618a 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -11,14 +11,27 @@ import { commitExists, git, emptyTreeName, revParse } from "./git.js"; import { GitNotes } from "./git-notes.js"; import { GitGitGadget, IGitGitGadgetOptions } from "./gitgitgadget.js"; import { getConfig } from "./gitgitgadget-config.js"; -import { GitHubGlue, IGitHubUser, IPRComment, IPRCommit, IPullRequestInfo, RequestError } from "./github-glue.js"; +import { + ConclusionType, + GitHubGlue, + IGitHubUser, + IPRComment, + IPRCommit, + IPullRequestInfo, + RequestError, +} from "./github-glue.js"; import { toPrettyJSON } from "./json-util.js"; import { MailArchiveGitHelper } from "./mail-archive-helper.js"; import { MailCommitMapping } from "./mail-commit-mapping.js"; import { IMailMetadata } from "./mail-metadata.js"; import { IPatchSeriesMetadata } from "./patch-series-metadata.js"; import { IConfig, getExternalConfig, setConfig } from "./project-config.js"; -import { getPullRequestCommentKeyFromURL, getPullRequestKeyFromURL, pullRequestKey } from "./pullRequestKey.js"; +import { + getPullRequestCommentKeyFromURL, + getPullRequestKeyFromURL, + getPullRequestOrCommentKeyFromURL, + pullRequestKey, +} from "./pullRequestKey.js"; import { ISMTPOptions } from "./send-mail.js"; import { fileURLToPath } from "url"; @@ -89,6 +102,7 @@ export class CIHelper { needsMailingListMirror?: boolean; needsUpstreamBranches?: boolean; needsMailToCommitNotes?: boolean; + createOrUpdateCheckRun?: boolean; }): Promise { // configure the Git committer information process.env.GIT_CONFIG_PARAMETERS = [ @@ -121,6 +135,8 @@ export class CIHelper { // Ignore, for now } + if (setupOptions?.createOrUpdateCheckRun) return await this.createOrUpdateCheckRun(); + // help dugite realize where `git` is... const gitExecutable = os.type() === "Windows_NT" ? "git.exe" : "git"; const stripSuffix = `bin${path.sep}${gitExecutable}`; @@ -269,6 +285,68 @@ export class CIHelper { } } + protected static validateConclusion = typia.createValidate(); + + protected async createOrUpdateCheckRun(): Promise { + const { owner, repo, pull_number } = getPullRequestOrCommentKeyFromURL(core.getInput("pr-url")); + let check_run_id = ((id?: string) => (!id ? undefined : Number.parseInt(id, 10)))( + core.getInput("check-run-id"), + ); + const name = core.getInput("name") || undefined; + const title = core.getInput("title") || undefined; + const summary = core.getInput("summary") || undefined; + const text = core.getInput("text") || undefined; + const detailsURL = core.getInput("details-url") || undefined; + const conclusion = core.getInput("conclusion") || undefined; + + if (!check_run_id) { + const problems = []; + if (!name) problems.push(`name is required`); + if (!title) problems.push(`title is required`); + if (!summary) problems.push(`summary is required`); + if (conclusion) { + const result = CIHelper.validateConclusion(conclusion); + if (!result.success) problems.push(result.errors); + } + if (problems.length) throw new Error(`Could not create Check Run:${JSON.stringify(problems, null, 2)}`); + + ({ id: check_run_id } = await this.github.createCheckRun({ + owner, + repo, + pull_number, + name: name!, + output: { + title: title!, + summary: summary!, + text, + }, + detailsURL, + conclusion: conclusion as ConclusionType | undefined, + })); + core.setOutput("check-run-id", check_run_id); + } else { + const problems = []; + if (name) problems.push(`Specify either check-run-id or name but not both`); + if (!summary && (title || text)) problems.push(`title or text require a summary`); + if (problems.length) throw new Error(`Could not create Check Run:${JSON.stringify(problems, null, 2)}`); + + await this.github.updateCheckRun({ + owner, + repo, + check_run_id, + output: summary + ? { + title, + summary, + text, + } + : undefined, + detailsURL, + conclusion: conclusion as ConclusionType | undefined, + }); + } + } + public parsePRCommentURLInput(): { owner: string; repo: string; pull_number: number; comment_id: number } { return getPullRequestCommentKeyFromURL(core.getInput("pr-comment-url")); } diff --git a/lib/github-glue.ts b/lib/github-glue.ts index a20e0af38c..ca7900e139 100644 --- a/lib/github-glue.ts +++ b/lib/github-glue.ts @@ -51,6 +51,17 @@ export interface IGitHubUser { type: string; } +export type ConclusionType = + | "action_required" + | "cancelled" + | "failure" + | "neutral" + | "success" + | "skipped" + | "stale" + | "timed_out" + | undefined; + export class GitHubGlue { public workDir: string; protected client: Octokit = new Octokit(); // add { log: console } to debug @@ -477,6 +488,60 @@ export class GitHubGlue { this.tokens.set(repositoryOwner, token); } + public async createCheckRun(options: { + owner: string; + repo: string; + pull_number: number; + name: string; + output?: { + title: string; + summary: string; + text?: string; + }; + details_url?: string; + conclusion?: ConclusionType; + }): Promise<{ id: number }> { + if (process.env.GITGITGADGET_DRY_RUN) { + console.log(`Would create Check Run with options ${JSON.stringify(options, null, 2)}`); + return { id: -1 }; // debug mode does not actually do anything + } + + await this.ensureAuthenticated(options.owner); + const prInfo = await this.getPRInfo(options); + const { data } = await this.client.checks.create({ + ...options, + head_sha: prInfo.headCommit, + status: options.conclusion ? "completed" : "in_progress", + }); + return data; + } + + public async updateCheckRun(options: { + owner: string; + repo: string; + check_run_id: number; + output?: { + title?: string; + summary: string; + text?: string; + }; + detailsURL?: string; + conclusion?: ConclusionType; + }): Promise<{ id: number }> { + if (process.env.GITGITGADGET_DRY_RUN) { + console.log(`Would create Check Run with options ${JSON.stringify(options, null, 2)}`); + return { id: -1 }; // debug mode does not actually do anything + } + + await this.ensureAuthenticated(options.owner); + const { data } = await this.client.checks.update({ + ...options, + conclusion: options.conclusion, + status: options.conclusion ? "completed" : "in_progress", + }); + return data; + } + protected async ensureAuthenticated(repositoryOwner: string): Promise { if (repositoryOwner !== this.authenticated) { let token = this.tokens.get(repositoryOwner); diff --git a/lib/pullRequestKey.ts b/lib/pullRequestKey.ts index 40f5c45839..a7b8bc3e99 100644 --- a/lib/pullRequestKey.ts +++ b/lib/pullRequestKey.ts @@ -17,7 +17,9 @@ export function getPullRequestKey(pullRequest: pullRequestKeyInfo): pullRequestK export type pullRequestCommentKey = pullRequestKey & { comment_id: number }; -function getPullRequestOrCommentKeyFromURL(pullRequestOrCommentURL: string): pullRequestKey & { comment_id?: number } { +export function getPullRequestOrCommentKeyFromURL( + pullRequestOrCommentURL: string, +): pullRequestKey & { comment_id?: number } { const match = pullRequestOrCommentURL.match(/^https:\/\/github.com\/(.*)\/(.*)\/pull\/(\d+)(.*)$/); if (!match) { throw new Error(`Unrecognized PR URL: "${pullRequestOrCommentURL}`); From eba8635a4649848d2996b4d830536c88da4696d5 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 6 Oct 2025 19:24:13 +0200 Subject: [PATCH 14/21] check-run: use a reasonable default for `details-url` The details URL should in almost all cases point to the current workflow run. So let's do that by default! Signed-off-by: Johannes Schindelin --- check-run/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check-run/action.yml b/check-run/action.yml index dbff03d93e..a276a4e434 100644 --- a/check-run/action.yml +++ b/check-run/action.yml @@ -34,7 +34,7 @@ inputs: default: '' details-url: description: 'The details URL of the Check Run (required when creating a new one)' - default: '' + default: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' conclusion: description: 'If set, the Check Run will be marked as completed' default: '' From 5053061aff58b518c751bb8272cc397cdf4ad684 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 6 Oct 2025 18:23:18 +0200 Subject: [PATCH 15/21] check-run: use Typia for type checking This not only makes the validation more robust, easier to read (and verify the logic), but also prepares for persisting the parameters in an Action state so that subsequent calls to the Action can update, say, the text, without having to re-specify all of the parameters. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 100 ++++++++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index aae707618a..ec3b75ba12 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -285,64 +285,56 @@ export class CIHelper { } } - protected static validateConclusion = typia.createValidate(); - protected async createOrUpdateCheckRun(): Promise { - const { owner, repo, pull_number } = getPullRequestOrCommentKeyFromURL(core.getInput("pr-url")); - let check_run_id = ((id?: string) => (!id ? undefined : Number.parseInt(id, 10)))( - core.getInput("check-run-id"), - ); - const name = core.getInput("name") || undefined; - const title = core.getInput("title") || undefined; - const summary = core.getInput("summary") || undefined; - const text = core.getInput("text") || undefined; - const detailsURL = core.getInput("details-url") || undefined; - const conclusion = core.getInput("conclusion") || undefined; - - if (!check_run_id) { - const problems = []; - if (!name) problems.push(`name is required`); - if (!title) problems.push(`title is required`); - if (!summary) problems.push(`summary is required`); - if (conclusion) { - const result = CIHelper.validateConclusion(conclusion); - if (!result.success) problems.push(result.errors); + type CheckRunParameters = { + owner: string; + repo: string; + pull_number: number; + check_run_id?: number; + name: string; + output?: { + title: string; + summary: string; + text?: string; + }; + detailsURL?: string; + conclusion?: ConclusionType; + }; + const params = {} as CheckRunParameters; + + const validateCheckRunParameters = () => { + const result = typia.createValidate()(params); + if (!result.success) { + throw new Error( + `Invalid check-run state:\n- ${result.errors + .map((e) => `${e.path} (value: ${e.value}, expected: ${e.expected}): ${e.description}`) + .join("\n- ")}`, + ); } - if (problems.length) throw new Error(`Could not create Check Run:${JSON.stringify(problems, null, 2)}`); - - ({ id: check_run_id } = await this.github.createCheckRun({ - owner, - repo, - pull_number, - name: name!, - output: { - title: title!, - summary: summary!, - text, - }, - detailsURL, - conclusion: conclusion as ConclusionType | undefined, - })); - core.setOutput("check-run-id", check_run_id); - } else { - const problems = []; - if (name) problems.push(`Specify either check-run-id or name but not both`); - if (!summary && (title || text)) problems.push(`title or text require a summary`); - if (problems.length) throw new Error(`Could not create Check Run:${JSON.stringify(problems, null, 2)}`); + }; + ["pr-url", "check-run-id", "name", "title", "summary", "text", "details-url", "conclusion"] + .map((name) => [name.replaceAll("-", "_"), core.getInput(name)] as const) + .forEach(([key, value]) => { + if (!value) return; + if (key === "pr-url") Object.assign(params, getPullRequestOrCommentKeyFromURL(value)); + else if (key === "check-run-id") params.check_run_id = Number.parseInt(value, 10); + else if (key === "details-url") params.detailsURL = value; + else if (key === "title" || key === "summary" || key === "text") { + if (!params.output) Object.assign(params, { output: {} }); + (params.output as { [key: string]: string })[key] = value; + } else (params as unknown as { [key: string]: string })[key.replaceAll("-", "_")] = value; + }); + validateCheckRunParameters(); + + if (params.check_run_id === undefined) { + ({ id: params.check_run_id } = await this.github.createCheckRun(params)); + core.setOutput("check-run-id", params.check_run_id); + } else { await this.github.updateCheckRun({ - owner, - repo, - check_run_id, - output: summary - ? { - title, - summary, - text, - } - : undefined, - detailsURL, - conclusion: conclusion as ConclusionType | undefined, + ...params, + // needed to pacify TypeScript's concerns about the ID being potentially undefined + check_run_id: params.check_run_id, }); } } From 911a2593f5725a15734bb788d0da6966ddd39f42 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 6 Oct 2025 19:57:38 +0200 Subject: [PATCH 16/21] check-run: use Typia for type checking This not only makes the validation more robust, easier to read (and verify the logic), but also prepares for persisting the parameters in an Action state so that subsequent calls to the Action can update, say, the text, without having to re-specify all of the parameters. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index ec3b75ba12..d434940d4d 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -297,7 +297,7 @@ export class CIHelper { summary: string; text?: string; }; - detailsURL?: string; + details_url?: string; conclusion?: ConclusionType; }; const params = {} as CheckRunParameters; @@ -317,13 +317,12 @@ export class CIHelper { .map((name) => [name.replaceAll("-", "_"), core.getInput(name)] as const) .forEach(([key, value]) => { if (!value) return; - if (key === "pr-url") Object.assign(params, getPullRequestOrCommentKeyFromURL(value)); - else if (key === "check-run-id") params.check_run_id = Number.parseInt(value, 10); - else if (key === "details-url") params.detailsURL = value; + if (key === "pr_url") Object.assign(params, getPullRequestOrCommentKeyFromURL(value)); + else if (key === "check_run_id") params.check_run_id = Number.parseInt(value, 10); else if (key === "title" || key === "summary" || key === "text") { if (!params.output) Object.assign(params, { output: {} }); (params.output as { [key: string]: string })[key] = value; - } else (params as unknown as { [key: string]: string })[key.replaceAll("-", "_")] = value; + } else (params as unknown as { [key: string]: string })[key] = value; }); validateCheckRunParameters(); From 2096531731af2748277233d7784c5e66bc358416 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 3 Oct 2025 18:29:15 +0200 Subject: [PATCH 17/21] check-run: persist parameters in an Action state ... and reuse it when called a second time. That way, one can update, say, only the text, without having to re-specify all the details _again_. For technical details, the token and the config _do_ need to be re-specified again (the token because of security concerns, the config so that the appropriate token can be used just in case that pr-repo and upstream-repo can be handled by the same workflow). Note: Instead of using `core.saveState()`, the state is explicitly persisted via the environment variable `STATE_check-run` (yes, it _is_ a funny name). The reasons for that are: 1. Subsequent invocations of the Action would _not_ get the state, only the `post` Action would. 2. The `post` Action would only receive the state saved by the _first_ Action invocation, subsequent `saveState()` calls would be ignored! Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index d434940d4d..da6fe83b63 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -300,7 +300,7 @@ export class CIHelper { details_url?: string; conclusion?: ConclusionType; }; - const params = {} as CheckRunParameters; + const params = JSON.parse(core.getState("check-run") || "{}") as CheckRunParameters; const validateCheckRunParameters = () => { const result = typia.createValidate()(params); @@ -312,6 +312,7 @@ export class CIHelper { ); } }; + if (Object.keys(params).length) validateCheckRunParameters(); ["pr-url", "check-run-id", "name", "title", "summary", "text", "details-url", "conclusion"] .map((name) => [name.replaceAll("-", "_"), core.getInput(name)] as const) @@ -336,6 +337,7 @@ export class CIHelper { check_run_id: params.check_run_id, }); } + core.exportVariable("STATE_check-run", JSON.stringify(params)); } public parsePRCommentURLInput(): { owner: string; repo: string; pull_number: number; comment_id: number } { From bc996adcf995fdd8cb5d34f010b2ab0f00b9e755 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 9 Sep 2025 23:59:06 +0200 Subject: [PATCH 18/21] check-run: automatically update the conclusion in a `post` step In a GitHub Action it is possible to register a `post` Action which will run, if the main Action has been run in a GitHub workflow's job, as an extra step after all the regular steps have been executed (or skipped). This presents a fine opportunity to mark the the Check Run as completed _automatically_, without the need to call the Action _again_. By adding a new input called job status and filling it automatically with -- you guessed it -- the job status, we can even automatically determine the conclusion (I verified manually that this will receive the expected value in the `post` Action). Signed-off-by: Johannes Schindelin --- .github/workflows/push-github-actions.yml | 2 +- check-run/action.yml | 4 ++++ check-run/post.js | 13 +++++++++++++ lib/ci-helper.ts | 22 ++++++++++++++++++---- 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 check-run/post.js diff --git a/.github/workflows/push-github-actions.yml b/.github/workflows/push-github-actions.yml index d7084499f5..cf5d224358 100644 --- a/.github/workflows/push-github-actions.yml +++ b/.github/workflows/push-github-actions.yml @@ -66,7 +66,7 @@ jobs: # Now, add the generated files git add -A dist/ && # Remove the rest - git rm -r -- \* ':(exclude)dist/' ':(exclude)*/action.yml' ':(exclude)*/index.js' && + git rm -r -- \* ':(exclude)dist/' ':(exclude)*/action.yml' ':(exclude)*/index.js' ':(exclude)*/post.js' && # Now make that fake merge commit tree=$(git write-tree) && diff --git a/check-run/action.yml b/check-run/action.yml index a276a4e434..610c7a9319 100644 --- a/check-run/action.yml +++ b/check-run/action.yml @@ -38,12 +38,16 @@ inputs: conclusion: description: 'If set, the Check Run will be marked as completed' default: '' + job-status: + description: 'Needed at the end of the job' + default: ${{ job.status }} outputs: check-run-id: description: 'The ID of the created or updated Check Run' runs: using: 'node20' main: './index.js' + post: './post.js' branding: icon: 'git-commit' color: 'orange' \ No newline at end of file diff --git a/check-run/post.js b/check-run/post.js new file mode 100644 index 0000000000..3682f8fca8 --- /dev/null +++ b/check-run/post.js @@ -0,0 +1,13 @@ +async function run() { + const { CIHelper } = await import("../dist/index.js") + + try { + const ci = new CIHelper() + ci.setupGitHubAction({ createOrUpdateCheckRun: "post" }) + } catch (e) { + console.error(e) + process.exitCode = 1 + } +} + +run() diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index da6fe83b63..34ae748171 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -102,7 +102,7 @@ export class CIHelper { needsMailingListMirror?: boolean; needsUpstreamBranches?: boolean; needsMailToCommitNotes?: boolean; - createOrUpdateCheckRun?: boolean; + createOrUpdateCheckRun?: boolean | "post"; }): Promise { // configure the Git committer information process.env.GIT_CONFIG_PARAMETERS = [ @@ -135,7 +135,9 @@ export class CIHelper { // Ignore, for now } - if (setupOptions?.createOrUpdateCheckRun) return await this.createOrUpdateCheckRun(); + if (setupOptions?.createOrUpdateCheckRun) { + return await this.createOrUpdateCheckRun(setupOptions.createOrUpdateCheckRun === "post"); + } // help dugite realize where `git` is... const gitExecutable = os.type() === "Windows_NT" ? "git.exe" : "git"; @@ -285,7 +287,7 @@ export class CIHelper { } } - protected async createOrUpdateCheckRun(): Promise { + protected async createOrUpdateCheckRun(runPost: boolean): Promise { type CheckRunParameters = { owner: string; repo: string; @@ -299,6 +301,7 @@ export class CIHelper { }; details_url?: string; conclusion?: ConclusionType; + job_status?: ConclusionType; }; const params = JSON.parse(core.getState("check-run") || "{}") as CheckRunParameters; @@ -314,7 +317,7 @@ export class CIHelper { }; if (Object.keys(params).length) validateCheckRunParameters(); - ["pr-url", "check-run-id", "name", "title", "summary", "text", "details-url", "conclusion"] + ["pr-url", "check-run-id", "name", "title", "summary", "text", "details-url", "conclusion", "job-status"] .map((name) => [name.replaceAll("-", "_"), core.getInput(name)] as const) .forEach(([key, value]) => { if (!value) return; @@ -327,6 +330,17 @@ export class CIHelper { }); validateCheckRunParameters(); + if (runPost) { + if (!params.check_run_id) { + core.info("No Check Run ID found in state; doing nothing"); + return; + } + if (!params.conclusion) { + Object.assign(params, { conclusion: params.job_status }); + validateCheckRunParameters(); + } + } + if (params.check_run_id === undefined) { ({ id: params.check_run_id } = await this.github.createCheckRun(params)); core.setOutput("check-run-id", params.check_run_id); From c127af2a61288ab64f1b894e75f0a44856eb78d6 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 1 Sep 2025 17:04:18 +0200 Subject: [PATCH 19/21] CIHelper: add a way to create the initial Git notes GitGitGadget stores its information in Git notes that are pushed to the `pr-repo`. These notes need to be initialized before any 3rd-party project can be handled by GitGitGadget. Signed-off-by: Johannes Schindelin --- lib/ci-helper.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++- lib/git-notes.ts | 8 +++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 34ae748171..576264ffa0 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -21,7 +21,7 @@ import { RequestError, } from "./github-glue.js"; import { toPrettyJSON } from "./json-util.js"; -import { MailArchiveGitHelper } from "./mail-archive-helper.js"; +import { MailArchiveGitHelper, stateKey as mailArchiveStateKey } from "./mail-archive-helper.js"; import { MailCommitMapping } from "./mail-commit-mapping.js"; import { IMailMetadata } from "./mail-metadata.js"; import { IPatchSeriesMetadata } from "./patch-series-metadata.js"; @@ -103,6 +103,7 @@ export class CIHelper { needsUpstreamBranches?: boolean; needsMailToCommitNotes?: boolean; createOrUpdateCheckRun?: boolean | "post"; + createGitNotes?: boolean; }): Promise { // configure the Git committer information process.env.GIT_CONFIG_PARAMETERS = [ @@ -136,6 +137,9 @@ export class CIHelper { } if (setupOptions?.createOrUpdateCheckRun) { + if (setupOptions?.createGitNotes) { + throw new Error(`Cannot use createOrUpdateCheckRun and createGitNotes at the same time`); + } return await this.createOrUpdateCheckRun(setupOptions.createOrUpdateCheckRun === "post"); } @@ -169,6 +173,56 @@ export class CIHelper { ]) { await git(["config", key, value], { workDir: this.workDir }); } + if (setupOptions?.createGitNotes) { + if ( + setupOptions.needsMailToCommitNotes || + setupOptions.needsUpstreamBranches || + setupOptions.needsMailingListMirror + ) { + throw new Error("`createGitNotes` cannot be combined with any other options"); + } + const initialUser = core.getInput("initial-user"); + console.time("Retrieving latest mail repo revision"); + const fetchLatestMailRepoRevision = await git([ + "ls-remote", + `${this.config.mailrepo.url}/${this.config.mailrepo.public_inbox_epoch ?? 1}`, + this.config.mailrepo.branch, + ]); + const latestMailRepoRevision = fetchLatestMailRepoRevision.split("\t")[0]; + console.timeEnd("Retrieving latest mail repo revision"); + console.time("verify that Git notes do not yet exist"); + const existingNotes = await git( + [ + "ls-remote", + "origin", + GitNotes.defaultNotesRef, + "refs/notes/mail-to-commit", + "refs/notes/commit-to-mail", + ], + { + workDir: this.workDir, + }, + ); + if (existingNotes !== "") { + throw new Error(`Git notes already exist in ${this.workDir}:\n${existingNotes}`); + } + console.timeEnd("verify that Git notes do not yet exist"); + console.time("create the initial Git notes and push them"); + for (const key of ["mail-to-commit", "commit-to-mail"]) { + const notes = new GitNotes(this.workDir, `refs/notes/${key}`); + await notes.initializeWithEmptyCommit(); + await notes.push(this.urlRepo, this.notesPushToken); + } + const options: IGitGitGadgetOptions = { + allowedUsers: [initialUser], + }; + await this.notes.set("", options, true); + await this.notes.set(mailArchiveStateKey, latestMailRepoRevision, false); + await this.notes.push(this.urlRepo, this.notesPushToken); + console.timeEnd("create the initial Git notes and push them"); + return; + } + console.time("fetch Git notes"); const notesRefs = [GitNotes.defaultNotesRef]; if (setupOptions?.needsMailToCommitNotes) { diff --git a/lib/git-notes.ts b/lib/git-notes.ts index 06ebac8312..677a705713 100644 --- a/lib/git-notes.ts +++ b/lib/git-notes.ts @@ -120,6 +120,14 @@ export class GitNotes { return notes.replace(/^[^]*\n\n/, ""); } + public async initializeWithEmptyCommit(): Promise { + const emptyTree = await git(["hash-object", "-t", "tree", "--stdin"], { stdin: "", workDir: this.workDir }); + const emptyCommit = await git(["commit-tree", "-m", "Initial empty commit", emptyTree], { + workDir: this.workDir, + }); + await git(["update-ref", this.notesRef, emptyCommit, ""], { workDir: this.workDir }); + } + public async update(url: string): Promise { if (this.notesRef.match(/^refs\/notes\/(gitgitgadget|commit-to-mail|mail-to-commit)$/)) { await git(["fetch", "--no-tags", url, `+${this.notesRef}:${this.notesRef}`], { workDir: this.workDir }); From 7d8d4f216e964714423c147fcc3b98a3d81f2cf4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 1 Sep 2025 17:12:09 +0200 Subject: [PATCH 20/21] Add the `initialize-git-notes` Action This Action exposes the newly-added functionality to create the initial set of Git notes that GitGitGadget needs in order to persist its state. Signed-off-by: Johannes Schindelin --- initialize-git-notes/action.yml | 20 ++++++++++++++++++++ initialize-git-notes/index.js | 11 +++++++++++ 2 files changed, 31 insertions(+) create mode 100644 initialize-git-notes/action.yml create mode 100644 initialize-git-notes/index.js diff --git a/initialize-git-notes/action.yml b/initialize-git-notes/action.yml new file mode 100644 index 0000000000..aa3d0bbf30 --- /dev/null +++ b/initialize-git-notes/action.yml @@ -0,0 +1,20 @@ +name: 'Initialize the Git notes' +description: 'Creates initial, mostly empty Git notes for GitGitGadget to store its state in.' +author: 'Johannes Schindelin' +inputs: + config: + description: 'The GitGitGadget configuration to use (see https://github.com/gitgitgadget/gitgitgadget/blob/HEAD/lib/project-config.ts)' + default: '' # sadly, ${{ vars.CONFIG }} does not work, and documentation about what contexts are permissible here is sorely missing + pr-repo-token: + description: 'The access token to work on the repository that holds PRs and state' + required: true + initial-user: + description: 'The user that is initially the only one allowed to use GitGitGadget in the given project' + default: ${{ github.actor }} + required: true +runs: + using: 'node20' + main: './index.js' +branding: + icon: 'git-commit' + color: 'orange' \ No newline at end of file diff --git a/initialize-git-notes/index.js b/initialize-git-notes/index.js new file mode 100644 index 0000000000..b08b482e7c --- /dev/null +++ b/initialize-git-notes/index.js @@ -0,0 +1,11 @@ +async function run() { + const { CIHelper } = await import("../dist/index.js") + + const ci = new CIHelper() + + await ci.setupGitHubAction({ + createGitNotes: true, + }) +} + +run() From 648d8b1fbed134f51b3328b366b2046b86f2f9e7 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 3 Oct 2025 18:04:21 +0200 Subject: [PATCH 21/21] mail-archive-helper: recommend `initialize-git-notes` over `misc-helper` The idea is to retire the `misc-helper` and do everything via GitHub workflows/Actions in GitGitGadget. Signed-off-by: Johannes Schindelin --- lib/mail-archive-helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mail-archive-helper.ts b/lib/mail-archive-helper.ts index d5e6e93f46..6c454ba0ff 100644 --- a/lib/mail-archive-helper.ts +++ b/lib/mail-archive-helper.ts @@ -269,7 +269,7 @@ export class MailArchiveGitHelper { throw new Error( [ "Mail archive email commit tip not set. ", - "Please run `misc-helper init-email-commit-tip` to set the value.", + "Please run the `initialize-git-notes` workflow to set the value.", ].join(""), ); }