diff --git a/lib/ci-helper.ts b/lib/ci-helper.ts index 0e61f3ac88..92b802074a 100644 --- a/lib/ci-helper.ts +++ b/lib/ci-helper.ts @@ -10,6 +10,7 @@ import { MailArchiveGitHelper } from "./mail-archive-helper"; import { MailCommitMapping } from "./mail-commit-mapping"; import { IMailMetadata } from "./mail-metadata"; import { IPatchSeriesMetadata } from "./patch-series-metadata"; +import { IConfig, getConfig } from "./project-config"; import { getPullRequestKeyFromURL, pullRequestKey } from "./pullRequestKey"; const readFile = util.promisify(fs.readFile); @@ -23,6 +24,7 @@ type CommentFunction = (comment: string) => Promise; * commit. */ export class CIHelper { + public readonly config: IConfig = getConfig(); public readonly workDir?: string; public readonly notes: GitNotes; public readonly urlBase: string; @@ -34,9 +36,7 @@ export class CIHelper { protected testing: boolean; private gggNotesUpdated: boolean; private mail2CommitMapUpdated: boolean; - protected maxCommitsExceptions = new Set([ - "https://github.com/gitgitgadget/git/pull/923" - ]); + protected maxCommitsExceptions: string[]; public constructor(workDir?: string, skipUpdate?: boolean, gggConfigDir = ".") { this.gggConfigDir = gggConfigDir; @@ -47,8 +47,9 @@ export class CIHelper { this.mail2CommitMapUpdated = !!skipUpdate; this.github = new GitHubGlue(workDir, "git"); this.testing = false; - this.urlBase = `https://github.com/gitgitgadget/`; - this.urlRepo = `${this.urlBase}git/`; + this.maxCommitsExceptions = this.config.lint?.maxCommitsIgnore || []; + this.urlBase = `https://github.com/${this.config.repo.owner}/`; + this.urlRepo = `${this.urlBase}${this.config.repo.name}/`; } /* @@ -95,7 +96,7 @@ export class CIHelper { * is one, and if it was not yet recorded in GitGitGadget's metadata, record * it and create a GitHub Commit Status. * - * @returns `true` iff the metadata had to be updated + * @returns `true` if the metadata had to be updated */ public async updateCommitMapping(messageID: string, upstreamCommit?: string): Promise { await this.maybeUpdateGGGNotes(); @@ -121,7 +122,8 @@ export class CIHelper { await this.notes.set(messageID, mailMeta, true); if (!this.testing && mailMeta.pullRequestURL && mailMeta.pullRequestURL.startsWith(this.urlBase) ) { - await this.github.annotateCommit(mailMeta.originalCommit, upstreamCommit, "gitgitgadget", "git"); + await this.github.annotateCommit(mailMeta.originalCommit, upstreamCommit, + this.config.repo.owner, this.config.repo.baseOwner); } return true; @@ -129,11 +131,15 @@ export class CIHelper { public async updateCommitMappings(): Promise { if (!this.gggNotesUpdated) { - await git(["fetch", this.urlRepo, - "--tags", + const args = []; + + for (const branch of this.config.repo.branches) { + args.push(`refs/heads/${branch}:refs/remotes/upstream/${branch}`); + } + + await git(["fetch", this.urlRepo, "--tags", "+refs/notes/gitgitgadget:refs/notes/gitgitgadget", - "+refs/heads/maint:refs/remotes/upstream/maint", - "+refs/heads/seen:refs/remotes/upstream/seen"], + ...args], { workDir: this.workDir }); this.gggNotesUpdated = true; } @@ -309,24 +315,28 @@ export class CIHelper { const prKey = getPullRequestKeyFromURL(pullRequestURL); - // Identify branch in gitster/git + // Identify branch in maintainer repo + const maintainerBranch = `refs/remotes/${this.config.repo.maintainerBranch}/`; + const maintainerRepo = `${this.config.repo.maintainerBranch}.${this.config.repo.name}`; + let gitsterBranch: string | undefined = await git(["for-each-ref", `--points-at=${tipCommitInGitGit}`, - "--format=%(refname)", "refs/remotes/gitster/"], + "--format=%(refname)", maintainerBranch], { workDir: this.workDir }); if (gitsterBranch) { const newline = gitsterBranch.indexOf("\n"); if (newline > 0) { - const comment2 = `Found multiple candidates in gitster/git:\n${gitsterBranch};\n\nUsing the first one.`; + const comment2 = `Found multiple candidates in ${maintainerRepo}:\n${ + gitsterBranch};\n\nUsing the first one.`; const url2 = await this.github.addPRComment(prKey, comment2); console.log(`Added comment about ${gitsterBranch}: ${url2}`); - gitsterBranch = gitsterBranch.substr(0, newline); + gitsterBranch = gitsterBranch.substring(0, newline); } - gitsterBranch = gitsterBranch.replace(/^refs\/remotes\/gitster\//, ""); + gitsterBranch = gitsterBranch.substring(maintainerBranch.length); const comment = `This branch is now known as [\`${gitsterBranch - }\`](https://github.com/gitster/git/commits/${gitsterBranch}).`; + }\`](https://github.com/${maintainerRepo}/commits/${gitsterBranch}).`; if (prMeta.branchNameInGitsterGit !== gitsterBranch) { prMeta.branchNameInGitsterGit = gitsterBranch; notesUpdated = true; @@ -338,13 +348,13 @@ export class CIHelper { let closePR: string | undefined; const prLabelsToAdd: string[] = []; - for (const branch of ["seen", "next", "master", "maint"]) { + for (const branch of this.config.repo.trackingBranches) { const mergeCommit = await this.identifyMergeCommit(branch, tipCommitInGitGit); if (!mergeCommit) { continue; } - if (branch === "master" || branch === "maint") { + if (this.config.repo.closingBranches.includes(branch)) { closePR = mergeCommit; } @@ -359,8 +369,8 @@ export class CIHelper { prLabelsToAdd.push(branch); // Add comment on GitHub - const comment = `This patch series was integrated into ${branch - } via https://github.com/git/git/commit/${mergeCommit}.`; + const comment = `This patch series was integrated into ${branch} via https://github.com/${ + this.config.repo.baseOwner}/${this.config.repo.name}/commit/${mergeCommit}.`; const url = await this.github.addPRComment(prKey, comment); console.log(`Added comment about ${branch}: ${url}`); } @@ -514,11 +524,12 @@ export class CIHelper { const argument = match[3]; const prKey = { owner: repositoryOwner, - repo: "git", + repo: this.config.repo.name, pull_number: comment.prNumber }; - const pullRequestURL = `https://github.com/${repositoryOwner}/git/pull/${comment.prNumber}`; + const pullRequestURL = `https://github.com/${repositoryOwner}/${this.config.repo.name + }/pull/${comment.prNumber}`; console.log(`Handling command ${command} with argument ${argument} at ${ pullRequestURL}#issuecomment-${commentID}`); @@ -531,7 +542,7 @@ export class CIHelper { try { const gitGitGadget = await GitGitGadget.get(this.gggConfigDir, this.workDir); if (!gitGitGadget.isUserAllowed(comment.author)) { - throw new Error(`User ${comment.author} is not yet permitted to use GitGitGadget`); + throw new Error(`User ${comment.author} is not yet permitted to use ${this.config.app.displayName}`); } const getPRAuthor = async (): Promise => { @@ -560,7 +571,7 @@ export class CIHelper { const metadata = await gitGitGadget.submit(pr, userInfo); const code = "\n```"; await addComment(`Submitted as [${metadata?.coverLetterMessageId - }](https://lore.kernel.org/git/${ + }](https://${this.config.mailrepo.host}/${this.config.mailrepo.name}/${ metadata?.coverLetterMessageId})\n\nTo fetch this version into \`FETCH_HEAD\`:${ code}\ngit fetch ${this.urlRepo} ${metadata?.latestTag}${code }\n\nTo fetch this version to local tag \`${metadata?.latestTag}\`:${ @@ -600,17 +611,18 @@ export class CIHelper { } if (await gitGitGadget.allowUser(comment.author, accountName)) { - await addComment(`User ${accountName} is now allowed to use GitGitGadget.${extraComment}`); + await addComment(`User ${accountName} is now allowed to use ${this.config.app.displayName}.${ + extraComment}`); } else { - await addComment(`User ${accountName} already allowed to use GitGitGadget.`); + await addComment(`User ${accountName} already allowed to use ${this.config.app.displayName}.`); } } else if (command === "/disallow") { const accountName = argument || await getPRAuthor(); if (await gitGitGadget.denyUser(comment.author, accountName)) { - await addComment(`User ${accountName} is no longer allowed to use GitGitGadget.`); + await addComment(`User ${accountName} is no longer allowed to use ${this.config.app.displayName}.`); } else { - await addComment(`User ${accountName} already not allowed to use GitGitGadget.`); + await addComment(`User ${accountName} already not allowed to use ${this.config.app.displayName}.`); } } else if (command === "/cc") { await this.handleCC(argument, prKey); @@ -628,8 +640,9 @@ export class CIHelper { public async checkCommits(pr: IPullRequestInfo, addComment: CommentFunction, userInfo?: IGitHubUser): Promise { let result = true; - const maxCommits = 30; - if (!this.maxCommitsExceptions.has(pr.pullRequestURL) && + const maxCommits = this.config.lint.maxCommits; + + if (!this.maxCommitsExceptions.includes(pr.pullRequestURL) && pr.commits && pr.commits > maxCommits) { await addComment(`The pull request has ${pr.commits} commits. The max allowed is ${maxCommits }. Please split the patch series into multiple pull requests. Also consider ${ @@ -748,8 +761,8 @@ export class CIHelper { private async getPRInfo(prKey: pullRequestKey): Promise { const pr = await this.github.getPRInfo(prKey); - if (!new Set(["gitgitgadget", "dscho", "git"]).has(pr.baseOwner) || - pr.baseRepo !== "git") { + if (!this.config.repo.owners.includes(pr.baseOwner) || + pr.baseRepo !== this.config.repo.name) { throw new Error(`Unsupported repository: ${pr.pullRequestURL}`); } @@ -771,6 +784,11 @@ export class CIHelper { private async getUserInfo(author: string): Promise { const userInfo = await this.github.getGitHubUserInfo(author); if (!userInfo.name) { + if (this.config.user.allowUserAsLogin) { + userInfo.name = userInfo.login; + } else { + throw new Error(`Could not determine full name of ${author}`); + } throw new Error(`Could not determine full name of ${author}`); } diff --git a/lib/gitgitgadget-config.ts b/lib/gitgitgadget-config.ts new file mode 100644 index 0000000000..b26c822333 --- /dev/null +++ b/lib/gitgitgadget-config.ts @@ -0,0 +1,48 @@ +import { IConfig, setConfig } from "./project-config"; + +const defaultConfig: IConfig = { + repo: { + name: "git", + owner: "gitgitgadget", + baseOwner: "git", + owners: ["gitgitgadget", "git", "dscho"], + branches: ["maint", "seen"], + closingBranches: ["maint", "master"], + trackingBranches: ["maint", "seen", "master", "next"], + maintainerBranch: "gitster", + host: "github.com", + }, + mailrepo: { + name: "git", + owner: "gitgitgadget", + host: "lore.kernel.org", + }, + mail: { + author: "GitGitGadget", + sender: "GitGitGadget" + }, + app: { + appID: 12836, + installationID: 195971, + name: "gitgitgadget", + displayName: "GitGitGadget", + altname: "gitgitgadget-git" + }, + lint: { + maxCommitsIgnore: [ + "https://github.com/gitgitgadget/git/pull/923" + ], + maxCommits: 30, + }, + user: { + allowUserAsLogin: false, + } +}; + +export default defaultConfig; + +setConfig(defaultConfig); + +export function getConfig(): IConfig { + return setConfig(defaultConfig); +} \ No newline at end of file diff --git a/lib/gitgitgadget.ts b/lib/gitgitgadget.ts index 8e1f27feed..c2a46b968d 100644 --- a/lib/gitgitgadget.ts +++ b/lib/gitgitgadget.ts @@ -5,9 +5,8 @@ import { IGitHubUser, IPullRequestInfo } from "./github-glue"; import { PatchSeries, SendFunction } from "./patch-series"; import { IPatchSeriesMetadata } from "./patch-series-metadata"; import { PatchSeriesOptions } from "./patch-series-options"; -import { - ISMTPOptions, parseHeadersAndSendMail, parseMBox, - sendMail } from "./send-mail"; +import { IConfig, getConfig } from "./project-config"; +import { ISMTPOptions, parseHeadersAndSendMail, parseMBox, sendMail } from "./send-mail"; export interface IGitGitGadgetOptions { allowedUsers: string[]; @@ -88,6 +87,7 @@ export class GitGitGadget { return [options, allowedUsers]; } + public readonly config: IConfig = getConfig(); public readonly workDir: string; public readonly notes: GitNotes; protected options: IGitGitGadgetOptions; @@ -199,12 +199,11 @@ export class GitGitGadget { "fetch", this.publishTagsAndNotesToRemote, "--", - `+${this.notes.notesRef}:${this.notes.notesRef}`, - "+refs/heads/maint:refs/remotes/upstream/maint", - "+refs/heads/master:refs/remotes/upstream/master", - "+refs/heads/next:refs/remotes/upstream/next", - "+refs/heads/seen:refs/remotes/upstream/seen", + `+${this.notes.notesRef}:${this.notes.notesRef}` ]; + for (const branch of this.config.repo.trackingBranches) { + args.push(`refs/heads/${branch}:refs/remotes/upstream/${branch}`); + } const prArgs = [ `+${pullRequestRef}:${pullRequestRef}`, `+${pullRequestMerge}:${pullRequestMerge}`, @@ -212,18 +211,16 @@ export class GitGitGadget { if (additionalRef) { args.push(`+${additionalRef}:${additionalRef}`); } - if (repositoryOwner === "gitgitgadget") { + if (repositoryOwner === this.config.repo.owner) { args.push(...prArgs); } else { - prArgs.unshift("fetch", `https://github.com/${repositoryOwner}/git`, - "--"); - await git(prArgs, { workDir: this.workDir }); + await git(["fetch", `https://github.com/${repositoryOwner}/${this.config.repo.name}`, ...prArgs], + { workDir: this.workDir }); } await git(args, { workDir: this.workDir }); // re-read options - [this.options, this.allowedUsers] = - await GitGitGadget.readOptions(this.notes); + [this.options, this.allowedUsers] = await GitGitGadget.readOptions(this.notes); return pullRequestRef; } diff --git a/lib/mail-commit-mapping.ts b/lib/mail-commit-mapping.ts index 9417c8d71a..9b85d9a16f 100644 --- a/lib/mail-commit-mapping.ts +++ b/lib/mail-commit-mapping.ts @@ -1,7 +1,9 @@ import { git } from "./git"; import { GitNotes } from "./git-notes"; +import { IConfig, getConfig } from "./project-config"; export class MailCommitMapping { + public readonly config: IConfig = getConfig(); public readonly workDir?: string; public readonly mail2CommitNotes: GitNotes; @@ -32,16 +34,16 @@ export class MailCommitMapping { refs.push("refs/notes/mail-to-commit:refs/notes/mail-to-commit"); } if (includeUpstreamBranches) { - for (const ref of ["seen", "next", "master", "maint"]) { + for (const ref of this.config.repo.trackingBranches) { refs.push(`+refs/heads/${ref}:refs/remotes/upstream/${ref}`); } } - if (includeGitsterBranches) { - refs.push("+refs/heads/*:refs/remotes/gitster/*"); + if (includeGitsterBranches && this.config.repo.maintainerBranch) { + refs.push(`+refs/heads/*:refs/remotes/${this.config.repo.maintainerBranch}/*`); } if (refs.length) { - await git(["fetch", "https://github.com/gitgitgadget/git", ...refs], - { workDir: this.workDir }); + await git(["fetch", `https://github.com/${this.config.repo.owner}/${this.config.repo.name}`, ...refs], + { workDir: this.workDir }); } } } diff --git a/lib/patch-series.ts b/lib/patch-series.ts index 041e27d91f..1ba274f2d0 100644 --- a/lib/patch-series.ts +++ b/lib/patch-series.ts @@ -1,15 +1,14 @@ import addressparser = require("nodemailer/lib/addressparser"); import mimeFuncs = require("nodemailer/lib/mime-funcs"); // import { encodeWords } from "nodemailer/lib/mime-funcs"; -import { - commitExists, git, gitCommandExists, gitConfig, revListCount, revParse, -} from "./git"; +import { commitExists, git, gitCommandExists, gitConfig, revListCount, revParse, } from "./git"; import { GitNotes } from "./git-notes"; import { IGitGitGadgetOptions } from "./gitgitgadget"; import { IMailMetadata } from "./mail-metadata"; import { md2text } from "./markdown-renderer"; import { IPatchSeriesMetadata } from "./patch-series-metadata"; import { PatchSeriesOptions } from "./patch-series-options"; +import { IConfig, getConfig } from "./project-config"; import { ProjectOptions } from "./project-options"; import { getPullRequestKeyFromURL } from "./pullRequestKey"; @@ -438,10 +437,8 @@ export class PatchSeries { .replace(/["\\\\]/g, "\\$&")}"${match[2]}${match[3]}`; } - protected static insertCcAndFromLines(mails: string[], thisAuthor: string, - senderName?: string): - void { - const isGitGitGadget = thisAuthor.match(/^GitGitGadget { const match = mail.match(/^([^]*?)(\n\n[^]*)$/); @@ -450,8 +447,7 @@ export class PatchSeries { } let header = match[1]; - const authorMatch = - header.match(/^([^]*\nFrom: )([^]*?)(\n(?![ \t])[^]*)$/); + const authorMatch = header.match(/^([^]*\nFrom: )([^]*?)(\n(?![ \t])[^]*)$/); if (!authorMatch) { throw new Error("No From: line found in header:\n\n" + header); } @@ -461,13 +457,9 @@ export class PatchSeries { const onBehalfOf = i === 0 && senderName ? PatchSeries.encodeSender(senderName) : authorMatch[2].replace(/ <.*>$/, ""); - // Special-case GitGitGadget to send from - // " via GitGitGadget" - replaceSender = "\"" - + onBehalfOf.replace(/^"(.*)"$/, "$1") - .replace(/"/g, "\\\"") - + " via GitGitGadget\" " - + thisAuthor.replace(/^GitGitGadget /, ""); + // Special-case GitGitGadget to send from " via GitGitGadget" + replaceSender = `\"${onBehalfOf.replace(/^"(.*)"$/, "$1").replace(/"/g, "\\\"") + } via ${this.config.mail.sender}" ${isGitGitGadget[1]}`; } else if (authorMatch[2] === thisAuthor) { return; } @@ -479,8 +471,7 @@ export class PatchSeries { return; } - const ccMatch = - header.match(/^([^]*\nCc: [^]*?)(|\n(?![ \t])[^]*)$/); + const ccMatch = header.match(/^([^]*\nCc: [^]*?)(|\n(?![ \t])[^]*)$/); if (ccMatch) { header = ccMatch[1] + ",\n " + authorMatch[2] + ccMatch[2]; } else { @@ -629,6 +620,7 @@ export class PatchSeries { return results; } + public readonly config: IConfig = getConfig(); public readonly notes: GitNotes; public readonly options: PatchSeriesOptions; public readonly project: ProjectOptions; @@ -690,9 +682,8 @@ export class PatchSeries { throw new Error("Could not determine author ident from " + ident); } - logger.log("Adding Cc: and explicit From: lines for other authors, " - + "if needed"); - PatchSeries.insertCcAndFromLines(mails, thisAuthor, this.senderName); + logger.log("Adding Cc: and explicit From: lines for other authors, if needed"); + this.insertCcAndFromLines(mails, thisAuthor, this.senderName); if (mails.length > 1) { if (this.coverLetter) { const match2 = mails[0].match( @@ -724,16 +715,12 @@ export class PatchSeries { } const email = emailMatch[1]; - const prMatch = this.metadata.pullRequestURL - .match(/\/([^/]+)\/([^/]+)\/pull\/(\d+)$/); + const prMatch = this.metadata.pullRequestURL.match(/\/([^/]+)\/([^/]+)\/pull\/(\d+)$/); if (prMatch) { - const infix = this.metadata.iteration > 1 ? - `.v${this.metadata.iteration}` : ""; - const repoInfix = prMatch[1] === "gitgitgadget" ? + const infix = this.metadata.iteration > 1 ? `.v${this.metadata.iteration}` : ""; + const repoInfix = prMatch[1] === this.config.repo.owner ? prMatch[2] : `${prMatch[1]}.${prMatch[2]}`; - const newCoverMid = - `pull.${prMatch[3]}${infix}.${repoInfix}.${ - timeStamp}.${email}`; + const newCoverMid = `pull.${prMatch[3]}${infix}.${repoInfix}.${timeStamp}.${email}`; mails.map((value: string, index: number): void => { // cheap replace-all mails[index] = value.split(mid).join(newCoverMid); @@ -754,9 +741,8 @@ export class PatchSeries { } else { const prKey = getPullRequestKeyFromURL(this.metadata.pullRequestURL); const branch = this.metadata.headLabel.replace(/:/g, "/"); - const tagPrefix = prKey.owner === "gitgitgadget" ? "pr-" : `pr-${prKey.owner}-`; - tagName = `${tagPrefix}${prKey.pull_number}/${branch}-v${ - this.metadata.iteration}`; + const tagPrefix = prKey.owner === this.config.repo.owner ? "pr-" : `pr-${prKey.owner}-`; + tagName = `${tagPrefix}${prKey.pull_number}/${branch}-v${this.metadata.iteration}`; } this.metadata.latestTag = tagName; @@ -788,7 +774,7 @@ export class PatchSeries { const footers: string[] = []; if (pullRequestURL) { - const prefix = "https://github.com/gitgitgadget/git"; + const prefix = `https://github.com/${this.config.repo.owner}/${this.config.repo.name}`; const tagName2 = encodeURIComponent(tagName); footers.push(`Published-As: ${prefix}/releases/tag/${tagName2}`); footers.push(`Fetch-It-Via: git fetch ${prefix} ${tagName}`); @@ -920,7 +906,7 @@ export class PatchSeries { this.project.branchName], { workDir: this.project.workDir }); const args = [ - "format-patch", "--thread", "--stdout", "--signature=gitgitgadget", + "format-patch", "--thread", "--stdout", `--signature=${this.config.repo.owner}`, "--add-header=Fcc: Sent", "--base", mergeBase, this.project.to, ].concat(PatchSeries.generateSingletonHeaders()); diff --git a/lib/project-config.ts b/lib/project-config.ts new file mode 100644 index 0000000000..c2b1527c7a --- /dev/null +++ b/lib/project-config.ts @@ -0,0 +1,100 @@ +import * as fs from "fs"; +import path from "path"; + +export type projectInfo = { + to: string; // email to send patches to + branch: string; // upstream branch a PR must be based on + cc: string[]; // emails to always be copied on patches + urlPrefix: string; // url to 'listserv' of mail (should it be in mailrepo?) +}; + +export interface IConfig { + repo: { + name: string; // name of the repo + owner: string; // owner of repo holding the notes (tracking data) + baseOwner: string; // owner of base repo + 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 + maintainerBranch?: string; // branch/owner manually implementing changes + host: string; + }, + mailrepo: { + name: string; + owner: string; + host: string; + }, + mail: { + author: string; + sender: string; + }, + project?: projectInfo | undefined, // project-options values + app: { + appID: number; + installationID: number; + name: string; + displayName: string; // name to use in comments to identify app + 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 + } +}; + +let config: IConfig; // singleton + +/** + * Query to get the current configuration. + * + * @returns IConfig interface + */ +export function getConfig(): IConfig { + if (config === undefined) { + throw new Error("project-config not set"); + } + + return config; +} + +type importedConfig = { default: IConfig; } + +/** + * Load a config. The config may be a javascript file (plain or generated + * from typescript) or a json file (with a .json extension). + * + * @param file fully qualified filename and path + * @returns IConfig interface + */ +export async function loadConfig(file: string): Promise { + let loadedConfig: IConfig; + + if (path.extname(file) === ".js") { + const { default: newConfig } = (await import(file)) as importedConfig; + loadedConfig = newConfig; + } else { + const fileText = fs.readFileSync(file, {encoding: "utf-8"}); + loadedConfig = JSON.parse(fileText) as IConfig; + } + + if (loadedConfig === undefined) { + throw new Error("project-config not set"); + } + + return loadedConfig; +} + +/** + * Set/update the configuration. + * + * @param newConfig configuration to be set + * @returns current IConfig interface + */ +export function setConfig(newConfig: IConfig): IConfig { + config = newConfig; + return config; +} \ No newline at end of file diff --git a/lib/project-options.ts b/lib/project-options.ts index dc7cfb7d12..ba0bea8cae 100644 --- a/lib/project-options.ts +++ b/lib/project-options.ts @@ -1,4 +1,5 @@ import { commitExists, git, gitConfig, gitConfigForEach, revParse } from "./git"; +import { IConfig, getConfig, projectInfo } from "./project-config"; // For now, only the Git, Cygwin and BusyBox projects are supported export class ProjectOptions { @@ -23,11 +24,20 @@ export class ProjectOptions { public static async get(workDir: string, branchName: string, cc: string[], basedOn?: string, publishToRemote?: string, baseCommit?: string): Promise { + const config: IConfig = getConfig(); let upstreamBranch: string; let to: string; let midUrlPrefix = " Message-ID: "; - if (await commitExists("cb07fc2a29c86d1bc11", workDir) && + if (config.hasOwnProperty("project")) { + const project = config.project as projectInfo; + to = `--to=${project.to}`; + upstreamBranch = project.branch; + midUrlPrefix = project.urlPrefix; + for (const user of project.cc) { + cc.push(user); + } + } else if (await commitExists("cb07fc2a29c86d1bc11", workDir) && await revParse(`${baseCommit}:git-gui.sh`, workDir) !== undefined) { // Git GUI to = "--to=git@vger.kernel.org"; diff --git a/package.json b/package.json index cd0312b8ff..34110b472e 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "scripts": { "build": "tsc", "cleanbranch": "node ./build/script/delete-test-branches.js", - "lint": "eslint -c .eslintrc.js --ext .ts,.js \"{lib,script,tests}/**/*.{ts,tsx,js}\"", + "lint": "eslint -c .eslintrc.js --ext .ts,.js \"{lib,script,tests,tests-config}/**/*.{ts,tsx,js}\"", "start": "node server.js", "test": "jest --env=node", + "test:config": "jest --env=node --testRegex=/tests-config/.*\\.test\\.ts", "test:watch": "jest --watch --notify --notifyMode=change --coverage", "ci": "npm run lint && jest --env=node --ci --reporters=default --reporters=jest-junit" }, diff --git a/script/misc-helper.ts b/script/misc-helper.ts index 7e2e208732..72fb85c6b1 100644 --- a/script/misc-helper.ts +++ b/script/misc-helper.ts @@ -4,9 +4,12 @@ import { Command } from "commander"; import { CIHelper } from "../lib/ci-helper"; import { isDirectory } from "../lib/fs-util"; import { git, gitConfig } from "../lib/git"; +import { getConfig } from "../lib/gitgitgadget-config"; import { GitHubGlue } from "../lib/github-glue"; import { toPrettyJSON } from "../lib/json-util"; import { IPatchSeriesMetadata } from "../lib/patch-series-metadata"; +import { IConfig, loadConfig, setConfig } from "../lib/project-config"; +import path from "path"; const commander = new Command(); @@ -23,11 +26,14 @@ commander.version("1.0.0") + "current working directory to access the Git config e.g. for " + "`gitgitgadget.workDir`", ".") + .option("-c, --config ", + "Use this configuration when using gitgitgadget with a project other than git", "") .option("-s, --skip-update", "Do not update the local refs (useful for debugging)") .parse(process.argv); interface ICommanderOptions { + config: string | undefined; gitgitgadgetWorkDir: string | undefined; gitWorkDir: string | undefined; skipUpdate: boolean | undefined; @@ -38,6 +44,7 @@ if (commander.args.length === 0) { } const commandOptions = commander.opts(); +let config: IConfig = getConfig(); async function getGitGitWorkDir(): Promise { if (!commandOptions.gitWorkDir) { @@ -51,7 +58,7 @@ async function getGitGitWorkDir(): Promise { console.log(`Cloning git into ${commandOptions.gitWorkDir}`); await git([ "clone", - "https://github.com/gitgitgadget/git", + `https://github.com/${config.repo.owner}/${config.repo.name}`, commandOptions.gitWorkDir, ]); } @@ -64,6 +71,11 @@ async function getCIHelper(): Promise { } (async (): Promise => { + if (commandOptions.config) { + const newConfig = await loadConfig(path.resolve(commandOptions.config)); + config = setConfig(newConfig); + } + const ci = await getCIHelper(); const command = commander.args[0]; if (command === "update-open-prs") { @@ -87,7 +99,7 @@ async function getCIHelper(): Promise { const handledPRs = new Set(); const handledMessageIDs = new Set(); - for (const repositoryOwner of ["gitgitgadget", "git", "dscho"]) { + for (const repositoryOwner of config.repo.owners) { const pullRequests = await gitHub.getOpenPRs(repositoryOwner); for (const pr of pullRequests) { const meta = await ci.getPRMetadata(pr.pullRequestURL); @@ -178,7 +190,7 @@ async function getCIHelper(): Promise { } else if (command === "set-tip-commit-in-git.git") { if (commander.args.length !== 3) { process.stderr.write(`${command}: needs 2 parameters:${ - "\n"}PR URL and tip commit in git.git`); + "\n"}PR URL and tip commit in ${config.repo.baseOwner}.${config.repo.name}`); process.exit(1); } const pullRequestURL = commander.args[1]; @@ -236,8 +248,8 @@ async function getCIHelper(): Promise { console.log(`Result: ${result}`); } else if (command === "annotate-commit") { if (commander.args.length !== 3) { - process.stderr.write(`${command}: needs 2 parameters: ${ - ""}original and git.git commit\n`); + process.stderr.write(`${command}: needs 2 parameters: original and ${ + config.repo.baseOwner}.${config.repo.name} commit\n`); process.exit(1); } @@ -245,7 +257,7 @@ async function getCIHelper(): Promise { const gitGitCommit = commander.args[2]; const glue = new GitHubGlue(ci.workDir, "git"); - const id = await glue.annotateCommit(originalCommit, gitGitCommit, "gitgitgadget", "git"); + const id = await glue.annotateCommit(originalCommit, gitGitCommit, config.repo.owner, config.repo.baseOwner); console.log(`Created check with id ${id}`); } else if (command === "identify-merge-commit") { if (commander.args.length !== 3) { @@ -275,29 +287,24 @@ async function getCIHelper(): Promise { console.log(toPrettyJSON(await ci.getMailMetadata(messageID))); } else if (command === "get-pr-meta") { if (commander.args.length !== 2 && commander.args.length !== 3) { - process.stderr.write(`${command}: need a repository owner and ${ - ""}a Pull Request number\n`); + process.stderr.write(`${command}: need a repository owner and a Pull Request number\n`); process.exit(1); } - const repositoryOwner = commander.args.length === 3 ? - commander.args[1] : "gitgitgadget"; + const repositoryOwner = commander.args.length === 3 ? commander.args[1] : config.repo.owner; const prNumber = commander.args[commander.args.length === 3 ? 2 : 1]; const pullRequestURL = prNumber.match(/^http/) ? prNumber : - `https://github.com/${repositoryOwner}/git/pull/${prNumber}`; + `https://github.com/${repositoryOwner}/${config.repo.name}/pull/${prNumber}`; console.log(toPrettyJSON(await ci.getPRMetadata(pullRequestURL))); } else if (command === "get-pr-commits") { if (commander.args.length !== 2 && commander.args.length !== 3) { - process.stderr.write(`${command}: need a repository owner and ${ - ""}a Pull Request number\n`); + process.stderr.write(`${command}: need a repository owner and a Pull Request number\n`); process.exit(1); } - const repositoryOwner = commander.args.length === 3 ? - commander.args[1] : "gitgitgadget"; + const repositoryOwner = commander.args.length === 3 ? commander.args[1] : config.repo.owner; const prNumber = commander.args[commander.args.length === 3 ? 2 : 1]; - const pullRequestURL = - `https://github.com/${repositoryOwner}/git/pull/${prNumber}`; + const pullRequestURL = `https://github.com/${repositoryOwner}/${config.repo.name}/pull/${prNumber}`; const prMeta = await ci.getPRMetadata(pullRequestURL); if (!prMeta) { throw new Error(`No metadata found for ${pullRequestURL}`); @@ -305,16 +312,13 @@ async function getCIHelper(): Promise { console.log(toPrettyJSON(await ci.getOriginalCommitsForPR(prMeta))); } else if (command === "handle-pr") { if (commander.args.length !== 2 && commander.args.length !== 3) { - process.stderr.write(`${command}: need a repository owner and ${ - ""}a Pull Request number\n`); + process.stderr.write(`${command}: need a repository owner and a Pull Request number\n`); process.exit(1); } - const repositoryOwner = commander.args.length === 3 ? - commander.args[1] : "gitgitgadget"; + const repositoryOwner = commander.args.length === 3 ? commander.args[1] : config.repo.owner; const prNumber = commander.args[commander.args.length === 3 ? 2 : 1]; - const pullRequestURL = - `https://github.com/${repositoryOwner}/git/pull/${prNumber}`; + const pullRequestURL = `https://github.com/${repositoryOwner}/${config.repo.name}/pull/${prNumber}`; const meta = await ci.getPRMetadata(pullRequestURL); if (!meta) { @@ -370,7 +374,7 @@ async function getCIHelper(): Promise { process.exit(1); } const pullRequestURL = commander.args[1].match(/^[0-9]+$/) ? - `https://github.com/gitgitgadget/git/pull/${commander.args[1]}` : + `https://github.com/gitgitgadget/${config.repo.name}/pull/${commander.args[1]}` : commander.args[1]; const comment = commander.args[2]; @@ -382,8 +386,7 @@ async function getCIHelper(): Promise { installationID?: number; name: string; }): Promise => { - const appName = options.name === "gitgitgadget" ? - "gitgitgadget" : "gitgitgadget-git"; + const appName = options.name === config.app.name ? config.app.name : config.app.altname; const key = await gitConfig(`${appName}.privateKey`); if (!key) { throw new Error(`Need the ${appName} App's private key`); @@ -401,50 +404,42 @@ async function getCIHelper(): Promise { options.installationID = (await client.rest.apps.getRepoInstallation({ owner: options.name, - repo: "git", + repo: config.repo.name, })).data.id; } const result = await client.rest.apps.createInstallationAccessToken( { installation_id: options.installationID, }); - const configKey = options.name === "gitgitgadget" ? - "gitgitgadget.githubToken" : - `gitgitgadget.${options.name}.githubToken`; + const configKey = options.name === config.app.name ? + `${config.app.name}.githubToken` : `gitgitgadget.${options.name}.githubToken`; await git(["config", configKey, result.data.token]); }; - await set({appID: 12836, installationID: 195971, name: "gitgitgadget"}); + await set(config.app); for (const org of commander.args.slice(1)) { await set({ appID: 46807, name: org}); } } else if (command === "handle-pr-comment") { if (commander.args.length !== 2 && commander.args.length !== 3) { - process.stderr.write(`${command}: optionally takes a ${ - ""}repository owner and one comment ID\n`); + process.stderr.write(`${command}: optionally takes a repository owner and one comment ID\n`); process.exit(1); } - const repositoryOwner = commander.args.length === 3 ? - commander.args[1] : "gitgitgadget"; - const commentID = - parseInt(commander.args[commander.args.length === 3 ? 2 : 1], 10); + const repositoryOwner = commander.args.length === 3 ? commander.args[1] : config.repo.owner; + const commentID = parseInt(commander.args[commander.args.length === 3 ? 2 : 1], 10); await ci.handleComment(repositoryOwner, commentID); } else if (command === "handle-pr-push") { if (commander.args.length !== 2 && commander.args.length !== 3) { - process.stderr.write(`${command}: optionally takes a repository ${ - ""}owner and a Pull Request number\n`); + process.stderr.write(`${command}: optionally takes a repository owner and a Pull Request number\n`); process.exit(1); } - const repositoryOwner = commander.args.length === 3 ? - commander.args[1] : "gitgitgadget"; - const prNumber = - parseInt(commander.args[commander.args.length === 3 ? 2 : 1], 10); + const repositoryOwner = commander.args.length === 3 ? commander.args[1] : config.repo.owner; + const prNumber = parseInt(commander.args[commander.args.length === 3 ? 2 : 1], 10); await ci.handlePush(repositoryOwner, prNumber); } else if (command === "handle-new-mails") { - const mailArchiveGitDir = - await gitConfig("gitgitgadget.loreGitDir"); + const mailArchiveGitDir = await gitConfig("gitgitgadget.loreGitDir"); if (!mailArchiveGitDir) { process.stderr.write("Need a lore.kernel/git worktree"); process.exit(1); @@ -453,8 +448,7 @@ async function getCIHelper(): Promise { for (const arg of commander.args.slice(1)) { onlyPRs.add(parseInt(arg, 10)); } - await ci.handleNewMails(mailArchiveGitDir, - onlyPRs.size ? onlyPRs : undefined); + await ci.handleNewMails(mailArchiveGitDir, onlyPRs.size ? onlyPRs : undefined); } else { process.stderr.write(`${command}: unhandled sub-command\n`); process.exit(1); diff --git a/tests-config/ci-helper.test.ts b/tests-config/ci-helper.test.ts new file mode 100644 index 0000000000..95102eacde --- /dev/null +++ b/tests-config/ci-helper.test.ts @@ -0,0 +1,1290 @@ +import { afterAll, beforeAll, expect, jest, test } from "@jest/globals"; +import { CIHelper } from "../lib/ci-helper"; +import { GitNotes } from "../lib/git-notes"; +// import { getConfig } from "../lib/gitgitgadget-config"; +import { GitHubGlue, IGitHubUser, IPRComment, IPRCommit, IPullRequestInfo, } from "../lib/github-glue"; +import { IMailMetadata } from "../lib/mail-metadata"; +import { IConfig, loadConfig, setConfig } from "../lib/project-config"; +import { testSmtpServer } from "test-smtp-server"; +import { testCreateRepo, TestRepo } from "../tests/test-lib"; +import path from "path"; + +jest.setTimeout(180000); + +const testConfig: IConfig = { + repo: { + name: "telescope", + owner: "webb", + baseOwner: "galileo", + owners: ["webb", "galileo"], + branches: ["maint"], + closingBranches: ["maint", "main"], + trackingBranches: ["maint", "main", "hubble"], + maintainerBranch: "lippershey", + host: "github.com", + }, + mailrepo: { + name: "git", + owner: "gitgitgadget", + host: "lore.kernel.org", + }, + mail: { + author: "GitGadget", + sender: "GitGadget" + }, + app: { + appID: 12836, + installationID: 195971, + name: "gitgitgadget", + displayName: "BigScopes", + altname: "gitgitgadget-git" + }, + lint: { + maxCommitsIgnore: [], + maxCommits: 30, + }, + user: { + allowUserAsLogin: false, + }, + project: { + to: "david@groundcontrol.com", + branch: "upstream/master", + cc: [], + urlPrefix: "https://mailarchive.com/egit/" + } +}; + +let config = setConfig(testConfig); + +const eMailOptions = { + smtpserver: new testSmtpServer(), + smtpOpts: "" +}; + +// async in case new config is loaded +beforeAll(async (): Promise => { + eMailOptions.smtpserver.startServer(); // start listening + eMailOptions.smtpOpts = + `{port: ${eMailOptions.smtpserver.getPort() + }, secure: true, tls: {rejectUnauthorized: false}}`; + + if (process.env.GITGITGADGET_CONFIG) { + const configSource = await loadConfig(path.resolve(process.env.GITGITGADGET_CONFIG)); + config = setConfig(configSource); + } + + process.env.GIT_AUTHOR_NAME = config.mail.author; + process.env.GIT_AUTHOR_EMAIL = `${config.mail.author}@fakehost.com`; +}); + +afterAll((): void => { + eMailOptions.smtpserver.stopServer(); // terminate server +}); + +// Mocking class to replace GithubGlue with mock of GitHubGlue + +class TestCIHelper extends CIHelper { + public ghGlue: GitHubGlue; // not readonly reference + public addPRCommentCalls: string[][]; // reference mock.calls + public updatePRCalls: string[][]; // reference mock.calls + public addPRLabelsCalls: Array<[_: string, labels: string[]]>; + + public constructor(workDir?: string, debug = false, gggDir = ".") { + super(workDir, debug, gggDir); + this.testing = true; + this.ghGlue = this.github; + + const commentInfo = { id: 1, url: "ok" }; + // eslint-disable-next-line @typescript-eslint/require-await + const addPRComment = jest.fn( async (): Promise<{id: number; url: string}> => commentInfo ); + this.ghGlue.addPRComment = addPRComment; + this.addPRCommentCalls = addPRComment.mock.calls; + + // eslint-disable-next-line @typescript-eslint/require-await + const updatePR = jest.fn( async (): Promise => 1 ); + this.ghGlue.updatePR = updatePR; + this.updatePRCalls = updatePR.mock.calls; + + // eslint-disable-next-line @typescript-eslint/require-await + const addPRLabels = jest.fn( async (_: string, labels: string[]): Promise => labels ); + this.ghGlue.addPRLabels = addPRLabels; + this.addPRLabelsCalls = addPRLabels.mock.calls; + + // need keys to authenticate + // this.ghGlue.ensureAuthenticated = async (): Promise => {}; + } + + public setGHGetPRInfo(o: IPullRequestInfo): void { + // eslint-disable-next-line @typescript-eslint/require-await + this.ghGlue.getPRInfo = jest.fn( async (): Promise => o ); + } + + public setGHGetPRComment(o: IPRComment): void { + // eslint-disable-next-line @typescript-eslint/require-await + this.ghGlue.getPRComment = jest.fn( async (): Promise => o ); + } + + public setGHGetPRCommits(o: IPRCommit[]): void { + // eslint-disable-next-line @typescript-eslint/require-await + this.ghGlue.getPRCommits = jest.fn( async (): Promise => o ); + } + + public setGHGetGitHubUserInfo(o: IGitHubUser): void { + // eslint-disable-next-line @typescript-eslint/require-await + this.ghGlue.getGitHubUserInfo = jest.fn( async (): Promise => o ); + } + + public addMaxCommitsException(pullRequestURL: string): void { + this.maxCommitsExceptions = [pullRequestURL]; + } + + public removeMaxCommitsException(): void { + this.maxCommitsExceptions = []; + } +} + +// Create three repos. +// worktree is a local copy for doing updates and has the config +// info that would normally be in the gitgitgadget repo. To ensure +// testing isolation, worktree is NOT the repo used for git clone +// tests. That work is done in gggLocal. + +// gggRemote represents the master on github. + +// gggLocal represents the empty repo to be used by gitgitgadget. It +// is empty to ensure nothing needs to be present (worktree would +// have objects present). + +async function setupRepos(instance: string): + Promise <{ worktree: TestRepo; gggLocal: TestRepo; gggRemote: TestRepo }> { + const worktree = await testCreateRepo(__filename, `-work-cmt${instance}`); + const gggLocal = await testCreateRepo(__filename, `-git-lcl${instance}`); + const gggRemote = await testCreateRepo(__filename, `-git-rmt${instance}`); + + // re-route the URLs + await worktree.git(["config", `url.${gggRemote.workDir}.insteadOf`, + `https://github.com/${config.repo.baseOwner}/${config.repo.name}`]); + + await gggLocal.git(["config", `url.${gggRemote.workDir}.insteadOf`, + `https://github.com/${config.repo.baseOwner}/${config.repo.name}`]); + + // set needed config + await worktree.git([ "config", "--add", "gitgitgadget.workDir", gggLocal.workDir, ]); + await worktree.git([ "config", "--add", "gitgitgadget.publishRemote", + `https://github.com/${config.repo.baseOwner}/${config.repo.name}`, ]); + await worktree.git([ "config", "--add", "gitgitgadget.smtpUser", "joe_user@example.com", ]); + await worktree.git([ "config", "--add", "gitgitgadget.smtpHost", "localhost", ]); + await worktree.git([ "config", "--add", "gitgitgadget.smtpPass", "secret", ]); + await worktree.git([ "config", "--add", "gitgitgadget.smtpOpts", eMailOptions.smtpOpts, ]); + + const notes = new GitNotes(gggRemote.workDir); + await notes.set("", {allowedUsers: ["ggg", "user1"]}, true); + + // Initial empty commit + const commitA = await gggRemote.commit("A"); + expect(commitA).not.toBeUndefined(); + + // Set up fake upstream branches + for (const branch of config.repo.trackingBranches) { + await gggRemote.git(["branch", branch]); + } + + return { worktree, gggLocal, gggRemote }; +} + +/** + * Check the mail server for an email. + * + * @param messageId string to search for + */ +async function checkMsgId(messageId: string): Promise { + const mails = eMailOptions.smtpserver.getEmails(); + + for (const mail of mails) { + const parsed = await mail.getParsed(); + if (parsed.messageId?.match(messageId)) { + return true; + } + } + + return false; +} + +test("identify merge that integrated some commit", async () => { + const repo = await testCreateRepo(__filename); + + /* + * Create a branch structure like this: + * + * a - b ----- c - d + * \ / / + * | e ----- f + * \ / + * g - h + */ + const commitA = await repo.commit("a"); + const commitG = await repo.commit("g"); + const commitH = await repo.commit("h"); + await repo.git(["reset", "--hard", commitA]); + const commitE = await repo.commit("e"); + const commitF = await repo.merge("f", commitH); + await repo.git(["reset", "--hard", commitA]); + const commitB = await repo.commit("b"); + const commitC = await repo.merge("c", commitE); + const commitD = await repo.merge("d", commitF); + await repo.git(["update-ref", `refs/remotes/upstream/${config.repo.trackingBranches[2]}`, commitD]); + + const ci = new CIHelper(repo.workDir, true); + expect(commitB).not.toBeUndefined(); + expect(await ci.identifyMergeCommit(config.repo.trackingBranches[2], commitG)).toEqual(commitD); + expect(await ci.identifyMergeCommit(config.repo.trackingBranches[2], commitE)).toEqual(commitC); + expect(await ci.identifyMergeCommit(config.repo.trackingBranches[2], commitH)).toEqual(commitD); +}); + +test("identify upstream commit", async () => { + // initialize test worktree and gitgitgadget remote + const worktree = await testCreateRepo(__filename, "-worktree"); + const gggRemote = await testCreateRepo(__filename, "-gitgitgadget"); + + // re-route the URLs + await worktree.git(["config", `url.${gggRemote.workDir}.insteadOf`, + `https://github.com/${config.repo.owner}/${config.repo.name}`]); + + // Set up fake upstream branches + const commitA = await gggRemote.commit("A"); + expect(commitA).not.toBeUndefined(); + for (const branch of config.repo.trackingBranches) { + await gggRemote.git(["branch", branch]); + } + + // Now come up with a local change + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // "Contribute" it via a PullRequest + const pullRequestURL = "https://example.com/pull/123"; + const messageID = "fake-1st-mail@example.com"; + const notes = new GitNotes(worktree.workDir); + await notes.appendCommitNote(commitB, messageID); + const bMeta = { + messageID, + originalCommit: commitB, + pullRequestURL, + } as IMailMetadata; + await notes.set(messageID, bMeta); + + // "Apply" the patch, and merge it + await gggRemote.newBranch("gg/via-pull-request"); + const commitBNew = await gggRemote.commit("B"); + await gggRemote.git(["checkout", config.repo.trackingBranches[2]]); + await gggRemote.git(["merge", "--no-ff", "gg/via-pull-request"]); + + // Update the `mail-to-commit` notes ref, at least the part we care about + const mail2CommitNotes = new GitNotes(gggRemote.workDir, + "refs/notes/mail-to-commit"); + await mail2CommitNotes.setString(messageID, commitBNew); + + // "publish" the gitgitgadget notes + await worktree.git(["push", gggRemote.workDir, notes.notesRef]); + + const ci = new TestCIHelper(worktree.workDir); + expect(await ci.identifyUpstreamCommit(commitB)).toEqual(commitBNew); + + expect(await ci.updateCommitMapping(messageID)).toBeTruthy(); + const bMetaNew = await notes.get(messageID); + expect(bMetaNew).not.toBeUndefined(); + expect(bMetaNew?.originalCommit).toEqual(commitB); + expect(bMetaNew?.commitInGitGit).toEqual(commitBNew); +}); + +test("handle comment allow basic test", async () => { + const { worktree, gggLocal } = await setupRepos("a1"); + + // Ready to start testing + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", // set in setupRepos + body: "/allow user2", + prNumber, + }; + const user = { + email: "user2@example.com", + login: "user2", + name: "User Two", + type: "basic", + }; + + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(`is now allowed to use ${config.app.displayName}`); +}); + +test("handle comment allow fail invalid user", async () => { + const { worktree, gggLocal } = await setupRepos("a2"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const comment = { + author: "ggg", + body: "/allow bad_@@@@", + prNumber, + }; + + ci.setGHGetPRComment(comment); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(/is not a valid GitHub username/); +}); + +test("handle comment allow no public email", async () => { + const { worktree, gggLocal } = await setupRepos("a3"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const comment = { + author: "ggg", + body: "/allow bad", + prNumber, + }; + const user: IGitHubUser = { + email: null, + login: "noEmail", + name: "no email", + type: "basic", + }; + + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(`is now allowed to use ${config.app.displayName}`); + expect(ci.addPRCommentCalls[0][1]).toMatch(/no public email address set/); +}); + +test("handle comment allow already allowed", async () => { + const { worktree, gggLocal } = await setupRepos("a4"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/allow ggg", + prNumber, + }; + const user = { + email: "bad@example.com", + login: "ggg", + name: "not so bad", + type: "basic", + }; + + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(`already allowed to use ${config.app.displayName}`); +}); + +test("handle comment allow no name specified (with trailing white space)", + async () => { + const { worktree, gggLocal } = await setupRepos("a5"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/allow ", + prNumber, + }; + const user = { + email: "bad@example.com", + login: "ggg", + name: "not so bad", + type: "basic", + }; + const prInfo = { + author: "ggg", + baseCommit: "A", + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Super body", + hasComments: true, + headCommit: "B", + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Submit a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(`already allowed to use ${config.app.displayName}`); +}); + +test("handle comment disallow basic test", async () => { + const { worktree, gggLocal } = await setupRepos("d1"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/disallow user1 ", + prNumber, + }; + const user = { + email: "user1@example.com", + login: "user1", + name: "not so bad", + type: "basic", + }; + + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(`is no longer allowed to use ${config.app.displayName}`); +}); + +test("handle comment disallow was not allowed", async () => { + const { worktree, gggLocal } = await setupRepos("d2"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/disallow unknown1 ", + prNumber, + }; + + ci.setGHGetPRComment(comment); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(`already not allowed to use ${config.app.displayName}`); +}); + +test("handle comment submit not author", async () => { + const { worktree, gggLocal } = await setupRepos("s1"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "bad@example.com", + login: "ggg", + name: "ee cummings", + type: "basic", + }; + const prInfo = { + author: "ggNOTg", + baseCommit: "A", + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Super body", + hasComments: true, + headCommit: "B", + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Submit a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(/Only the owner of a PR can submit/); +}); + +test("handle comment submit not mergeable", async () => { + const { worktree, gggLocal } = await setupRepos("s2"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "bad@example.com", + login: "ggg", + name: "ee cummings", + type: "basic", + }; + const prInfo = { + author: "ggg", + baseCommit: "A", + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Super body", + hasComments: true, + headCommit: "B", + headLabel: "somebody:master", + mergeable: false, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Do Not Submit a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(/does not merge cleanly/); +}); + +test("handle comment submit email success", async () => { + const { worktree, gggLocal, gggRemote } = await setupRepos("s3"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const template = "fine template\r\nnew line"; + // add template to master repo + await gggRemote.commit("temple", ".github//PULL_REQUEST_TEMPLATE.md", template); + const commitA = await gggRemote.revParse("HEAD"); + expect(commitA).not.toBeUndefined(); + + // Now come up with a local change + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // get the pr refs in place + const pullRequestRef = `refs/pull/${prNumber}`; + await gggRemote.git([ + "fetch", worktree.workDir, + `refs/heads/master:${pullRequestRef}/head`, + `refs/heads/master:${pullRequestRef}/merge`, + ]); // fake merge + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + const commits = [{ + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BA55FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Submit ok\n\nSuccinct message\n\nSigned-off-by: x", + parentCount: 1, + }]; + const prInfo = { + author: "ggg", + baseCommit: commitA, + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: `Super body\r\n${template}\r\nCc: Copy One \r\n` + + "Cc: Copy Two ", + hasComments: true, + headCommit: commitB, + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Submit a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetPRCommits(commits); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(/Submitted as/); + + const msgId = ci.addPRCommentCalls[0][1].match(/\[(.*)\]/); + expect(msgId).not.toBeUndefined(); + if (msgId && msgId[1]) { + const msgFound = await checkMsgId(msgId[1]); + expect(msgFound).toBeTruthy(); + } +}); + +test("handle comment preview email success", async () => { + const { worktree, gggLocal, gggRemote } = await setupRepos("p1"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const template = "fine template\nnew line"; + await gggRemote.commit("temple", ".github//PULL_REQUEST_TEMPLATE.md", + template); + const commitA = await gggRemote.revParse("HEAD"); + expect(commitA).not.toBeUndefined(); + + // Now come up with a local change + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // get the pr refs in place + const pullRequestRef = `refs/pull/${prNumber}`; + await gggRemote.git([ + "fetch", worktree.workDir, + `refs/heads/master:${pullRequestRef}/head`, + `refs/heads/master:${pullRequestRef}/merge`, + ]); // fake merge + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "preview@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + const commits = [{ + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BA55FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Submit ok\n\nSigned-off-by: x", + parentCount: 1, + }]; + const prInfo = { + author: "ggg", + baseCommit: commitA, + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "There will be a submit email and a preview email.", + hasComments: true, + headCommit: commitB, + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Preview a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetPRCommits(commits); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(/Submitted as/); + + const msgId1 = ci.addPRCommentCalls[0][1].match(/\[(.*)\]/); + expect(msgId1).not.toBeUndefined(); + if (msgId1 && msgId1[1]) { + const msgFound1 = await checkMsgId(msgId1[1]); + expect(msgFound1).toBeTruthy(); + } + + comment.body = " /preview"; + ci.setGHGetPRComment(comment); + await ci.handleComment(config.repo.owner, 433865360); // do it again + expect(ci.addPRCommentCalls[1][1]).toMatch(/Preview email sent as/); + + const msgId2 = ci.addPRCommentCalls[0][1].match(/\[(.*)\]/); + expect(msgId2).not.toBeUndefined(); + if (msgId2 && msgId2[1]) { + const msgFound2 = await checkMsgId(msgId2[1]); + expect(msgFound2).toBeTruthy(); + } + + await ci.handleComment(config.repo.owner, 433865360); // should still be v2 + + const msgId3 = ci.addPRCommentCalls[0][1].match(/\[(.*)\]/); + expect(msgId3).not.toBeUndefined(); + if (msgId3 && msgId3[1]) { + const msgFound3 = await checkMsgId(msgId3[1]); + expect(msgFound3).toBeTruthy(); + } +}); + +test("handle push/comment too many commits fails", async () => { + const { worktree, gggLocal, gggRemote } = await setupRepos("pu1"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const commitA = await gggRemote.revParse("HEAD"); + expect(commitA).not.toBeUndefined(); + + // Now come up with a local change + // this should be in a separate repo from the worktree + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // get the pr refs in place + const pullRequestRef = `refs/pull/${prNumber}`; + await gggRemote.git([ + "fetch", worktree.workDir, + `refs/heads/master:${pullRequestRef}/head`, + `refs/heads/master:${pullRequestRef}/merge`, + ]); // fake merge + + const commits: IPRCommit[] = []; + for (let i = 0; i < 40; i++) { + commits.push({ + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: `${i}abc123`, + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: `commit ${i}\n\nfoo\n\nSigned-off-by: Bob `, + parentCount: 1, + }); + } + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "preview@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + const prInfo = { + author: "ggg", + baseCommit: commitA, + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Never seen - too many commits.", + commits: commits.length, + hasComments: false, + headCommit: commitB, + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Preview a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + ci.setGHGetPRCommits(commits); + + const failMsg = `The pull request has ${commits.length} commits.`; + // fail for too many commits on push + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + + expect(ci.addPRCommentCalls[0][1]).toMatch(failMsg); + ci.addPRCommentCalls.length = 0; + + // fail for too many commits on submit + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(failMsg); + ci.addPRCommentCalls.length = 0; + + ci.addMaxCommitsException(prInfo.pullRequestURL); + const commitsFail = commits; + commitsFail[0].message = `x: A${commitsFail[0].message}`; + ci.setGHGetPRCommits(commitsFail); + await ci.handleComment(config.repo.owner, 433865360); + // There will still be a comment, but about upper-case after prefix + expect(ci.addPRCommentCalls).toHaveLength(1); + expect(ci.addPRCommentCalls[0][1]).not.toMatch(failMsg); + ci.removeMaxCommitsException(); + ci.addPRCommentCalls.length = 0; + + // fail for too many commits on preview + comment.body = " /preview"; + ci.setGHGetPRComment(comment); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(failMsg); + ci.addPRCommentCalls.length = 0; + + // fail for too many commits push new user + prInfo.author = "starfish"; + comment.author = "starfish"; + user.login = "starfish"; + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + ci.setGHGetPRCommits(commits); + + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + + expect(ci.addPRCommentCalls[0][1]).toMatch(/Welcome/); + expect(ci.addPRCommentCalls[1][1]).toMatch(failMsg); + expect(ci.addPRLabelsCalls[0][1]).toEqual(["new user"]); +}); + +test("handle push/comment merge commits fails", async () => { + const { worktree, gggLocal, gggRemote} = await setupRepos("pu2"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const commitA = await gggRemote.revParse("HEAD"); + expect(commitA).not.toBeUndefined(); + + // Now come up with a local change + // this should be in a separate repo from the worktree + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // get the pr refs in place + const pullRequestRef = `refs/pull/${prNumber}`; + await gggRemote.git( + [ "fetch", worktree.workDir, + `refs/heads/master:${pullRequestRef}/head`, + `refs/heads/master:${pullRequestRef}/merge`]); // fake merge + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + const commits = [{ + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BAD1FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Merge a commit", + parentCount: 2, + }]; + + const prInfo = { + author: "ggg", + baseCommit: commitA, + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Never seen - merge commits.", + commits: commits.length, + hasComments: false, + headCommit: commitB, + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Preview a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetPRCommits(commits); + ci.setGHGetGitHubUserInfo(user); + + // fail for merge commits on push + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + + expect(ci.addPRCommentCalls[0][1]).toMatch(commits[0].commit); + ci.addPRCommentCalls.length = 0; + + // fail for merge commits on submit + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(commits[0].commit); + ci.addPRCommentCalls.length = 0; + + // fail for merge commits on preview + comment.body = " /preview"; + ci.setGHGetPRComment(comment); + + await ci.handleComment(config.repo.owner, 433865360); + expect(ci.addPRCommentCalls[0][1]).toMatch(commits[0].commit); + ci.addPRCommentCalls.length = 0; + + // fail for merge commits push new user + prInfo.author = "starfish"; + comment.author = "starfish"; + user.login = "starfish"; + + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + + expect(ci.addPRCommentCalls[0][1]).toMatch(/Welcome/); + expect(ci.addPRCommentCalls[1][1]).toMatch(commits[0].commit); + expect(ci.addPRLabelsCalls[0][1]).toEqual(["new user"]); + ci.addPRCommentCalls.length = 0; + + // Test Multiple merges + commits.push({ + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BAD2FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Merge a commit", + parentCount: 1, + }); + commits.push({ + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BAD3FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Merge a commit", + parentCount: 2, + }); + + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + + expect(ci.addPRCommentCalls[0][1]).toMatch(/Welcome/); + expect(ci.addPRCommentCalls[1][1]).toMatch(commits[0].commit); + expect(ci.addPRCommentCalls[1][1]).not.toMatch(commits[1].commit); + expect(ci.addPRCommentCalls[1][1]).toMatch(commits[2].commit); + ci.addPRCommentCalls.length = 0; + +}); + +test("disallow no-reply emails", async () => { + const { worktree, gggLocal, gggRemote} = await setupRepos("pu2"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const commitA = await gggRemote.revParse("HEAD"); + expect(commitA).not.toBeUndefined(); + + // Now come up with a local change + // this should be in a separate repo from the worktree + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // get the pr refs in place + const pullRequestRef = `refs/pull/${prNumber}`; + await gggRemote.git( + [ "fetch", worktree.workDir, + `refs/heads/master:${pullRequestRef}/head`, + `refs/heads/master:${pullRequestRef}/merge`]); // fake merge + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + const commits = [{ + author: { + email: "random@users.noreply.github.com", + login: "random", + name: "random", + }, + commit: "BAD1FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Using ineligible email address", + parentCount: 1, + }]; + + const prInfo = { + author: "ggg", + baseCommit: commitA, + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Never seen - merge commits.", + commits: commits.length, + hasComments: false, + headCommit: commitB, + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Preview a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetPRCommits(commits); + ci.setGHGetGitHubUserInfo(user); + + // fail for commits with fake email on push + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + +}); + +// Basic tests for ci-helper - lint tests are in commit-lint.tests.ts + +test("basic lint tests", async () => { + const { worktree, gggLocal, gggRemote} = await setupRepos("pu4"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + const commitA = await gggRemote.revParse("HEAD"); + expect(commitA).not.toBeUndefined(); + + // Now come up with a local change + // this should be in a separate repo from the worktree + await worktree.git(["pull", gggRemote.workDir, "master"]); + const commitB = await worktree.commit("b"); + + // get the pr refs in place + const pullRequestRef = `refs/pull/${prNumber}`; + await gggRemote.git( + [ "fetch", worktree.workDir, + `refs/heads/master:${pullRequestRef}/head`, + `refs/heads/master:${pullRequestRef}/merge`]); // fake merge + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/submit ", + prNumber, + }; + const user = { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + const commits = [ + { + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BAD1FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Message has no description", + parentCount: 1, + }, + { + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BAD2FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Missing blank line is bad\nhere\nSigned-off-by: x", + parentCount: 1, + }, + { + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "F00DFEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "Successful test\n\nSigned-off-by: x", + parentCount: 1, + }, + { + author: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + commit: "BAD5FEEDBEEF", + committer: { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + }, + message: "tests: This should be lower case\n\nSigned-off-by: x", + parentCount: 1, + }, + ]; + + const prInfo = { + author: "ggg", + baseCommit: commitA, + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Never seen - merge commits.", + commits: commits.length, + hasComments: false, + headCommit: commitB, + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Preview a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetPRCommits(commits); + ci.setGHGetGitHubUserInfo(user); + + // fail for commits with lint errors + await expect(ci.handlePush(config.repo.owner, 433865360)).rejects.toThrow(/Failing check due/); + expect(ci.addPRCommentCalls[0][1]).toMatch(commits[0].commit); + expect(ci.addPRCommentCalls[0][1]).toMatch(/too short/); + expect(ci.addPRCommentCalls[1][1]).toMatch(commits[1].commit); + expect(ci.addPRCommentCalls[1][1]).toMatch(/empty line/); + expect(ci.addPRCommentCalls[2][1]).toMatch(commits[3].commit); + expect(ci.addPRCommentCalls[2][1]).toMatch(/lower case/); + +}); + +test("Handle comment cc", async () => { + const {worktree, gggLocal} = await setupRepos("cc"); + + const ci = new TestCIHelper(gggLocal.workDir, false, worktree.workDir); + const prNumber = 59; + + // GitHubGlue Responses + const comment = { + author: "ggg", + body: "/cc \"Some Body\" ", + prNumber, + }; + const user = { + email: "ggg@example.com", + login: "ggg", + name: "e. e. cummings", + type: "basic", + }; + + const prInfo = { + author: "ggg", + baseCommit: "foo", + baseLabel: "gitgitgadget:next", + baseOwner: config.repo.baseOwner, + baseRepo: config.repo.name, + body: "Never seen - no cc.", + commits: 1, + hasComments: false, + headCommit: "bar", + headLabel: "somebody:master", + mergeable: true, + number: prNumber, + pullRequestURL: `https://github.com/${config.repo.owner}/${config.repo.name}/pull/59`, + title: "Preview a fun fix", + }; + + ci.setGHGetPRInfo(prInfo); + ci.setGHGetPRComment(comment); + ci.setGHGetGitHubUserInfo(user); + + await ci.handleComment(config.repo.owner, prNumber); + + expect(ci.updatePRCalls[0][ci.updatePRCalls[0].length-1]).toMatch(/Some Body/); + ci.updatePRCalls.length = 0; + + comment.body = "/cc \"A Body\" , " + + "\"S Body\" "; + + await ci.handleComment(config.repo.owner, prNumber); + + expect(ci.updatePRCalls[0][ci.updatePRCalls[0].length-1]).toMatch(/A Body/); + expect(ci.updatePRCalls[1][ci.updatePRCalls[0].length-1]).toMatch(/S Body/); + ci.updatePRCalls.length = 0; + + // email will not be re-added to list + prInfo.body = "changes\r\n\r\ncc: "; + + await ci.handleComment(config.repo.owner, prNumber); + + expect(ci.updatePRCalls[0][ci.updatePRCalls[0].length-1]).toMatch(/S Body/); + expect(ci.updatePRCalls).toHaveLength(1); +}); diff --git a/tests/ci-helper.test.ts b/tests/ci-helper.test.ts index b8d4d82e09..d6ad7bf56a 100644 --- a/tests/ci-helper.test.ts +++ b/tests/ci-helper.test.ts @@ -1,15 +1,16 @@ import { afterAll, beforeAll, expect, jest, test } from "@jest/globals"; import { CIHelper } from "../lib/ci-helper"; import { GitNotes } from "../lib/git-notes"; -import { - GitHubGlue, IGitHubUser, IPRComment, IPRCommit, IPullRequestInfo, -} from "../lib/github-glue"; +import { getConfig } from "../lib/gitgitgadget-config"; +import { GitHubGlue, IGitHubUser, IPRComment, IPRCommit, IPullRequestInfo, } from "../lib/github-glue"; import { IMailMetadata } from "../lib/mail-metadata"; import { testSmtpServer } from "test-smtp-server"; import { testCreateRepo, TestRepo } from "./test-lib"; jest.setTimeout(180000); +getConfig(); + const eMailOptions = { smtpserver: new testSmtpServer(), smtpOpts: "" @@ -87,11 +88,11 @@ class TestCIHelper extends CIHelper { } public addMaxCommitsException(pullRequestURL: string): void { - this.maxCommitsExceptions.add(pullRequestURL); + this.maxCommitsExceptions = [pullRequestURL]; } - public removeMaxCommitsException(pullRequestURL: string): void { - this.maxCommitsExceptions.delete(pullRequestURL); + public removeMaxCommitsException(): void { + this.maxCommitsExceptions = []; } } @@ -825,7 +826,7 @@ test("handle push/comment too many commits fails", async () => { // There will still be a comment, but about upper-case after prefix expect(ci.addPRCommentCalls).toHaveLength(1); expect(ci.addPRCommentCalls[0][1]).not.toMatch(failMsg); - ci.removeMaxCommitsException(prInfo.pullRequestURL); + ci.removeMaxCommitsException(); ci.addPRCommentCalls.length = 0; // fail for too many commits on preview diff --git a/tests/gitgitgadget.test.ts b/tests/gitgitgadget.test.ts index 1eeffd8e87..2293216c8f 100644 --- a/tests/gitgitgadget.test.ts +++ b/tests/gitgitgadget.test.ts @@ -2,6 +2,7 @@ import { expect, jest, test } from "@jest/globals"; import { git, gitCommandExists } from "../lib/git"; import { GitNotes } from "../lib/git-notes"; import { GitGitGadget, IGitGitGadgetOptions } from "../lib/gitgitgadget"; +import { getConfig } from "../lib/gitgitgadget-config"; import { PatchSeries } from "../lib/patch-series"; import { IPatchSeriesMetadata } from "../lib/patch-series-metadata"; import { testCreateRepo } from "./test-lib"; @@ -9,6 +10,8 @@ import { testCreateRepo } from "./test-lib"; // This test script might take quite a while to run jest.setTimeout(60000); +getConfig(); + const expectedMails = [ `From 91fba7811291c1064b2603765a2297c34fc843c0 Mon Sep 17 00:00:00 2001 Message-Id: > diff --git a/tests/patch-series.test.ts b/tests/patch-series.test.ts index f84008a90c..a348737423 100644 --- a/tests/patch-series.test.ts +++ b/tests/patch-series.test.ts @@ -1,10 +1,16 @@ +/* eslint-disable max-classes-per-file */ import { expect, jest, test } from "@jest/globals"; +import { getConfig } from "../lib/gitgitgadget-config"; import { git } from "../lib/git"; +import { GitNotes } from "../lib/git-notes"; import { PatchSeries } from "../lib/patch-series"; +import { ProjectOptions } from "../lib/project-options"; import { testCreateRepo } from "./test-lib"; jest.setTimeout(60000); +getConfig(); + const mbox1 = `From 38d1082511bb02a709f203481c2787adc6e67c02 Mon Sep 17 00:00:00 2001 Message-Id: @@ -86,7 +92,23 @@ class PatchSeriesTest extends PatchSeries { const thisAuthor = "GitGitGadget "; const senderName = "Nguyễn Thái Ngọc Duy"; - PatchSeries.insertCcAndFromLines(mails, thisAuthor, senderName); + const prMeta = { + baseCommit: "", + baseLabel: "", + headCommit: "", + headLabel: "", + iteration: 1, + }; + class ProjectOptionsTest extends ProjectOptions { + public constructor() { + super("", "","","","",[],"",""); + } + } + + const x = new PatchSeriesTest(new GitNotes(), {}, + new ProjectOptionsTest(), prMeta, "", 1); + + x.insertCcAndFromLines(mails, thisAuthor, senderName); test("non-ASCII characters are encoded correctly", () => { const needle = "\"=?UTF-8?Q?Nguy=E1=BB=85n_Th=C3=A1i_Ng=E1=BB=8Dc?=" diff --git a/tests/project-options.test.ts b/tests/project-options.test.ts index e4c67c4fd4..4b57a52aca 100644 --- a/tests/project-options.test.ts +++ b/tests/project-options.test.ts @@ -1,6 +1,7 @@ import { expect, jest, test } from "@jest/globals"; import { isDirectory } from "../lib/fs-util"; import { GitNotes } from "../lib/git-notes"; +import { getConfig } from "../lib/gitgitgadget-config"; import { PatchSeries } from "../lib/patch-series"; import { ProjectOptions } from "../lib/project-options"; import { testCreateRepo } from "./test-lib"; @@ -8,6 +9,8 @@ import { testCreateRepo } from "./test-lib"; // This test script might take quite a while to run jest.setTimeout(20000); +getConfig(); + test("project options", async () => { const repo = await testCreateRepo(__filename); expect(await isDirectory(`${repo.workDir}/.git`)).toBeTruthy(); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index af5c09c3b4..4ebadc0e29 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -4,6 +4,7 @@ "lib/**/*.ts", "script/**/*.ts", "tests/**/*.ts", + "tests-config/**/*.ts", "./.eslintrc.js", ] } \ No newline at end of file