diff --git a/src/pr/body.ts b/src/pr/body.ts new file mode 100644 index 0000000..f489743 --- /dev/null +++ b/src/pr/body.ts @@ -0,0 +1,130 @@ +import type { JiraTicket } from '../jira/types'; +import { featureTitle, ticketUrl } from '../vcs/naming'; +import type { PullRequest, StagingKind, VerifiedCheck } from './types'; + +/** + * What a reviewer is told about the checks. + * + * A tick and a cross rather than prose, because the interesting case is the mixed one: a body + * that reads "verified locally" while one command failed is how an unverified change gets + * merged. The list is rendered from what it was given, without filtering. + */ +const MARKS = { passed: '✅', failed: '❌' } as const; + +const WHITESPACE = /\s+/gu; +/** Runs of backticks inside the text, so a code span can be delimited by one backtick more. */ +const BACKTICK_RUN = /`+/gu; +/** What a summary that is nothing but whitespace is called, so the sentence still reads. */ +const NO_SUMMARY = '(no summary)'; + +/** + * A Jira summary, rendered so that GitHub reads it as text and not as markup. + * + * A summary is arbitrary prose typed by a human into a field with no rules, and GitHub turns an + * `@handle` in a pull-request body into a notification and a subscription for that person — a + * reviewer request in all but name, in the same body that says this pull request has requested + * nobody. `#123`, a bare URL, `**` and a stray backtick are the same problem in smaller ways: + * a summary that reshapes the body is a body a reviewer cannot trust. + * + * A code span rather than a table of backslash escapes, because GitHub's mention, issue-link, + * autolink and emphasis extensions all stop at a code span, while `\@` is a CommonMark escape + * that GitHub's mention pass does not reliably honour. The delimiter is one backtick longer + * than the longest run inside the text, which is CommonMark's own rule for embedding backticks, + * so nothing in the summary can close the span early and break out. + * + * Only the body needs this. A pull-request title is plain text on GitHub — it renders no + * markdown and creates no mention — which is why `commitTitle` interpolates the summary as it is. + */ +function asText(summary: string): string { + const flat = summary.replace(WHITESPACE, ' ').trim(); + + if (flat === '') { + return NO_SUMMARY; + } + + const fence = '`'.repeat(Math.max(0, ...(flat.match(BACKTICK_RUN) ?? []).map((run) => run.length)) + 1); + // CommonMark strips one leading and one trailing space from a code span, so this is how a + // summary that itself starts or ends with a backtick still renders as what it says. + const pad = flat.startsWith('`') || flat.endsWith('`') ? ' ' : ''; + + return `${fence}${pad}${flat}${pad}${fence}`; +} + +interface PullRequestBodyInput { + readonly ticket: JiraTicket; + /** The branch the work is on, named so a reviewer can find it without opening the diff. */ + readonly branch: string; + /** What the verify slice ran in the checkout. May be empty, and then says so. */ + readonly checks: readonly VerifiedCheck[]; + /** How the diff's file list was chosen. Required, because the answer changes what to review. */ + readonly staging: StagingKind; +} + +/** + * What the reviewer is told about how the file list was chosen. + * + * The unfiltered case is stated outright rather than left for someone to notice from the diff. + * A body that describes a change as verified while quietly containing a regenerated lockfile is + * the shape of review that gets rubber-stamped, and the fix is one sentence saying which files + * were chosen by what. + */ +const STAGING_NOTES: Record = { + 'agent-reported': 'Only the files the agent reported writing are in this diff; anything else the checkout produced was left uncommitted.', + 'everything-changed': + 'Every file that differed in the checkout is in this diff. The agent cannot yet report which files it wrote (MAPCO-11435), so tool output the repository does not ignore — a regenerated lockfile, for instance — may be in here too. Worth a look before approving.', +}; + +function renderChecks(checks: readonly VerifiedCheck[]): string[] { + if (checks.length === 0) { + return ['**Nothing was verified locally.** No checks were run against this change before it was pushed.']; + } + + // The commands come from the verify slice rather than from the model, but they are rendered + // through the same neutraliser: a body is not the place to decide which strings are trusted. + return checks.map((check) => `- ${check.passed ? MARKS.passed : MARKS.failed} ${asText(check.command)}`); +} + +/** + * The pull request body. + * + * Two jobs: link the ticket, and say exactly what was and was not verified. It also says what + * the agent cannot do — a reviewer who does not know the agent has no merge or approve + * capability has no reason to believe the pull request is waiting for them. + * + * No reviewer is named and none is @-mentioned. Requesting one is not deferred by omission but + * by decision: routing to the right human needs the ownership data MAPCO-11378 introduces, and + * a wrong reviewer is worse than none, because it looks handled. + */ +function buildPullRequestBody(input: PullRequestBodyInput): string { + const { ticket, branch, checks, staging } = input; + + return [ + `Written automatically by the MapColonies developer agent from [${ticket.key}](${ticketUrl(ticket.key)}) — ${asText(featureTitle(ticket.summary))}.`, + '', + '## Verified locally', + '', + ...renderChecks(checks), + '', + 'Nothing beyond the above was checked, and nothing was deployed.', + '', + '## What is in this diff', + '', + STAGING_NOTES[staging], + '', + '## Review', + '', + `Branch \`${branch}\`. The agent can commit, push and open a pull request; it cannot merge, approve or review one, and it has requested no reviewer — a human decides all of that.`, + ].join('\n'); +} + +/** What the worker says on the ticket once the pull request exists. */ +function buildTicketComment(ticket: JiraTicket, branch: string, pullRequest: PullRequest): string { + return [ + `Opened a pull request for ${ticket.key}: ${pullRequest.url}`, + '', + `Branch \`${branch}\`. It needs a human review — the agent cannot approve or merge it.`, + ].join('\n'); +} + +export { asText, buildPullRequestBody, buildTicketComment }; +export type { PullRequestBodyInput }; diff --git a/src/pr/publish.ts b/src/pr/publish.ts new file mode 100644 index 0000000..f51a3eb --- /dev/null +++ b/src/pr/publish.ts @@ -0,0 +1,248 @@ +import type { Logger } from '@map-colonies/js-logger'; +import type { Repo } from '../github/types'; +import type { JiraTicket } from '../jira/types'; +import { branchName, commitMessage, commitTitle } from '../vcs/naming'; +import { toRepoRelative } from '../vcs/paths'; +import type { GitPort } from '../vcs/types'; +import { buildPullRequestBody, buildTicketComment } from './body'; +import type { PullRequest, PullRequestPort, StagingKind, TicketCommentPort, VerifiedCheck } from './types'; + +/** + * What a caller passes as `wrote` when the agent slice cannot say which files it wrote. + * + * A sentinel and not an omitted field, and not an empty array either, because the three states + * mean different things and only one of them is safe to guess at. An empty list is "the agent + * ran and wrote nothing", which is a refusal. This is "nobody knows", which is the state the + * repository is actually in today: `AgentRun` (src/agent/types.ts) reports `outcome: 'changed'` + * — a boolean — and `sdkOptions.wroteFiles()` pairs each write `tool_use` with its `tool_result` + * only to answer yes or no, discarding every `file_path` on the way. Nothing in the tree can produce a write list, so + * a required list would mean every ticket publishing with `[]`, refusing as + * `nothing-the-agent-wrote`, and no pull request ever being opened. + * + * With the sentinel the worker commits every path git reports as changed instead. That is not + * the same as `git add --all`: `changedFiles()` is `status --porcelain`, which never reports an + * ignored file, so what can get swept in is limited to output a repository declines to ignore — + * a regenerated `package-lock.json`, most often. The pull request says so in its own body and + * the worker says so in the log, so the reviewer who has to notice is told rather than left to + * spot it. Handing a human a slightly noisy diff is a smaller failure than handing them nothing. + * + * Reporting the paths is MAPCO-11435's to add — the SDK's `tool_use` blocks carry them, they are + * simply thrown away — and this whole branch disappears the day they arrive. + */ +const UNREPORTED_WRITES = 'unreported'; + +/** + * The paths the agent wrote, or the fact that nothing knows them. + * + * Absolute or repo-relative: the model's file tools report the absolute path the Agent SDK + * handed them, git prints repo-relative ones, and `toRepoRelative` reconciles the two so that a + * correct caller cannot be wrong about which dialect this wants. + */ +type WriteList = readonly string[] | typeof UNREPORTED_WRITES; + +/** + * Why no pull request was opened, when nothing actually failed. + * + * `nothing-to-commit` is the working tree being clean: the verify slice ran and left no diff. + * An empty pull request is not a smaller result than a full one, it is noise on a repo and a + * reviewer's time spent finding out there is nothing there. + * + * `nothing-the-agent-wrote` is the tree being dirty with files the agent never touched, which + * is the ordinary state of a checkout that has just had `npm ci` and a test suite run through + * it: a repo that commits no lockfile gets a `package-lock.json` written into it, a test script + * leaves coverage or build output behind. Opening a pull request on that would put a machine- + * generated diff in front of a reviewer under a body claiming it was verified. The two + * refusals are told apart because they mean different things to whoever reads the log line. + */ +type PublishRefusal = 'nothing-to-commit' | 'nothing-the-agent-wrote'; + +type PublishOutcome = + | { + readonly ok: true; + readonly branch: string; + readonly commit: string; + readonly pullRequest: PullRequest; + /** Whether the ticket got the comment. False means the pull request exists anyway. */ + readonly commented: boolean; + } + | { readonly ok: false; readonly reason: PublishRefusal }; + +interface PublishRequest { + readonly ticket: JiraTicket; + /** GitHub's canonical repo, which is where the default branch comes from. */ + readonly repo: Repo; + /** What the verify slice ran. Goes into the body verbatim. */ + readonly checks: readonly VerifiedCheck[]; + /** + * The paths the agent reported writing, or `UNREPORTED_WRITES` when nothing can report them. + * + * A list is an allow-list: the model has `Edit`, `Write` and `NotebookEdit` and no shell, so + * it can create and modify files and cannot delete or rename one, which makes a write list a + * complete description of what it did. A path that is listed but unchanged is dropped, a path + * that changed but is not listed is left in the working tree uncommitted and named in a + * warning, and a path that resolves outside the checkout is refused and named as well. + * + * Entries may be absolute or repo-relative; see `WriteList`. `UNREPORTED_WRITES` is the state + * the pipeline is in until MAPCO-11435 forwards the paths — see the sentinel's own note. + */ + readonly wrote: WriteList; +} + +interface PublishDeps { + readonly git: GitPort; + readonly pullRequests: PullRequestPort; + /** The Jira comment path only. Claim, release and transition are MAPCO-11431's. */ + readonly tickets: TicketCommentPort; + readonly logger: Logger; +} + +/** Which paths a commit will contain, and why the rest of the tree is not in it. */ +interface Staging { + readonly kind: StagingKind; + /** Exactly what gets staged, in the working tree's own order so the argv is deterministic. */ + readonly staging: readonly string[]; + /** Changed paths nobody reported writing. Left in the tree, uncommitted, and logged. */ + readonly generated: readonly string[]; + /** Reported writes that do not resolve to a path inside the checkout. */ + readonly outside: readonly string[]; +} + +/** + * Decide what goes in the commit. + * + * The intersection of "changed" and "written", with the two sides first brought into the same + * dialect — git's repo-relative paths and the Agent SDK's absolute ones (`toRepoRelative`). + * Both directions of the mismatch are reported by the caller: a path the agent claims it wrote + * but that has no diff usually means it wrote the file back unchanged, and a changed path nobody + * wrote is tool output. Either is something a human should be able to find in Loki afterwards. + * + * With no write list at all, every changed path is staged and the caller says so loudly in both + * the log and the pull request body — see `UNREPORTED_WRITES` for why that is the better of the + * two available failures. + */ +function selectStaging(changed: readonly string[], wrote: WriteList, root: string): Staging { + if (wrote === UNREPORTED_WRITES) { + return { kind: 'everything-changed', staging: changed, generated: [], outside: [] }; + } + + const reported = new Set(); + const outside: string[] = []; + + for (const candidate of wrote) { + const inside = toRepoRelative(candidate, root); + + if (inside === null) { + outside.push(candidate); + } else { + reported.add(inside); + } + } + + return { + kind: 'agent-reported', + staging: changed.filter((path) => reported.has(path)), + generated: changed.filter((path) => !reported.has(path)), + outside, + }; +} + +/** + * Turn a verified working tree into a reviewable pull request. + * + * Every string that ends up on the repository — the branch, the commit subject, the pull + * request title — is computed here from the Jira issue by `vcs/naming.ts`. None of it is asked + * of the model, which has no git and no GitHub capability to act on an answer with anyway. That + * is the whole point of MAPCO-11436: the naming rules and the "no master, no merge, no approve" + * rules hold because the code is the only thing that can perform any of it. + * + * The order is commit, push, open, comment, and it is the order it reads. Unlike the release + * path in `tickets/claim.ts`, none of these steps make the ticket available to another worker, + * so there is no ordering trap here — the only rule is that the comment goes last, because it + * links a pull request that has to exist first. + * + * A failure in git or in the GitHub API throws, on purpose. It is `handleTicket`'s per-ticket + * try/catch that contains it (MAPCO-11431 owns that seam), and what it leaves behind is a + * pushed `agent/` branch with no pull request — greppable by prefix, harmless, and a much + * better state than a half-reported success. + */ +async function publishPullRequest(request: PublishRequest, deps: PublishDeps): Promise { + const { ticket, repo, checks, wrote } = request; + const { git, pullRequests, tickets, logger } = deps; + + const changed = await git.changedFiles(); + + if (changed.length === 0) { + logger.warn({ msg: 'nothing to publish', key: ticket.key, repo: repo.fullName }); + + return { ok: false, reason: 'nothing-to-commit' }; + } + + const selection = selectStaging(changed, wrote, git.root); + + if (selection.outside.length > 0) { + // Not a refusal of the ticket: the agent may legitimately have read something outside the + // clone, and the interesting case — a path that walks out of it — is worth seeing by name. + logger.warn({ msg: 'ignoring reported writes outside the checkout', key: ticket.key, repo: repo.fullName, paths: selection.outside }); + } + + if (selection.generated.length > 0) { + logger.warn({ msg: 'leaving changed paths out of the commit', key: ticket.key, repo: repo.fullName, paths: selection.generated }); + } + + if (selection.kind === 'everything-changed') { + // The one line that says this pull request's diff was not filtered by anything. It is in the + // body too, but a reviewer reads the body and an operator reads Loki. + logger.warn({ msg: 'committing every changed path: no write list was reported', key: ticket.key, repo: repo.fullName, paths: selection.staging }); + } + + if (selection.staging.length === 0) { + logger.warn({ msg: 'nothing the agent wrote is changed', key: ticket.key, repo: repo.fullName, changed, wrote }); + + return { ok: false, reason: 'nothing-the-agent-wrote' }; + } + + const { staging } = selection; + const branch = branchName(ticket); + + await git.createBranch(branch); + const commit = await git.commit(commitMessage(ticket), staging); + await git.push(branch); + + const pullRequest = await pullRequests.open(repo, { + head: branch, + // The default branch GitHub reported for this repo, never a hard-coded `master` — the org + // has both, and a wrong base opens a pull request full of somebody else's commits. + base: repo.defaultBranch, + // The same string as the commit subject: a squash merge uses the pull request title as the + // commit message on the default branch, so this is what release-please reads. + title: commitTitle(ticket), + body: buildPullRequestBody({ ticket, branch, checks, staging: selection.kind }), + }); + + logger.info({ msg: 'pull request opened', key: ticket.key, repo: repo.fullName, branch, commit, files: staging.length, url: pullRequest.url }); + + return { ok: true, branch, commit, pullRequest, commented: await comment(ticket, branch, pullRequest, tickets, logger) }; +} + +/** + * Put the pull request link on the ticket. + * + * Contained rather than thrown: the pull request already exists and is already reviewable, and + * a Jira outage in the last half-second must not turn a published result into a failed one. The + * comment is how a human finds the pull request, so losing it is worth a warning — and worth + * reporting in the outcome — but it is not worth discarding the work. + */ +async function comment(ticket: JiraTicket, branch: string, pullRequest: PullRequest, tickets: TicketCommentPort, logger: Logger): Promise { + try { + await tickets.addComment(ticket.key, buildTicketComment(ticket, branch, pullRequest)); + + return true; + } catch (err) { + logger.warn({ msg: 'pull request opened but not linked on the ticket', key: ticket.key, url: pullRequest.url, err }); + + return false; + } +} + +export { publishPullRequest, UNREPORTED_WRITES }; +export type { PublishDeps, PublishOutcome, PublishRefusal, PublishRequest, WriteList }; diff --git a/src/pr/restPullRequests.ts b/src/pr/restPullRequests.ts new file mode 100644 index 0000000..9ef08f3 --- /dev/null +++ b/src/pr/restPullRequests.ts @@ -0,0 +1,80 @@ +import type { Repo } from '../github/types'; +import type { TokenProvider } from '../vcs/types'; +import type { PullRequest, PullRequestDraft, PullRequestPort } from './types'; + +const API = 'https://api.github.com'; +const CREATED = 201; + +/* eslint-disable @typescript-eslint/naming-convention -- mirrors the GitHub REST wire format */ +interface PullRequestResponse { + number: number; + html_url: string; +} + +/** + * The request body, in full. + * + * `draft: false` is written out rather than left to GitHub's default. It is an acceptance + * criterion — a draft suppresses some workflows and tends to be scrolled past — and a default + * is not a decision anyone can read. There is no `assignee`, no `assignees` and no + * `reviewers` key, because the API cannot leave off a field that was never sent. + */ +interface CreatePullRequest { + title: string; + head: string; + base: string; + body: string; + draft: boolean; + maintainer_can_modify: boolean; +} +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Opens pull requests over the GitHub REST API. + * + * The credential is minted per call from `TokenProvider`, never held on the instance and never + * read from the environment, so there is no long-lived token for this class to reuse even if a + * later slice keeps one instance alive for the process's lifetime. The provider itself is + * MAPCO-11428. + */ +class RestPullRequests implements PullRequestPort { + public constructor(private readonly tokens: TokenProvider) {} + + public async open(repo: Repo, draft: PullRequestDraft): Promise { + const payload: CreatePullRequest = { + title: draft.title, + head: draft.head, + base: draft.base, + body: draft.body, + draft: false, + // Lets a reviewer push a fix onto the agent's branch instead of recreating it by hand. + // eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub REST wire format + maintainer_can_modify: true, + }; + + const token = await this.tokens.mint(); + + const response = await fetch(`${API}/repos/${repo.fullName}/pulls`, { + method: 'POST', + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + // Anything but 201 is a failure, including the 422 GitHub answers with when the branch was + // never pushed or a pull request for it already exists. None of those are "no pull request + // needed" — the ticket must not be reported as done because the response was misread. + if (response.status !== CREATED) { + throw new Error(`Opening a pull request on ${repo.fullName} failed: ${response.status} ${response.statusText}`); + } + + const body = (await response.json()) as PullRequestResponse; + + return { number: body.number, url: body.html_url }; + } +} + +export { RestPullRequests }; diff --git a/src/pr/types.ts b/src/pr/types.ts new file mode 100644 index 0000000..e357723 --- /dev/null +++ b/src/pr/types.ts @@ -0,0 +1,69 @@ +import type { Repo } from '../github/types'; + +/** + * One thing the verify slice ran in the checkout before any of this was allowed to happen. + * + * Declared here rather than imported because the verify slice (MAPCO-11434) does not exist + * yet: this is the narrowest shape a pull-request body needs in order to say what was checked, + * and it is the caller's job to fill it in honestly. `passed: false` is representable on + * purpose — a body that can only describe success is a body that will eventually lie. + */ +interface VerifiedCheck { + /** Exactly what was run, as a reviewer would run it themselves. */ + readonly command: string; + readonly passed: boolean; +} + +/** + * How the commit's file list was chosen, which is something a reviewer has to be told. + * + * `agent-reported` is the intended shape: only the files the agent said it wrote. `everything- + * changed` is what the worker does when nothing can tell it which files those were — see + * `UNREPORTED_WRITES` in `publish.ts` — and it is a distinct value rather than a silent fallback + * precisely so the pull request body can say which of the two produced its diff. + */ +type StagingKind = 'agent-reported' | 'everything-changed'; + +/** Everything GitHub needs to open the pull request, and nothing it does not. */ +interface PullRequestDraft { + /** The branch the work is on. */ + readonly head: string; + /** The branch it is proposed into — the repo's real default branch, never a guess. */ + readonly base: string; + /** Conventional-commit title, computed from the Jira issue in `vcs/naming.ts`. */ + readonly title: string; + readonly body: string; +} + +/** A pull request that exists. */ +interface PullRequest { + readonly number: number; + /** The `html_url`, which is what goes on the ticket for a human to click. */ + readonly url: string; +} + +/** + * Opening a pull request, and nothing else. + * + * There is no `merge`, no `approve`, no `requestReviewers` and no `update`. Those are the acts + * MAPCO-11436 says a machine must not perform, and the cheapest way to guarantee it is a port + * that cannot express them — the App's own permissions are the second layer (MAPCO-11428), not + * the only one. Reviewer routing is MAPCO-11378 and will need its own port when it lands. + */ +interface PullRequestPort { + open: (repo: Repo, draft: PullRequestDraft) => Promise; +} + +/** + * The one Jira write this slice needs: a comment saying where the pull request is. + * + * Deliberately a single-member port rather than a dependency on `JiraPort`. Claiming, + * releasing, assigning and transitioning belong to MAPCO-11431 and are not implemented, or + * even reachable, from here. The member is spelled `addComment` so that `JiraPort` already + * satisfies this structurally — wiring it up is passing the same object, not writing an adapter. + */ +interface TicketCommentPort { + addComment: (issueKey: string, body: string) => Promise; +} + +export type { PullRequest, PullRequestDraft, PullRequestPort, StagingKind, TicketCommentPort, VerifiedCheck }; diff --git a/src/vcs/cliGit.ts b/src/vcs/cliGit.ts new file mode 100644 index 0000000..cfd2b09 --- /dev/null +++ b/src/vcs/cliGit.ts @@ -0,0 +1,365 @@ +import { execFile } from 'node:child_process'; +import { devNull } from 'node:os'; +import { promisify } from 'node:util'; +import type { Repo } from '../github/types'; +import { AGENT_PREFIX } from './naming'; +import type { GitIdentity, GitPort, TokenProvider } from './types'; + +const run = promisify(execFile); + +/** `git status --porcelain` prefixes every path with two status letters and a space. */ +const STATUS_PREFIX_LENGTH = 3; +/** Porcelain writes a rename as `old -> new`; the new path is the one that exists. */ +const RENAME_ARROW = ' -> '; +/** + * Room for git's own output. The default 1 MiB is enough for a push, but `status --porcelain` + * on a large generated diff is not worth failing with `ENOBUFS` over. + */ +const MAX_OUTPUT_BYTES = 10_485_760; +/** What a redacted secret reads as in an error message. */ +const REDACTED = '***'; + +/** + * How long any one git invocation may take before it is killed. + * + * Every other subprocess boundary in the worker is bounded — the verify slice's runner gives a + * clone's own suite fifteen minutes (`src/workspace/subprocess.ts`) — and this one used to be + * the exception. The failure it prevents is specific: a push to a remote behind a proxy that + * completes the handshake and then answers nothing hangs `execFile` forever, and because + * `publishPullRequest` is awaited by `handleTicket` which is awaited by `runCycle`, one hung + * push stops the scheduler from ever ticking again. The pod stays alive and healthy — it is + * outbound-only, so no probe kills it (MAPCO-11430) — and the queue simply stops. + * + * Five minutes rather than fifteen: nothing here installs anything or runs a test suite. It is + * a status, a checkout, a commit and a push, and a push that has not finished in five minutes + * is not going to. + */ +const GIT_TIMEOUT_MS = 300_000; + +/** + * Never ask a human anything. + * + * git's default is to prompt on a missing or rejected credential, and a prompt on a process + * with no terminal is a process that waits until the timeout above rather than failing with a + * usable message. `GIT_TERMINAL_PROMPT=0` turns the prompt into an immediate error; the askpass + * variables are emptied because a developer machine — where `npm run dry-run` runs — often has + * a graphical credential helper configured that would otherwise pop a window nobody sees. + */ +/* eslint-disable @typescript-eslint/naming-convention -- environment variable names, not identifiers */ +const NON_INTERACTIVE: NodeJS.ProcessEnv = { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + GCM_INTERACTIVE: 'never', +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * The environment variable the push credential is handed over in, and the helper that reads it. + * + * The token used to be interpolated into the push URL, which put it in git's argv — and argv is + * world-readable through `/proc//cmdline`, so any process on the host could read a live + * installation token with org-wide write access straight out of the process table. It did not + * even need to be a process the worker started: the model has `Write`, the verify slice runs + * the clone's own `npm test`, and a rewritten test script therefore gets same-user execution in + * the same container minutes before the push happens (containing that is MAPCO-11430's job, but + * the token need not be reachable for it to matter). + * + * A credential helper is git's own answer to this. The helper is a shell snippet — git runs a + * `!`-prefixed value through `sh -c` with the operation appended — and the snippet contains no + * secret, only the *name* of a variable. The value travels in the child's environment, which + * `/proc//environ` exposes to the process owner alone rather than to everybody. The empty + * `credential.helper=` in front of it resets the helper list, so a helper inherited from a + * developer's global config cannot answer first with a stale credential of its own. + * + * `case` rather than `test "$1" = get &&`, so the snippet exits zero on the `store` and `erase` + * operations git also calls it with instead of looking like a failing helper. + */ +const TOKEN_ENV = 'GIT_AGENT_PUSH_TOKEN'; +/* Exported so that `tests/unit/vcs/scratchRepo.spec.ts` can hand the snippet to the real git and + * watch it answer, rather than asserting that one string equals another string. A shell snippet + * git runs through `sh -c` is not something a mock can tell you is correct. */ +const CREDENTIAL_FROM_ENV = [ + '-c', + 'credential.helper=', + '-c', + `credential.helper=!f() { case "$1" in get) printf 'username=x-access-token\\npassword=%s\\n' "$${TOKEN_ENV}" ;; esac; }; f`, +]; + +/** + * Every git invocation that could fire a hook carries this, and it is the reason the publish + * path cannot be killed by the repository it is working on. + * + * The clone is an arbitrary repository whose hooks the verify slice has just installed for us: + * `npm ci` runs `prepare`, which runs husky, which is how a `pre-commit` running `pretty-quick` + * and a `commit-msg` running `commitlint` end up live in the checkout. Those hooks are a + * contract between that repo and its humans; they are arbitrary code, and any one of them + * exiting non-zero loses the whole result — no commit, no branch, no pull request, no comment on + * the ticket. The repo's real gate on this change is the pull request's own CI plus a human + * review, and both still run. + * + * `--no-verify` was the first attempt and is **not** enough: it bypasses `pre-commit` and + * `commit-msg` only, so a `prepare-commit-msg` hook still runs — and still fails, and can also + * rewrite the header this worker computed. Observed in the scratch repo in + * `tests/unit/vcs/scratchRepo.spec.ts`, which is what a hooks-path of nothing fixes and + * `--no-verify` did not. Pointing `core.hooksPath` at `/dev/null` means git looks for every hook + * inside a path that is not a directory and finds none of them. + * + * None of this excuses writing a header the org's `commit-msg` hook would reject: `naming.ts` + * emits one commitlint accepts, because a squash merge puts that string on the default branch + * where the hook is not bypassed. + */ +const HOOKS_OFF = ['-c', `core.hooksPath=${devNull}`]; + +/** + * Refs the worker is allowed to write, matched in full. + * + * Anchored on `agent/` so the segment cannot be pushed rightwards, and allow-listing the rest + * of the characters so a ref can never arrive with a leading `-` and be read by git as a flag. + * `..` is excluded separately because a dot on its own is legal in a ref and a pair is not. + */ +const WRITABLE_REF = /^agent\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u; +const REF_TRAVERSAL = '..'; +const REF_LOCK_SUFFIX = '.lock'; + +/** + * Credentials that reached an error message anyway, in `https://user:secret@host` form. + * + * Belt and braces next to redacting the minted token by value. This worker never builds such a + * URL any more — the credential goes to git through the environment — but git echoes back + * whatever remote it was given, a clone URL from GitHub's API could carry credentials, and a + * token in a log line outlives the token's own hour. + */ +const URL_CREDENTIALS = /\/\/[^@/\s]+@/gu; + +/** + * Ask git to read every pathspec as one literal path. + * + * `git add -- ` still glob-matches its pathspecs and still honours a `:(glob)` magic + * prefix, so a path arriving from the agent slice is not inert just because `execFile` gave it + * no shell to escape into. This slice's first answer was an allow-list that refused any path + * containing `*`, `?`, `[`, `]` or a backslash — which also refuses `app/[id]/page.tsx`, a + * filename every Next.js repository in the org has and git stages without complaint (verified: + * `git add -- 'app/[id]/page.tsx'` exits 0 and stages exactly that file). `--literal-pathspecs` + * is git's own switch for the same problem — it disables wildmatch and magic prefixes for the + * whole invocation — so the allow-list is redundant and the legitimate filename goes through. + */ +const LITERAL_PATHSPECS = '--literal-pathspecs'; + +/** + * A control character in a path. + * + * The one shape still refused outright, and not for git's sake — git handles it. It is refused + * because `status --porcelain` quotes a path containing a newline or a tab whatever + * `core.quotePath` is set to, so the string this module printed and the string it is handed back + * could never be the same one: the path would be reported as changed under one spelling, staged + * under another, and quietly not be in the commit. + */ +/* eslint-disable-next-line no-control-regex -- a control character in a path is exactly what this matches */ +const UNSAFE_PATH = /[\u0000-\u001F]/u; +const PARENT_SEGMENT = '..'; +const GIT_DIR = '.git'; + +/** + * Something the worker refuses to do to a repository, rather than something that went wrong. + * + * A throw and not a refusal value, unlike the Jira refusals: a caller asking to push `master` + * is a bug in the caller, not a state the pipeline is expected to reach and report on. + */ +class GitGuardError extends Error { + public constructor(message: string) { + super(message); + this.name = 'GitGuardError'; + } +} + +/** + * Runs git and resolves its stdout. Injected so the guards can be tested without a checkout. + * + * `env` is additions to the child's environment, not a replacement for it, and exists for one + * reason: it is how the push credential reaches git without ever appearing in its argv. + */ +type RunGit = (args: readonly string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise; + +interface CliGitOptions { + /** The checkout the verify slice left its diff in. */ + readonly cwd: string; + /** The repo being worked, with GitHub's canonical name and its real default branch. */ + readonly repo: Repo; + /** Author and committer of the commit. */ + readonly identity: GitIdentity; + /** Mints the push credential. Called once per push, never held. */ + readonly tokens: TokenProvider; + /** Overridden in tests. */ + readonly run?: RunGit; +} + +async function execGit(args: readonly string[], cwd: string, env: NodeJS.ProcessEnv = {}): Promise { + // `execFile`, never `exec`: arguments are passed as an array, so there is no shell to quote + // for and a branch name or a commit message cannot become a second command. + const { stdout } = await run('git', [...args], { + cwd, + maxBuffer: MAX_OUTPUT_BYTES, + timeout: GIT_TIMEOUT_MS, + // A git that ignored the term signal would otherwise keep the promise pending past the + // timeout, which is the whole failure the timeout exists to prevent. + killSignal: 'SIGKILL', + env: { ...process.env, ...NON_INTERACTIVE, ...env }, + }); + + return stdout; +} + +/** Replace a token, and any credentials in a URL, wherever they appear in a message. */ +function redact(message: string, token: string): string { + const withoutToken = token === '' ? message : message.split(token).join(REDACTED); + + return withoutToken.replace(URL_CREDENTIALS, `//${REDACTED}@`); +} + +/** + * Refuse any ref outside `agent/`. + * + * This is the acceptance criterion "pushing to master is observed to fail" expressed as code. + * A prompt telling a model not to push to master enforces nothing; a push path that has no way + * to name `master` cannot be talked into it, and the worker's own branch names are computed in + * `naming.ts` rather than supplied by the model in the first place. The default branch is + * rejected by name as well, in case a repo's default branch is itself under `agent/`. + */ +function assertWritable(branch: string, defaultBranch: string): void { + if (!WRITABLE_REF.test(branch) || branch.includes(REF_TRAVERSAL) || branch.endsWith(REF_LOCK_SUFFIX)) { + throw new GitGuardError(`refusing to write \`${branch}\`: the worker may only create and push refs under \`${AGENT_PREFIX}/\``); + } + + if (branch === defaultBranch) { + throw new GitGuardError(`refusing to write \`${branch}\`: it is the repository's default branch`); + } +} + +/** + * Refuse to stage anything but a literal path inside the checkout. + * + * The paths come from the agent slice — the files the model reported writing — and the model is + * the one input to this pipeline nobody controls. An empty list is refused rather than widened + * to the whole tree: `git add --all` in a checkout that has just had `npm ci` and the repo's own + * test script run through it commits whatever tool output the repo does not happen to gitignore, + * and a pull request whose entire diff is a generated `package-lock.json` is worse than no pull + * request at all. + */ +function assertCommittable(paths: readonly string[]): void { + if (paths.length === 0) { + throw new GitGuardError('refusing to commit: no paths were given, and the worker never stages a whole working tree'); + } + + for (const candidate of paths) { + const segments = candidate.split('/'); + + // An empty segment covers an absolute path, a trailing slash and a doubled slash at once. + if (UNSAFE_PATH.test(candidate) || segments.some((segment) => segment === '' || segment === PARENT_SEGMENT || segment === GIT_DIR)) { + throw new GitGuardError(`refusing to commit \`${candidate}\`: only literal paths inside the checkout may be staged`); + } + } +} + +/** + * git through its command line, with the worker's rules built in. + * + * The model never reaches this class: the Agent SDK gets file and test tools only, so a commit + * happens because the worker decided to make one. There is no method here that merges, forces, + * approves or deletes anything, so no later slice can reach for one by accident. + */ +class CliGit implements GitPort { + /** The checkout, so a caller can turn an absolute path into the one git prints. */ + public readonly root: string; + + private readonly git: RunGit; + + public constructor(private readonly options: CliGitOptions) { + this.root = options.cwd; + this.git = options.run ?? execGit; + } + + public async changedFiles(): Promise { + // `--untracked-files=all` because the default collapses a whole new directory to `src/`, + // and the caller matches these paths against the files the agent reported writing — a new + // file in a new directory would never match `src/` and would silently not be committed. + // `core.quotePath=false` because porcelain otherwise escapes a non-ASCII path as + // `"src/caf\303\251.ts"`, which matches nothing either. + const stdout = await this.git(['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all'], this.options.cwd); + + return stdout + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => { + const path = line.slice(STATUS_PREFIX_LENGTH); + const arrow = path.indexOf(RENAME_ARROW); + + return arrow > 0 ? path.slice(arrow + RENAME_ARROW.length) : path; + }); + } + + public async createBranch(branch: string): Promise { + assertWritable(branch, this.options.repo.defaultBranch); + + // `-b`, so an existing branch is an error rather than a silent switch onto somebody else's + // work. A second attempt on the same ticket gets a fresh checkout, not a reused branch. + // A `post-checkout` hook's exit status is `git checkout`'s exit status, hence `HOOKS_OFF`. + await this.git([...HOOKS_OFF, 'checkout', '-b', branch], this.options.cwd); + } + + public async commit(message: string, paths: readonly string[]): Promise { + assertCommittable(paths); + + // Exactly the paths the agent wrote, never `--all`. `--` ends the option list, so a path is + // a path even if it starts with a dash, and `--literal-pathspecs` means the ones that look + // like globs are the filenames they are rather than patterns. + await this.git([LITERAL_PATHSPECS, 'add', '--', ...paths], this.options.cwd); + + // Configured with `-c` per invocation rather than written with `git config`: nothing the + // worker does outlives the run, and a container's git config is shared by every ticket that + // container ever handles. + const config = [ + ...HOOKS_OFF, + '-c', + `user.name=${this.options.identity.name}`, + '-c', + `user.email=${this.options.identity.email}`, + '-c', + 'commit.gpgsign=false', + ]; + + await this.git([...config, 'commit', '--message', message], this.options.cwd); + + return (await this.git(['rev-parse', 'HEAD'], this.options.cwd)).trim(); + } + + public async push(branch: string): Promise { + assertWritable(branch, this.options.repo.defaultBranch); + + // Minted here, one push at a time. Nothing stores it: it is not written to the remote's + // config, not kept on the instance, and not in the argv — it reaches git through the child's + // environment and the credential helper above, so it is not in the process table either. + // What is left that could leak it is an error message, which is what `redact` is for. + const token = await this.options.tokens.mint(); + + try { + // An explicit refspec and an explicit URL, and the URL is the plain clone URL with no + // credentials in it. No `--force`, no `--set-upstream`, no named remote: whatever + // `push.default` or `origin` happen to be in this checkout, this pushes exactly one branch + // to exactly one ref and can create nothing else. + // `HOOKS_OFF` here too: a `pre-push` hook is the target repo's own arbitrary code, and a + // repo that runs its test suite on push must not be able to swallow a pushed branch. + await this.git( + [...HOOKS_OFF, ...CREDENTIAL_FROM_ENV, 'push', this.options.repo.cloneUrl, `refs/heads/${branch}:refs/heads/${branch}`], + this.options.cwd, + { [TOKEN_ENV]: token } + ); + } catch (err) { + // git echoes the remote URL back on failure, credentials included. + throw new Error(redact(err instanceof Error ? err.message : String(err), token)); + } + } +} + +export { CliGit, CREDENTIAL_FROM_ENV, GitGuardError, TOKEN_ENV }; +export type { CliGitOptions, RunGit }; diff --git a/src/vcs/naming.ts b/src/vcs/naming.ts new file mode 100644 index 0000000..48c5217 --- /dev/null +++ b/src/vcs/naming.ts @@ -0,0 +1,272 @@ +import type { JiraTicket } from '../jira/types'; +import { parseRepoPrefix } from '../tickets/resolveRepo'; + +/** + * The `{type}` segment of a branch name. + * + * `bug` is what MAPCO-11436 asks for — "`{type}` being feat, bug or chore" — and a branch + * segment is the one place it can be honoured, because nothing validates a branch name. It is + * deliberately *not* what the commit header uses; see `CommitType`. + */ +type BranchType = 'feat' | 'bug' | 'chore'; + +/** + * The conventional-commit type of the commit and pull-request header. + * + * `fix` and not `bug`, and this is the one place the ticket's wording is not followed literally. + * `bug` is not a conventional-commit type: it is absent from `@map-colonies/commitlint-config`'s + * `type-enum` (`deps, devdeps, helm, build, chore, ci, docs, feat, fix, perf, refactor, revert, + * style, test`), so the org's own `commit-msg` hook rejects a `bug:` header outright, and + * release-please matches no type for it — a merged agent bugfix would ship with no patch release + * and no changelog line. `fix:` is the convention's type for a defect, is in the enum, and cuts + * the patch release the ticket wants the real type to reach release-please for. The ticket's own + * sentence separates the two ideas: the branch enumerates feat/bug/chore, and the header is + * "conventional commits using the real type". + */ +type CommitType = 'feat' | 'fix' | 'chore'; + +/** + * Jira issue type to branch type. + * + * Deliberately not flattened to `chore` for everything. Around 109 MapColonies repos run + * release-please, so a merged `feat:` cuts a minor release — and an agent-authored feature is + * a feature. Hiding that behind `chore:` would mean the changelog stops describing the + * software, which is a worse outcome than an extra release. + * + * Keys are lower-cased issue-type names as the MAPCO project spells them. + */ +const BRANCH_TYPES: Record = { + bug: 'bug', + feature: 'feat', + story: 'feat', + 'product requirement': 'feat', + task: 'chore', + 'tech requirement': 'chore', + epic: 'chore', +}; + +/** Branch type to the conventional-commit type that names the same thing in a header. */ +const COMMIT_TYPES: Record = { feat: 'feat', bug: 'fix', chore: 'chore' }; + +/** + * What an issue type the map does not know becomes. + * + * `chore` and not `feat`, because the failure has to be the harmless one: guessing `feat` on + * a type nobody has classified yet would cut a release on 109 repos' worth of unfamiliar + * workflows. A missed release is noticed and fixed; a spurious one is already published. + */ +const DEFAULT_TYPE: BranchType = 'chore'; + +/** The leftmost path segment of every branch the worker creates. */ +const AGENT_PREFIX = 'agent'; + +/** + * How much of the ticket title survives into the branch name. + * + * A branch name is read in `git branch`, in a PR list and in a protection rule, none of which + * are improved by the whole title. The key is what identifies the branch; the slug is only + * there to make it recognisable. + */ +const SLUG_MAX_LENGTH = 48; + +/** + * How long a commit subject line may be. + * + * 72 is the git convention for a readable `git log --oneline`, and comfortably inside + * commitlint's default `header-max-length` of 100 — a commit the target repo's own hook + * rejects is a pull request that never happens. + */ +const HEADER_MAX_LENGTH = 72; + +/** Stands in for a ticket key that sanitises away to nothing, so a ref is never malformed. */ +const UNKNOWN_KEY = 'no-key'; + +/** What a subject says when the summary has nothing left in it once the repo prefix is off. */ +const FALLBACK_SUBJECT = 'apply the change described on the ticket'; + +/** + * Slug characters are allow-listed rather than blocked. + * + * `git check-ref-format` forbids a long list — spaces, `~`, `^`, `:`, `?`, `*`, `[`, `\`, + * `..`, a trailing dot, a trailing `.lock`, consecutive or leading or trailing slashes, `@{`. + * Enumerating that list invites missing one of them. Keeping only `[a-z0-9]` and joining with + * single dashes makes every one of those shapes unrepresentable, including the ones added to + * git after this was written. It is also why a title's own slashes cannot introduce a new path + * segment, so `agent/` stays leftmost and stays greppable. + */ +const UNSAFE_SLUG_CHARS = /[^a-z0-9]+/gu; +const UNSAFE_KEY_CHARS = /[^A-Z0-9-]+/gu; +const EDGE_DASHES = /^-+|-+$/gu; +/** Combining marks left behind by NFKD, so `café` slugs as `cafe` rather than `caf`. */ +const COMBINING_MARKS = /\p{M}+/gu; +const WHITESPACE = /\s+/gu; +/** Trailing punctuation on a subject, including the full stop commitlint's `subject-full-stop` bans. */ +const TRAILING_PUNCTUATION = /[\s.,;:!?-]+$/u; +/** A leading word with no lower-case letter in it, so an acronym is lowered as a word and not to `sLD`. */ +const ALL_CAPS_WORD = /^[^a-z]*[A-Z][^a-z]*$/u; + +/** Where a MAPCO issue is read by a human. Same host the README links to. */ +const JIRA_BROWSE_BASE = 'https://mapcolonies.atlassian.net/browse'; + +/** + * The feature title, with the `: ` prefix taken off when there is one. + * + * Whether a colon is the title convention or just punctuation in a sentence is decided by + * `parseRepoPrefix`, not decided again here — one rule, one place (MAPCO-11433 owns it). Only + * the split is repeated, and only on the same first colon that rule looked at. + */ +function featureTitle(summary: string): string { + const prefix = parseRepoPrefix(summary); + + if (prefix === null) { + return summary.trim(); + } + + return summary.slice(summary.indexOf(':') + 1).trim(); +} + +/** The branch `{type}` segment for a Jira issue type. Unknown types get `DEFAULT_TYPE`. */ +function branchType(issueType: string): BranchType { + const normalised = issueType.trim().toLowerCase().replace(WHITESPACE, ' '); + + return BRANCH_TYPES[normalised] ?? DEFAULT_TYPE; +} + +/** The conventional-commit type for a Jira issue type, which is what a header and a squash merge see. */ +function commitType(issueType: string): CommitType { + return COMMIT_TYPES[branchType(issueType)]; +} + +/** Cut a dash-joined slug to `limit`, at a dash rather than mid-word. */ +function trimToWord(slug: string, limit: number): string { + if (slug.length <= limit) { + return slug; + } + + const cut = slug.slice(0, limit); + const lastDash = cut.lastIndexOf('-'); + + return (lastDash > 0 ? cut.slice(0, lastDash) : cut).replace(EDGE_DASHES, ''); +} + +/** + * A ref-safe, bounded slug for a title. May legitimately be empty — a title that is entirely + * punctuation or entirely non-Latin script has no slug, and that is a branch without one + * rather than a branch with a dangling dash. + */ +function slugify(title: string): string { + const folded = title.normalize('NFKD').replace(COMBINING_MARKS, '').toLowerCase(); + + return trimToWord(folded.replace(UNSAFE_SLUG_CHARS, '-').replace(EDGE_DASHES, ''), SLUG_MAX_LENGTH); +} + +/** The issue key as it may appear in a ref: upper case, nothing exotic, never empty. */ +function safeKey(key: string): string { + const cleaned = key.trim().toUpperCase().replace(UNSAFE_KEY_CHARS, '-').replace(EDGE_DASHES, ''); + + return cleaned === '' ? UNKNOWN_KEY : cleaned; +} + +/** + * The branch the worker pushes: `agent/{type}/MAPCO-XXXXX-short-slug`. + * + * `agent/` is the leftmost segment on purpose. It makes every machine-authored branch + * greppable with one prefix, and it is the thing a branch-protection rule can be written + * against later — which is the same reason `CliGit` refuses to push anything that does not + * start with it. + */ +function branchName(ticket: JiraTicket): string { + const slug = slugify(featureTitle(ticket.summary)); + const stem = `${AGENT_PREFIX}/${branchType(ticket.issueType)}/${safeKey(ticket.key)}`; + + return slug === '' ? stem : `${stem}-${slug}`; +} + +/** + * The subject half of a commit title: one line, no trailing full stop, first word lower-cased. + * + * The lower-casing is not house style, it is a hard requirement of the org's `commit-msg` hook. + * `@commitlint/config-conventional`'s `subject-case` rule forbids a sentence-cased subject, and + * its sentence-case test is literally `upperFirst(subject) === subject` — so a subject is + * rejected whenever its *first character* is an upper-case letter, whatever follows. Verified + * against the real linter: `chore: Stop the loop` and `chore: SLD parsing drops a rule` both + * exit 1 on `subject-case`, and the lower-cased forms both exit 0. + * + * An all-capitals first word is lowered as a whole word rather than character by character, so + * `SLD parsing` becomes `sld parsing` instead of the unreadable `sLD parsing`. + */ +function subjectFrom(title: string): string { + const flat = title.replace(WHITESPACE, ' ').trim().replace(TRAILING_PUNCTUATION, ''); + const [first] = flat.split(' '); + + if (first === undefined || first === '') { + return flat; + } + + const lowered = ALL_CAPS_WORD.test(first) ? first.toLowerCase() : `${first.slice(0, 1).toLowerCase()}${first.slice(1)}`; + + return `${lowered}${flat.slice(first.length)}`; +} + +/** Cut a subject to `limit` at a word boundary, leaving no trailing punctuation behind. */ +function trimSubject(subject: string, limit: number): string { + if (subject.length <= limit) { + return subject; + } + + const cut = subject.slice(0, Math.max(limit, 0)); + const lastSpace = cut.lastIndexOf(' '); + + return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(TRAILING_PUNCTUATION, ''); +} + +/** Where a human reads the ticket. */ +function ticketUrl(key: string, browseBase: string = JIRA_BROWSE_BASE): string { + return `${browseBase.replace(/\/+$/u, '')}/${safeKey(key)}`; +} + +/** + * The conventional-commit title for a ticket: `{type}: {subject} (MAPCO-XXXXX)`. + * + * The key is a trailing reference rather than the head of the subject or a `(scope)`, and both + * of those positions were tried against the real linter first: + * + * - `chore: MAPCO-4 stop the loop` is **rejected**. commitlint's sentence-case test is + * `upperFirst(subject) === subject`, and an upper-case `M` at the head of the subject makes + * that true, so `subject-case` fails on every ticket regardless of issue type. This is the + * defect MAPCO-11436 shipped with first time round. + * - a `(scope)` is the one part of a header a target repo can reject outright: a `scope-enum` in + * somebody's commitlint config fails the hook on a key it has never heard of, and this string + * has to survive across every repo in the org. + * + * `chore: stop the loop (MAPCO-4)` exits 0 against `@map-colonies/commitlint-config`, keeps the + * key greppable, and keeps it in the changelog line release-please generates. The pull request + * gets this exact string too — a squash merge uses the PR title as the commit subject on the + * default branch, so the two agreeing is what makes the type reach release-please at all. + */ +function commitTitle(ticket: JiraTicket): string { + const type = commitType(ticket.issueType); + const reference = `(${safeKey(ticket.key)})`; + const described = subjectFrom(featureTitle(ticket.summary)); + // One space between the type and the subject, one before the reference. + const room = HEADER_MAX_LENGTH - `${type}: `.length - reference.length - 1; + const subject = trimSubject(described === '' ? FALLBACK_SUBJECT : described, room); + + // An absurdly long key can leave no room for a subject at all. A header that is only the + // reference is still a valid conventional commit — commitlint's `subject-case` skips a + // subject that does not start with a letter — and it is still bounded. + return subject === '' ? `${type}: ${reference}` : `${type}: ${subject} ${reference}`; +} + +/** + * The full commit message: the conventional title, then a body that says where it came from. + * + * A reviewer arriving at a commit on a branch nobody recognises should not have to guess which + * ticket it belongs to, and the pull request that links it may not exist yet at commit time. + */ +function commitMessage(ticket: JiraTicket): string { + return [commitTitle(ticket), '', 'Written automatically by the MapColonies developer agent.', '', `Ticket: ${ticketUrl(ticket.key)}`].join('\n'); +} + +export { AGENT_PREFIX, branchName, branchType, commitMessage, commitTitle, commitType, featureTitle, slugify, ticketUrl }; +export type { BranchType, CommitType }; diff --git a/src/vcs/paths.ts b/src/vcs/paths.ts new file mode 100644 index 0000000..955acfe --- /dev/null +++ b/src/vcs/paths.ts @@ -0,0 +1,49 @@ +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +/** + * The first segment `path.relative` produces when the target sits outside the base. + * + * Compared as a whole segment rather than with `startsWith('..')`, because a file legitimately + * named `..gitkeep` relativises to `..gitkeep` and is inside the checkout. + */ +const OUTSIDE = '..'; + +/** + * Turn one path the agent slice reported writing into the path git prints, or refuse it. + * + * The two halves of the publish path do not speak the same dialect, and this is the seam where + * that is dealt with rather than hoped about. `git status --porcelain` prints repo-relative + * paths with forward slashes (`src/tiles.ts`); the model's `Edit` and `Write` tools report the + * absolute path they were given (`/workspace/clone/src/tiles.ts`), because that is what the + * Agent SDK's `file_path` argument is. Intersecting the two by string equality — which is what + * this slice did first time round — silently matches nothing, and a publish path that stages + * nothing opens no pull request and says only that the agent wrote nothing. + * + * `null` is a refusal, not an error: a path that resolves outside the checkout, or to the + * checkout itself, is not something this worker will stage, and the caller names it in a log + * line rather than throwing the ticket away over it. + * + * Symlinks are deliberately not resolved. `realpath` would turn a legitimate path inside a + * symlinked checkout into one that appears to be outside it, and the guard that matters — no + * `..`, no absolute pathspec, no `.git` — is applied to the result in `cliGit.ts` regardless. + */ +function toRepoRelative(candidate: string, root: string): string | null { + if (candidate.trim() === '') { + return null; + } + + const absolute = isAbsolute(candidate) ? candidate : resolve(root, candidate); + const inside = relative(resolve(root), absolute); + + // An empty result is the checkout root itself; an absolute one means the two paths share no + // common root at all, which on POSIX only happens for a path that is not a path. + if (inside === '' || isAbsolute(inside) || inside.split(sep)[0] === OUTSIDE) { + return null; + } + + // git speaks forward slashes whatever the platform, and the pathspecs handed back to it have + // to match the strings it printed. + return inside.split(sep).join('/'); +} + +export { toRepoRelative }; diff --git a/src/vcs/types.ts b/src/vcs/types.ts new file mode 100644 index 0000000..eb80b25 --- /dev/null +++ b/src/vcs/types.ts @@ -0,0 +1,75 @@ +/** + * The git surface the worker uses, and the credential it uses it with. + * + * Everything in here is **worker code**. The Agent SDK is given file and test tools only, so + * the model has no way to run git, no way to reach a remote and no way to see a token — the + * branch name, the commit and the push are computed and performed by this layer instead of + * being asked for in a prompt. MAPCO-11436's whole point is that "never push to master" is a + * property of code, not a sentence in an instruction file. + */ + +/** Who a commit is authored by. */ +interface GitIdentity { + /** Author and committer name. For the App this is `{app-slug}[bot]`. */ + readonly name: string; + /** Author and committer email. For the App this is `{app-id}+{app-slug}[bot]@users.noreply.github.com`. */ + readonly email: string; +} + +/** + * Source of the credential used to push and to open a pull request. + * + * The contract is deliberately narrow and deliberately a *function*: every call mints a + * fresh, short-lived token, so there is nowhere for a long-lived secret to be stored and + * nothing to rotate. A GitHub App installation token lasts an hour; a run that outlives one + * mints another rather than holding one open. + * + * The implementation is the GitHub App itself, which is MAPCO-11428 and does not exist yet. + * Nothing here signs an App JWT — this slice depends on the contract only, so that the + * "never a static token" rule is expressed in the type rather than in a README paragraph. + * The same credential is what opens the pull request, which is why the port lives beside git + * rather than beside either caller. + */ +interface TokenProvider { + mint: () => Promise; +} + +/** + * What the worker does to a checkout that the verify slice has already left a diff in. + * + * Staging is not a separate step, but it is not "everything" either: `commit` takes the exact + * paths it is to stage. A checkout that has just had `npm ci` and the repo's own test script run + * through it is full of tool output, and only some repos gitignore all of it. There is no + * `merge`, no `forcePush` and no `checkout` of an arbitrary ref, because a capability that does + * not exist cannot be mis-used by a later slice. + */ +interface GitPort { + /** + * The checkout this port works in, absolute. + * + * Exposed because the publish path has to translate between two dialects of "a path in this + * repository": git prints repo-relative paths, and the model's file tools report the absolute + * ones the Agent SDK gave them. `vcs/paths.ts` does the translation and needs the root to do + * it; making the caller carry the same directory a second time would be two sources of truth + * for one fact, and the one that drifts is the one nothing notices. + */ + readonly root: string; + /** + * Paths that differ from `HEAD`, staged or not. Empty means the verify slice produced no + * diff, and an empty pull request is never worth opening. + */ + changedFiles: () => Promise; + /** Create and switch to a branch. Rejects anything outside `agent/`. */ + createBranch: (branch: string) => Promise; + /** + * Stage exactly `paths` and commit them. Resolves to the new commit's sha. + * + * Rejects an empty list and any path that is not a literal path inside the checkout, so + * "commit the whole working tree" is not a thing a caller can ask for by accident. + */ + commit: (message: string, paths: readonly string[]) => Promise; + /** Push one branch to the remote. Rejects anything outside `agent/`, and never forces. */ + push: (branch: string) => Promise; +} + +export type { GitIdentity, GitPort, TokenProvider }; diff --git a/tests/unit/pr/body.spec.ts b/tests/unit/pr/body.spec.ts new file mode 100644 index 0000000..e48d59f --- /dev/null +++ b/tests/unit/pr/body.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { buildPullRequestBody, buildTicketComment } from '@src/pr/body'; +import { ticket } from '@tests/helpers/fakeJira'; + +const checks = [ + { command: 'npm run lint', passed: true }, + { command: 'npm test', passed: true }, +]; + +const issue = ticket({ key: 'MAPCO-11436', issueType: 'Task', summary: 'developer-agent-bot: worker builds the branch, commit and PR in code' }); + +/** + * The body with its code spans removed — everything GitHub would still read as markup. + * + * The old assertion here was `expect(body).not.toContain('@')`, which passed only because the + * fixture summary happened to contain no `@`. A summary is arbitrary human prose, so the real + * question is not whether an `@` is present but whether one can reach GitHub's mention pass. + */ +function asMarkup(body: string): string { + return body.replace(/(`+)[\s\S]*?\1/gu, ''); +} + +describe('buildPullRequestBody', () => { + it('should link the ticket a reviewer needs to read.', () => { + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks, staging: 'agent-reported' }); + + expect(body).toContain('[MAPCO-11436](https://mapcolonies.atlassian.net/browse/MAPCO-11436)'); + expect(body).toContain('worker builds the branch, commit and PR in code'); + }); + + it('should state what was verified locally, command by command.', () => { + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks, staging: 'agent-reported' }); + + expect(body).toContain('## Verified locally'); + expect(body).toContain('`npm run lint`'); + expect(body).toContain('`npm test`'); + }); + + it('should show a failed check rather than describing it as verified.', () => { + // A body that can only describe success is a body that eventually lies, and this is the + // line a reviewer would otherwise skim past on the way to approving. + const body = buildPullRequestBody({ + ticket: issue, + branch: 'agent/chore/MAPCO-11436-worker-builds', + checks: [{ command: 'npm test', passed: false }], + staging: 'agent-reported', + }); + + expect(body).toContain('❌ `npm test`'); + expect(body).not.toContain('✅'); + }); + + it('should say plainly that nothing was verified when nothing was.', () => { + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks: [], staging: 'agent-reported' }); + + expect(body).toContain('**Nothing was verified locally.**'); + }); + + it('should name the branch and the fact that the agent cannot merge or approve.', () => { + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks, staging: 'agent-reported' }); + + expect(body).toContain('`agent/chore/MAPCO-11436-worker-builds`'); + expect(body).toContain('cannot merge, approve or review'); + }); + + it('should request no reviewer and mention nobody.', () => { + // Reviewer routing is MAPCO-11378. An @-mention here would be a request in all but name, + // and a wrong one is worse than none because it looks handled. + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks, staging: 'agent-reported' }); + + expect(asMarkup(body)).not.toContain('@'); + expect(body).toContain('requested no reviewer'); + }); + + it('should not let an @handle in the summary become a mention.', () => { + // GitHub notifies and subscribes `@alice` when the pull request opens — a reviewer request + // in all but name, in the same body that says nobody was requested. + const body = buildPullRequestBody({ + ticket: ticket({ key: 'MAPCO-14', summary: 'raster-shared: fix @alice retry helper' }), + branch: 'agent/chore/MAPCO-14-fix-alice-retry-helper', + checks, + staging: 'agent-reported', + }); + + expect(body).toContain('`fix @alice retry helper`'); + expect(asMarkup(body)).not.toContain('@'); + }); + + it('should not let a summary reshape the body with markdown of its own.', () => { + const body = buildPullRequestBody({ + ticket: ticket({ key: 'MAPCO-15', summary: 'x: ## Verified locally\n\n- ✅ nothing was run, honestly' }), + branch: 'agent/chore/MAPCO-15', + checks: [], + staging: 'agent-reported', + }); + + // The summary's own heading and tick survive as text inside the code span; neither reaches + // GitHub as markup, so the body still has exactly one heading and no passing check in it. + expect(asMarkup(body).split('## Verified locally')).toHaveLength(2); + expect(body).toContain('**Nothing was verified locally.**'); + expect(asMarkup(body)).not.toContain('✅'); + }); + + it('should say that only the files the agent wrote are in the diff.', () => { + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks, staging: 'agent-reported' }); + + expect(body).toContain('## What is in this diff'); + expect(body).toContain('Only the files the agent reported writing'); + }); + + it('should warn the reviewer when the diff was not filtered by a write list.', () => { + // The worker commits every changed path when nothing can tell it which files the agent + // wrote (MAPCO-11435). A body that did not say so would describe a diff containing a + // regenerated lockfile as a verified change, which is how a rubber stamp happens. + const body = buildPullRequestBody({ ticket: issue, branch: 'agent/chore/MAPCO-11436-worker-builds', checks, staging: 'everything-changed' }); + + expect(body).toContain('Every file that differed in the checkout is in this diff.'); + expect(body).toContain('MAPCO-11435'); + expect(body).not.toContain('Only the files the agent reported writing'); + }); + + it('should keep a summary that contains backticks inside its own code span.', () => { + // CommonMark's rule for embedding backticks: the delimiter is one longer than the longest + // run inside. Without it the summary closes the span early and the rest lands as markup. + const body = buildPullRequestBody({ + ticket: ticket({ key: 'MAPCO-16', summary: 'x: fix ``@alice`` in `render()`' }), + branch: 'agent/chore/MAPCO-16', + checks, + staging: 'agent-reported', + }); + + expect(body).toContain('``` fix ``@alice`` in `render()` ```'); + expect(asMarkup(body)).not.toContain('@'); + }); +}); + +describe('buildTicketComment', () => { + it('should link the pull request so the ticket leads to it.', () => { + const comment = buildTicketComment(issue, 'agent/chore/MAPCO-11436-worker-builds', { + number: 42, + url: 'https://github.com/MapColonies/developer-agent-bot/pull/42', + }); + + expect(comment).toContain('https://github.com/MapColonies/developer-agent-bot/pull/42'); + expect(comment).toContain('MAPCO-11436'); + expect(comment).toContain('`agent/chore/MAPCO-11436-worker-builds`'); + }); +}); diff --git a/tests/unit/pr/publish.spec.ts b/tests/unit/pr/publish.spec.ts new file mode 100644 index 0000000..18a1023 --- /dev/null +++ b/tests/unit/pr/publish.spec.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import type { Repo } from '@src/github/types'; +import { publishPullRequest, UNREPORTED_WRITES, type PublishDeps } from '@src/pr/publish'; +import type { PullRequest, PullRequestDraft, PullRequestPort, TicketCommentPort } from '@src/pr/types'; +import type { GitPort } from '@src/vcs/types'; +import { ticket } from '@tests/helpers/fakeJira'; +import { fakeLogger } from '@tests/helpers/fakeLogger'; + +const repo: Repo = { + name: 'developer-agent-bot', + fullName: 'MapColonies/developer-agent-bot', + defaultBranch: 'master', + cloneUrl: 'https://github.com/MapColonies/developer-agent-bot.git', +}; + +const opened: PullRequest = { number: 7, url: 'https://github.com/MapColonies/developer-agent-bot/pull/7' }; + +const checks = [{ command: 'npm test', passed: true }]; + +/** What the agent reported writing. The default fake tree has exactly this much changed in it. */ +const wrote = ['src/tiles.ts']; + +/** The clone the verify slice left its diff in. The agent's own paths are absolute inside it. */ +const checkout = '/workspace/clone'; + +/** Every effect the publish path had on the outside world, in order. Order is behaviour here. */ +type Effect = + | { kind: 'branch'; branch: string } + | { kind: 'commit'; message: string; paths: readonly string[] } + | { kind: 'push'; branch: string } + | { kind: 'pull-request'; draft: PullRequestDraft } + | { kind: 'comment'; key: string; body: string }; + +interface Fakes { + readonly deps: PublishDeps; + readonly effects: Effect[]; + readonly lines: { level: string; payload: Record }[]; +} + +function fakes(options: { readonly changed?: readonly string[]; readonly commentFailsWith?: Error } = {}): Fakes { + const effects: Effect[] = []; + const { logger, lines } = fakeLogger(); + + const git: GitPort = { + root: checkout, + changedFiles: async () => Promise.resolve(options.changed ?? ['src/tiles.ts']), + createBranch: async (branch: string) => { + effects.push({ kind: 'branch', branch }); + + return Promise.resolve(); + }, + commit: async (message: string, paths: readonly string[]) => { + effects.push({ kind: 'commit', message, paths }); + + return Promise.resolve('a1b2c3d'); + }, + push: async (branch: string) => { + effects.push({ kind: 'push', branch }); + + return Promise.resolve(); + }, + }; + + const pullRequests: PullRequestPort = { + open: async (_: Repo, draft: PullRequestDraft) => { + effects.push({ kind: 'pull-request', draft }); + + return Promise.resolve(opened); + }, + }; + + const tickets: TicketCommentPort = { + addComment: async (key: string, body: string) => { + if (options.commentFailsWith) { + throw options.commentFailsWith; + } + + effects.push({ kind: 'comment', key, body }); + + return Promise.resolve(); + }, + }; + + return { deps: { git, pullRequests, tickets, logger }, effects, lines }; +} + +const issue = ticket({ key: 'MAPCO-11436', issueType: 'Task', summary: 'developer-agent-bot: worker builds the branch, commit and PR in code' }); + +describe('publishPullRequest', () => { + it('should commit, push and open the pull request, in that order.', async () => { + const { deps, effects } = fakes(); + + const outcome = await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + expect(outcome).toMatchObject({ + ok: true, + branch: 'agent/chore/MAPCO-11436-worker-builds-the-branch-commit-and-pr-in-code', + commit: 'a1b2c3d', + commented: true, + }); + expect(effects.map((effect) => effect.kind)).toStrictEqual(['branch', 'commit', 'push', 'pull-request', 'comment']); + }); + + it('should derive the branch and the titles in code, from the issue type.', async () => { + // Nothing here comes from the model: it has no git and no GitHub capability, so there is + // nothing for it to name. A Bug gets the real type, never a flattened `chore:` — and the + // real type is `fix:`, because `bug` is not in @map-colonies/commitlint-config's type-enum + // and a `bug:` subject would fail the repo's own commit hook. Do not "correct" the + // assertion below to `bug:`; the ticket's wording predates checking the org config. + const { deps, effects } = fakes(); + + await publishPullRequest( + { ticket: ticket({ key: 'MAPCO-2', issueType: 'Bug', summary: 'developer-agent-bot: retry loop spins' }), repo, checks, wrote }, + deps + ); + + expect(effects[0]).toStrictEqual({ kind: 'branch', branch: 'agent/bug/MAPCO-2-retry-loop-spins' }); + expect(effects[2]).toStrictEqual({ kind: 'push', branch: 'agent/bug/MAPCO-2-retry-loop-spins' }); + expect(effects[1]).toMatchObject({ kind: 'commit' }); + expect((effects[1] as { message: string }).message.split('\n')[0]).toBe('fix: retry loop spins (MAPCO-2)'); + }); + + it('should give the pull request the same title as the commit, so a squash merge keeps the type.', async () => { + const { deps, effects } = fakes(); + + await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + const commit = effects.find((effect) => effect.kind === 'commit'); + const pullRequest = effects.find((effect) => effect.kind === 'pull-request'); + + expect(pullRequest?.draft.title).toBe(commit?.message.split('\n')[0]); + expect(pullRequest?.draft.title).toBe('chore: worker builds the branch, commit and PR in code (MAPCO-11436)'); + }); + + it("should open the pull request against the repo's own default branch.", async () => { + // Half the org's repos default to `master` and half to `main`. A hard-coded base opens a + // pull request full of somebody else's commits. + const { deps, effects } = fakes(); + + await publishPullRequest({ ticket: issue, repo: { ...repo, defaultBranch: 'main' }, checks, wrote }, deps); + + const pullRequest = effects.find((effect) => effect.kind === 'pull-request'); + + expect(pullRequest?.draft.base).toBe('main'); + expect(pullRequest?.draft.head).toBe('agent/chore/MAPCO-11436-worker-builds-the-branch-commit-and-pr-in-code'); + }); + + it('should link the pull request on the ticket, last of all.', async () => { + // Last because the comment has to link a pull request that already exists. + const { deps, effects } = fakes(); + + await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + const comment = effects.at(-1); + + expect(comment).toMatchObject({ kind: 'comment', key: 'MAPCO-11436' }); + expect((comment as { body: string }).body).toContain(opened.url); + }); + + it('should commit only the paths the agent wrote, never the rest of the tree.', async () => { + // The tree of a clone that has just had `npm ci` and a test suite run through it. Committing + // all of it opens a pull request whose diff is machine-generated, under a body that says the + // change was verified locally. + const { deps, effects, lines } = fakes({ changed: ['package-lock.json', 'src/tiles.ts', 'coverage/lcov.info'] }); + + await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + const commit = effects.find((effect) => effect.kind === 'commit'); + + expect(commit?.paths).toStrictEqual(['src/tiles.ts']); + expect(lines).toContainEqual({ + level: 'warn', + payload: { + msg: 'leaving changed paths out of the commit', + key: 'MAPCO-11436', + repo: 'MapColonies/developer-agent-bot', + paths: ['package-lock.json', 'coverage/lcov.info'], + }, + }); + }); + + it('should drop a path the agent reported writing that has no diff.', async () => { + const { deps, effects } = fakes({ changed: ['src/tiles.ts'] }); + + await publishPullRequest({ ticket: issue, repo, checks, wrote: ['src/tiles.ts', 'src/untouched.ts'] }, deps); + + expect(effects.find((effect) => effect.kind === 'commit')?.paths).toStrictEqual(['src/tiles.ts']); + }); + + it("should match the agent's absolute paths against the repo-relative ones git prints.", async () => { + // The model's `Edit` and `Write` tools report the absolute `file_path` they were given, and + // `git status` prints repo-relative paths. Comparing the two as strings — which this slice + // did first time round — matches nothing, stages nothing and opens no pull request, for + // every ticket, forever. + const { deps, effects } = fakes({ changed: ['src/tiles.ts', 'package-lock.json'] }); + + await publishPullRequest({ ticket: issue, repo, checks, wrote: [`${checkout}/src/tiles.ts`, `${checkout}/./src/nope.ts`] }, deps); + + expect(effects.find((effect) => effect.kind === 'commit')?.paths).toStrictEqual(['src/tiles.ts']); + }); + + it('should ignore a reported write that is not inside the checkout, and name it.', async () => { + const { deps, effects, lines } = fakes({ changed: ['src/tiles.ts'] }); + + await publishPullRequest({ ticket: issue, repo, checks, wrote: ['src/tiles.ts', '../elsewhere/thing.ts', '/etc/passwd'] }, deps); + + expect(effects.find((effect) => effect.kind === 'commit')?.paths).toStrictEqual(['src/tiles.ts']); + expect(lines).toContainEqual({ + level: 'warn', + payload: { + msg: 'ignoring reported writes outside the checkout', + key: 'MAPCO-11436', + repo: 'MapColonies/developer-agent-bot', + paths: ['../elsewhere/thing.ts', '/etc/passwd'], + }, + }); + }); + + it('should commit every changed path when no write list can be reported, and say so.', async () => { + // Nothing in the repository can produce a write list today: `AgentRun` reports a boolean and + // `wroteFiles()` throws every `file_path` away (MAPCO-11435). Requiring the list would mean + // every ticket publishing with `[]`, refusing as `nothing-the-agent-wrote`, and no pull + // request ever being opened — so the sentinel is a state the caller can state, the diff is + // unfiltered, and both the log line and the body say which of the two produced it. + const { deps, effects, lines } = fakes({ changed: ['src/tiles.ts', 'package-lock.json'] }); + + const outcome = await publishPullRequest({ ticket: issue, repo, checks, wrote: UNREPORTED_WRITES }, deps); + + expect(outcome).toMatchObject({ ok: true }); + expect(effects.find((effect) => effect.kind === 'commit')?.paths).toStrictEqual(['src/tiles.ts', 'package-lock.json']); + expect(lines).toContainEqual({ + level: 'warn', + payload: { + msg: 'committing every changed path: no write list was reported', + key: 'MAPCO-11436', + repo: 'MapColonies/developer-agent-bot', + paths: ['src/tiles.ts', 'package-lock.json'], + }, + }); + + const pullRequest = effects.find((effect) => effect.kind === 'pull-request'); + + expect(pullRequest?.draft.body).toContain('Every file that differed in the checkout is in this diff.'); + }); + + it('should tell the reviewer when the diff was filtered by a write list.', async () => { + const { deps, effects } = fakes(); + + await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + const pullRequest = effects.find((effect) => effect.kind === 'pull-request'); + + expect(pullRequest?.draft.body).toContain('Only the files the agent reported writing'); + }); + + it('should refuse when the only changes are ones the agent did not write.', async () => { + // A repo that commits no lockfile gets a `package-lock.json` written into it by the verify + // slice. A dirty tree is not evidence that the model did anything. + const { deps, effects } = fakes({ changed: ['package-lock.json'] }); + + const outcome = await publishPullRequest({ ticket: issue, repo, checks, wrote: [] }, deps); + + expect(outcome).toStrictEqual({ ok: false, reason: 'nothing-the-agent-wrote' }); + expect(effects).toStrictEqual([]); + }); + + it('should refuse to open an empty pull request when the tree is clean.', async () => { + // No diff means the verify slice produced nothing. A pull request with no changes costs a + // reviewer the time it takes to find that out. + const { deps, effects } = fakes({ changed: [] }); + + const outcome = await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + expect(outcome).toStrictEqual({ ok: false, reason: 'nothing-to-commit' }); + expect(effects).toStrictEqual([]); + }); + + it('should keep the pull request when the ticket comment fails.', async () => { + // The pull request exists and is reviewable. A Jira outage in the last half-second must not + // turn a published result into a failed one — but it is reported, not swallowed. + const { deps, effects, lines } = fakes({ commentFailsWith: new Error('jira is down') }); + + const outcome = await publishPullRequest({ ticket: issue, repo, checks, wrote }, deps); + + expect(outcome).toMatchObject({ ok: true, commented: false, pullRequest: opened }); + expect(effects.map((effect) => effect.kind)).toStrictEqual(['branch', 'commit', 'push', 'pull-request']); + expect(lines.at(-1)).toMatchObject({ level: 'warn', payload: { msg: 'pull request opened but not linked on the ticket' } }); + }); + + it('should let a push failure through rather than reporting a pull request that does not exist.', async () => { + const { deps } = fakes(); + const failing: PublishDeps = { ...deps, git: { ...deps.git, push: async () => Promise.reject(new Error('permission denied')) } }; + + await expect(publishPullRequest({ ticket: issue, repo, checks, wrote }, failing)).rejects.toThrow('permission denied'); + }); +}); diff --git a/tests/unit/pr/restPullRequests.spec.ts b/tests/unit/pr/restPullRequests.spec.ts new file mode 100644 index 0000000..e09fb70 --- /dev/null +++ b/tests/unit/pr/restPullRequests.spec.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Repo } from '@src/github/types'; +import { RestPullRequests } from '@src/pr/restPullRequests'; +import type { PullRequestDraft } from '@src/pr/types'; +import type { TokenProvider } from '@src/vcs/types'; + +const CREATED = 201; +const UNPROCESSABLE = 422; + +const repo: Repo = { + name: 'raster-shared', + fullName: 'MapColonies/raster-shared', + defaultBranch: 'master', + cloneUrl: 'https://github.com/MapColonies/raster-shared.git', +}; + +const draft: PullRequestDraft = { + head: 'agent/bug/MAPCO-1-stop-the-loop', + base: 'master', + title: 'fix: stop the loop (MAPCO-1)', + body: 'Written automatically by the MapColonies developer agent.', +}; + +function stubFetch(status: number, body?: unknown): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Promise.resolve({ + status, + ok: status === CREATED, + statusText: String(status), + json: async () => Promise.resolve(body), + }) + ) + ); +} + +/** Mints a different token every time, so "per call" is observable rather than asserted. */ +function fakeTokens(): TokenProvider & { readonly minted: string[] } { + const minted: string[] = []; + + return { + minted, + mint: async (): Promise => { + const token = `ghs-token-${minted.length + 1}`; + minted.push(token); + + return Promise.resolve(token); + }, + }; +} + +function lastCall(): { url: string; init: { headers: Record; body: string; method: string } } { + const [url, init] = vi.mocked(fetch).mock.calls.at(-1) as unknown as [string, { headers: Record; body: string; method: string }]; + + return { url, init }; +} + +function sentPayload(): Record { + return JSON.parse(lastCall().init.body) as Record; +} + +describe('RestPullRequests', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should open the pull request against the repo and report its number and url.', async () => { + /* eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub REST wire format */ + stubFetch(CREATED, { number: 42, html_url: 'https://github.com/MapColonies/raster-shared/pull/42' }); + + const pullRequest = await new RestPullRequests(fakeTokens()).open(repo, draft); + + expect(pullRequest).toStrictEqual({ number: 42, url: 'https://github.com/MapColonies/raster-shared/pull/42' }); + expect(lastCall().url).toBe('https://api.github.com/repos/MapColonies/raster-shared/pulls'); + expect(lastCall().init.method).toBe('POST'); + }); + + it('should open a normal pull request, never a draft.', async () => { + // A draft suppresses some workflows and gets scrolled past. Sent explicitly rather than + // left to GitHub's default, because a default is not a decision anyone can read. + /* eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub REST wire format */ + stubFetch(CREATED, { number: 1, html_url: 'https://github.com/MapColonies/raster-shared/pull/1' }); + + await new RestPullRequests(fakeTokens()).open(repo, draft); + + expect(sentPayload()).toMatchObject({ draft: false, head: draft.head, base: 'master', title: 'fix: stop the loop (MAPCO-1)' }); + }); + + it('should set no reviewer and no assignee.', async () => { + /* eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub REST wire format */ + stubFetch(CREATED, { number: 1, html_url: 'https://github.com/MapColonies/raster-shared/pull/1' }); + + await new RestPullRequests(fakeTokens()).open(repo, draft); + + expect(Object.keys(sentPayload())).toStrictEqual(['title', 'head', 'base', 'body', 'draft', 'maintainer_can_modify']); + }); + + it('should mint a token for every call rather than holding one.', async () => { + // The criterion is that installation tokens are minted per run and never static. An + // instance that outlives one token must not still be sending it. + /* eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub REST wire format */ + stubFetch(CREATED, { number: 1, html_url: 'https://github.com/MapColonies/raster-shared/pull/1' }); + const tokens = fakeTokens(); + const subject = new RestPullRequests(tokens); + + await subject.open(repo, draft); + await subject.open(repo, draft); + + expect(tokens.minted).toStrictEqual(['ghs-token-1', 'ghs-token-2']); + expect(lastCall().init.headers.authorization).toBe('Bearer ghs-token-2'); + }); + + it('should fail every attempt to merge or approve, and reach no such endpoint.', async () => { + // The API half of "merging and approving are attempted and observed to fail". The attempt is + // made rather than described: the adapter is reached through a loose record so that asking it + // to merge is a runtime question instead of a compile error. GitHub's own refusal — the App + // installation genuinely lacking `pull_requests: write` on a merge — is MAPCO-11428's to + // observe, and it is the second layer; this is the first one, which is that no call exists. + /* eslint-disable-next-line @typescript-eslint/naming-convention -- GitHub REST wire format */ + stubFetch(CREATED, { number: 3, html_url: 'https://github.com/MapColonies/raster-shared/pull/3' }); + const subject = new RestPullRequests(fakeTokens()); + const loose = subject as unknown as Record unknown) | undefined>; + + for (const act of ['merge', 'approve', 'review', 'requestReviewers', 'assign', 'close', 'update']) { + expect(loose[act]).toBeUndefined(); + expect(() => (loose[act] as (...args: unknown[]) => unknown)(repo, 1)).toThrow(TypeError); + } + + await subject.open(repo, draft); + + // One request, and it is the one that opens a pull request. Nothing reached `/merge`, nothing + // reached `/reviews`, and no method other than POST was used. + const requests = vi.mocked(fetch).mock.calls.map((call) => ({ url: call[0] as string, method: (call[1] as { method: string }).method })); + + expect(requests).toStrictEqual([{ url: 'https://api.github.com/repos/MapColonies/raster-shared/pulls', method: 'POST' }]); + expect(Object.getOwnPropertyNames(Object.getPrototypeOf(subject) as object).filter((name) => name !== 'constructor')).toStrictEqual(['open']); + }); + + it('should throw on anything that is not a created pull request.', async () => { + // 422 is what GitHub answers when the branch was never pushed, or when a pull request for + // it already exists. Neither means "no pull request was needed". + stubFetch(UNPROCESSABLE); + + await expect(new RestPullRequests(fakeTokens()).open(repo, draft)).rejects.toThrow('422'); + }); +}); diff --git a/tests/unit/vcs/cliGit.spec.ts b/tests/unit/vcs/cliGit.spec.ts new file mode 100644 index 0000000..2be49ed --- /dev/null +++ b/tests/unit/vcs/cliGit.spec.ts @@ -0,0 +1,402 @@ +import { devNull } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import type { Repo } from '@src/github/types'; +import { CliGit, GitGuardError, type RunGit } from '@src/vcs/cliGit'; +import type { TokenProvider } from '@src/vcs/types'; + +const repo: Repo = { + name: 'raster-shared', + fullName: 'MapColonies/raster-shared', + defaultBranch: 'master', + cloneUrl: 'https://github.com/MapColonies/raster-shared.git', +}; + +/** What every hook-firing invocation has to carry. Spelled out once so each assertion reads. */ +const HOOKS_OFF = ['-c', `core.hooksPath=${devNull}`]; + +/** + * How the push credential reaches git: a helper that reads a variable, not a token in the argv. + * + * Written out in full rather than imported, so that a change to the snippet has to be made in + * two places and read once — this is the string that decides whether a live installation token + * ends up in the process table. + */ +const CREDENTIAL_FROM_ENV = [ + '-c', + 'credential.helper=', + '-c', + `credential.helper=!f() { case "$1" in get) printf 'username=x-access-token\\npassword=%s\\n' "$GIT_AGENT_PUSH_TOKEN" ;; esac; }; f`, +]; + +const identity = { name: 'mapcolonies-developer-agent[bot]', email: '1234+mapcolonies-developer-agent[bot]@users.noreply.github.com' }; + +interface FakeGit { + readonly run: RunGit; + /** Every git invocation, in order, as the argv it would really have been given. */ + readonly calls: string[][]; + /** The environment additions each invocation was given, in the same order as `calls`. */ + readonly envs: NodeJS.ProcessEnv[]; +} + +/** + * A git that records its argv instead of running. + * + * The interesting assertions are all about what was *not* asked for — no `--force`, no + * `master`, no `merge` — and those only exist if the argv is visible. + */ +function fakeGit(options: { readonly status?: string; readonly failOn?: string; readonly sha?: string } = {}): FakeGit { + const calls: string[][] = []; + const envs: NodeJS.ProcessEnv[] = []; + + return { + calls, + envs, + run: async (args: readonly string[], _cwd: string, env: NodeJS.ProcessEnv = {}): Promise => { + calls.push([...args]); + envs.push({ ...env }); + + if (options.failOn !== undefined && args.includes(options.failOn)) { + // Shaped like a real git failure: the remote URL is echoed back, credentials included. + throw new Error(`Command failed: git ${args.join(' ')}\nremote: Permission to MapColonies/raster-shared.git denied.`); + } + + if (args.includes('status')) { + return Promise.resolve(options.status ?? ''); + } + + if (args[0] === 'rev-parse') { + return Promise.resolve(`${options.sha ?? 'deadbeef'}\n`); + } + + return Promise.resolve(''); + }, + }; +} + +/** Mints a different token every time, which is how "minted per call" is observable at all. */ +function fakeTokens(): TokenProvider & { readonly minted: string[] } { + const minted: string[] = []; + + return { + minted, + mint: async (): Promise => { + const token = `ghs-token-${minted.length + 1}`; + minted.push(token); + + return Promise.resolve(token); + }, + }; +} + +function cliGit(git: FakeGit, tokens: TokenProvider = fakeTokens()): CliGit { + return new CliGit({ cwd: '/tmp/checkout', repo, identity, tokens, run: git.run }); +} + +describe('CliGit.changedFiles', () => { + it('should report the paths the verify slice touched.', async () => { + const git = fakeGit({ status: ' M src/tiles.ts\n?? src/new.ts\n' }); + + await expect(cliGit(git).changedFiles()).resolves.toStrictEqual(['src/tiles.ts', 'src/new.ts']); + expect(git.calls).toStrictEqual([['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']]); + }); + + it('should report the new path of a rename, which is the one that exists.', async () => { + const git = fakeGit({ status: 'R src/old.ts -> src/new.ts\n' }); + + await expect(cliGit(git).changedFiles()).resolves.toStrictEqual(['src/new.ts']); + }); + + it('should list every untracked file rather than a collapsed directory.', async () => { + // The default porcelain output is `?? src/`, and the caller matches these paths against the + // files the agent said it wrote — a new file in a new directory would match nothing and + // would quietly not be committed. + const git = fakeGit(); + + await cliGit(git).changedFiles(); + + expect(git.calls[0]).toContain('--untracked-files=all'); + expect(git.calls[0]).toContain('core.quotePath=false'); + }); + + it('should report a clean tree as nothing at all.', async () => { + const git = fakeGit({ status: '\n' }); + + await expect(cliGit(git).changedFiles()).resolves.toStrictEqual([]); + }); +}); + +describe('CliGit.createBranch', () => { + it('should create the branch and switch onto it.', async () => { + const git = fakeGit(); + + await cliGit(git).createBranch('agent/feat/MAPCO-1-do-the-thing'); + + expect(git.calls).toStrictEqual([[...HOOKS_OFF, 'checkout', '-b', 'agent/feat/MAPCO-1-do-the-thing']]); + }); + + it('should refuse to create a branch outside agent/, so the prefix cannot be lost by accident.', async () => { + const git = fakeGit(); + + await expect(cliGit(git).createBranch('feat/MAPCO-1-do-the-thing')).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); +}); + +describe('CliGit.commit', () => { + it('should stage exactly the paths it was given, commit them and report the new sha.', async () => { + const git = fakeGit({ sha: 'a1b2c3d' }); + + await expect(cliGit(git).commit('feat: do the thing (MAPCO-1)', ['src/tiles.ts', 'src/new.ts'])).resolves.toBe('a1b2c3d'); + + expect(git.calls[0]).toStrictEqual(['--literal-pathspecs', 'add', '--', 'src/tiles.ts', 'src/new.ts']); + expect(git.calls[1]).toStrictEqual([ + ...HOOKS_OFF, + '-c', + 'user.name=mapcolonies-developer-agent[bot]', + '-c', + 'user.email=1234+mapcolonies-developer-agent[bot]@users.noreply.github.com', + '-c', + 'commit.gpgsign=false', + 'commit', + '--message', + 'feat: do the thing (MAPCO-1)', + ]); + expect(git.calls[2]).toStrictEqual(['rev-parse', 'HEAD']); + }); + + it('should never stage the whole working tree.', async () => { + // `git add --all` in a clone that has just had `npm ci` and the repo's test script run + // through it commits whatever tool output the repo does not gitignore — a pull request whose + // entire diff is a generated `package-lock.json`. + const git = fakeGit(); + + await cliGit(git).commit('chore: do the thing (MAPCO-1)', ['src/tiles.ts']); + + expect(git.calls.some((args) => args.includes('--all'))).toBe(false); + expect(git.calls.some((args) => args.includes('-A'))).toBe(false); + }); + + it('should refuse to commit with no paths at all rather than widening to everything.', async () => { + const git = fakeGit(); + + await expect(cliGit(git).commit('chore: do the thing (MAPCO-1)', [])).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it('should refuse a path that is not inside the checkout.', async () => { + // The paths come from the agent slice, which is the one input to this pipeline nobody + // controls. What is refused is a path that leaves the checkout or reaches into `.git` — + // not a path that merely looks like a pattern; see the next test. + const git = fakeGit(); + const subject = cliGit(git); + + await expect(subject.commit('chore: x (MAPCO-1)', ['/etc/passwd'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', ['../outside/thing.ts'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', ['src/../../outside.ts'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', ['.git/config'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', ['src/\u0000hidden.ts'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', [''])).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it('should stage a filename that looks like a glob rather than refusing it.', async () => { + // `app/[id]/page.tsx` is a real file in every Next.js repository in the org, and `git add` + // stages it without complaint. The first version of this slice refused every path containing + // `*`, `?`, `[`, `]` or a backslash, which turned a file git handles into a ticket that could + // never be published. `--literal-pathspecs` is git's own answer: no wildmatch, no `:(glob)` + // magic, every argument one literal path. + const git = fakeGit(); + + await cliGit(git).commit('chore: x (MAPCO-1)', ['app/[id]/page.tsx', 'src/what?.ts', 'src/a*b.ts']); + + expect(git.calls[0]).toStrictEqual(['--literal-pathspecs', 'add', '--', 'app/[id]/page.tsx', 'src/what?.ts', 'src/a*b.ts']); + }); + + it('should refuse the whole commit when one path in the list is bad.', async () => { + // Partially staging a set and committing the rest is a worse outcome than refusing: the + // pull request would look complete and be missing a file. + const git = fakeGit(); + + await expect(cliGit(git).commit('chore: x (MAPCO-1)', ['src/tiles.ts', '../escape.ts'])).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it("should not let the clone's own hooks swallow the result.", async () => { + // The verify slice runs `npm ci` in the clone, whose `prepare` installs husky — so the + // target repo's `pre-commit` and `commit-msg` are live by the time this runs. They are + // arbitrary code belonging to that repo, and any one of them failing loses the commit, the + // branch, the pull request and the ticket comment. The pull request's own CI still runs. + // + // `--no-verify` is deliberately not what does this: it leaves `prepare-commit-msg` running, + // which fails just as fatally and can rewrite the header. Observed in scratchRepo.spec.ts. + const git = fakeGit(); + + await cliGit(git).commit('chore: do the thing (MAPCO-1)', ['src/tiles.ts']); + + expect(git.calls[1]?.slice(0, 2)).toStrictEqual(HOOKS_OFF); + expect(git.calls[1]).not.toContain('--no-verify'); + }); + + it('should not write the identity into the checkout, only onto the invocation.', async () => { + // `git config user.name` would outlive the ticket and be inherited by the next one. + const git = fakeGit(); + + await cliGit(git).commit('chore: do the thing (MAPCO-1)', ['src/tiles.ts']); + + expect(git.calls.some((args) => args[0] === 'config')).toBe(false); + }); +}); + +describe('CliGit.push', () => { + it('should push exactly one branch to exactly one ref, with no force and no named remote.', async () => { + const git = fakeGit(); + const tokens = fakeTokens(); + + await cliGit(git, tokens).push('agent/feat/MAPCO-1-do-the-thing'); + + const [args] = git.calls; + + expect(args).toStrictEqual([ + ...HOOKS_OFF, + ...CREDENTIAL_FROM_ENV, + 'push', + 'https://github.com/MapColonies/raster-shared.git', + 'refs/heads/agent/feat/MAPCO-1-do-the-thing:refs/heads/agent/feat/MAPCO-1-do-the-thing', + ]); + expect(args?.some((arg) => arg.includes('force'))).toBe(false); + }); + + it('should keep the token out of the argv and hand it over in the environment.', async () => { + // argv is world-readable through `/proc//cmdline`, so a token in the push URL is a live + // installation credential any process on the host can read out of the process table — and + // the model can arrange for a same-user process, because the verify slice runs the clone's + // own test script. The environment is readable by the process owner only, and the helper in + // the argv names the variable rather than carrying its value. + const git = fakeGit(); + const tokens = fakeTokens(); + + await cliGit(git, tokens).push('agent/feat/MAPCO-1-do-the-thing'); + + expect(git.calls[0]?.some((arg) => arg.includes('ghs-token-1'))).toBe(false); + /* eslint-disable-next-line @typescript-eslint/naming-convention -- the environment variable's real name is the assertion */ + expect(git.envs[0]).toStrictEqual({ GIT_AGENT_PUSH_TOKEN: 'ghs-token-1' }); + // The URL that reaches git carries no credentials at all, so there is nothing for git to + // echo back into a log line either. + expect(git.calls[0]).toContain('https://github.com/MapColonies/raster-shared.git'); + }); + + it('should reset any credential helper the machine already has before adding its own.', async () => { + // A developer's global config can name a helper that answers first with a stale credential + // of its own, and `npm run dry-run` runs on exactly such a machine. An empty value resets + // git's helper list, so the only helper left is the one that reads the minted token. + const git = fakeGit(); + + await cliGit(git).push('agent/feat/MAPCO-1-do-the-thing'); + + expect(git.calls[0]?.indexOf('credential.helper=')).toBeGreaterThan(-1); + expect(git.calls[0]?.findIndex((arg) => arg.startsWith('credential.helper=!'))).toBeGreaterThan(git.calls[0]?.indexOf('credential.helper=') ?? 0); + }); + + it('should mint a token for every push rather than reusing one.', async () => { + // The credential is a GitHub App installation token, which expires in an hour. Reusing one + // across a long-lived process is how a worker starts failing pushes at the 61st minute. + const git = fakeGit(); + const tokens = fakeTokens(); + const subject = cliGit(git, tokens); + + await subject.push('agent/feat/MAPCO-1-one'); + await subject.push('agent/feat/MAPCO-2-two'); + + expect(tokens.minted).toStrictEqual(['ghs-token-1', 'ghs-token-2']); + /* eslint-disable-next-line @typescript-eslint/naming-convention -- the environment variable's real name is the assertion */ + expect(git.envs[1]).toStrictEqual({ GIT_AGENT_PUSH_TOKEN: 'ghs-token-2' }); + }); + + it('should refuse to push the default branch.', async () => { + // The criterion is that pushing to master fails. It fails here, before a token exists and + // before git is invoked at all — a prompt saying "never push to master" enforces nothing. + const git = fakeGit(); + const tokens = fakeTokens(); + + await expect(cliGit(git, tokens).push('master')).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + expect(tokens.minted).toStrictEqual([]); + }); + + it('should refuse to push any branch that is not under agent/.', async () => { + const git = fakeGit(); + + await expect(cliGit(git).push('main')).rejects.toThrow(GitGuardError); + await expect(cliGit(git).push('refs/heads/master')).rejects.toThrow(GitGuardError); + await expect(cliGit(git).push('release/1.2.0')).rejects.toThrow(GitGuardError); + await expect(cliGit(git).push('')).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it('should refuse a ref that tries to climb out of the agent prefix.', async () => { + const git = fakeGit(); + + await expect(cliGit(git).push('agent/../master')).rejects.toThrow(GitGuardError); + await expect(cliGit(git).push('agent/feat/MAPCO-1.lock')).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it('should refuse a branch that would arrive at git as a flag.', async () => { + const git = fakeGit(); + + await expect(cliGit(git).push('--mirror')).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it("should refuse the default branch even when the repo's default branch is itself under agent/.", async () => { + const git = fakeGit(); + const odd: Repo = { ...repo, defaultBranch: 'agent/main' }; + const subject = new CliGit({ cwd: '/tmp/checkout', repo: odd, identity, tokens: fakeTokens(), run: git.run }); + + await expect(subject.push('agent/main')).rejects.toThrow(GitGuardError); + expect(git.calls).toStrictEqual([]); + }); + + it('should keep the failure readable and the credentials out of it when a push fails.', async () => { + // git echoes back the remote it was given, and a token in a log line outlives the token + // itself. The failure still has to say what went wrong, so only the credentials go. + const git = fakeGit({ failOn: 'push' }); + const tokens = fakeTokens(); + + const failure = await cliGit(git, tokens) + .push('agent/feat/MAPCO-1-do-the-thing') + .catch((err: unknown) => String(err)); + + expect(failure).toContain('Permission to MapColonies/raster-shared.git denied'); + expect(failure).not.toContain('ghs-token-1'); + }); + + it('should redact credentials that a remote arrived with, not only the one it minted.', async () => { + // This worker no longer builds an authenticated URL — the credential goes to git through the + // environment — but a clone URL from GitHub's API, or a rewrite in somebody's git config, can + // still put one in front of git, and git will quote it back. + const tokens = fakeTokens(); + const run: RunGit = async (): Promise => + Promise.reject(new Error('fatal: could not read from https://x-access-token:some-other-secret@github.com/MapColonies/raster-shared.git')); + + const failure = await new CliGit({ cwd: '/tmp/checkout', repo, identity, tokens, run }) + .push('agent/feat/MAPCO-1-do-the-thing') + .catch((err: unknown) => String(err)); + + expect(failure).not.toContain('some-other-secret'); + expect(failure).toBe('Error: fatal: could not read from https://***@github.com/MapColonies/raster-shared.git'); + }); + + it('should redact the token by value, not only where it looks like a URL.', async () => { + const tokens = fakeTokens(); + // A failure that mentions the token on its own, with no URL around it to match on. + const run: RunGit = async (): Promise => Promise.reject(new Error('fatal: authentication failed using ghs-token-1')); + + const failure = await new CliGit({ cwd: '/tmp/checkout', repo, identity, tokens, run }) + .push('agent/feat/MAPCO-1-do-the-thing') + .catch((err: unknown) => String(err)); + + expect(failure).not.toContain('ghs-token-1'); + expect(failure).toBe('Error: fatal: authentication failed using ***'); + }); +}); diff --git a/tests/unit/vcs/naming.spec.ts b/tests/unit/vcs/naming.spec.ts new file mode 100644 index 0000000..dcd218e --- /dev/null +++ b/tests/unit/vcs/naming.spec.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from 'vitest'; +import { branchName, branchType, commitMessage, commitTitle, commitType, featureTitle, slugify, ticketUrl } from '@src/vcs/naming'; +import { ticket } from '@tests/helpers/fakeJira'; + +/** The git convention the commit header is bounded to, and comfortably inside commitlint's 100. */ +const HEADER_LIMIT = 72; + +/** + * The types `@map-colonies/commitlint-config` will accept, verbatim from its own `type-enum`. + * + * Asserted against rather than trusted, because a type outside this list fails the org's + * `commit-msg` hook and matches nothing in release-please — which is exactly how the first draft + * of this slice shipped a `bug:` header that no repo in the org would take. + */ +const ORG_TYPE_ENUM = ['deps', 'devdeps', 'helm', 'build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test']; + +/** + * The header properties `@commitlint/config-conventional` actually enforces, asserted here + * rather than described in a comment. + * + * `subject-case` forbids a sentence-cased subject, and commitlint's sentence-case test is + * literally `upperFirst(subject) === subject` — so the rule reduces to "the subject must not + * begin with an upper-case letter". `type-enum` and `subject-full-stop` are the other two the + * org's config leaves on. All 264 headers this module produces for a cross-product of issue + * types, hostile summaries and malformed keys were also run through the real linter + * programmatically; this keeps the properties they passed on under test. + */ +function expectCommitlintClean(header: string): void { + const [type, subject] = header.split(': '); + + expect(ORG_TYPE_ENUM).toContain(type); + expect(subject).toBeDefined(); + expect(header.endsWith('.')).toBe(false); + expect(header).not.toContain('\n'); + expect(header.length).toBeLessThanOrEqual(HEADER_LIMIT); + + const first = (subject as string).slice(0, 1); + + expect(first).toBe(first.toLowerCase()); +} + +/** + * The shapes `git check-ref-format` rejects. Asserted as a set rather than one case at a time, + * because the risk is a title nobody thought of producing a branch git will not accept — a + * failure that only shows up against a real remote. + */ +function expectRefSafe(ref: string): void { + expect(ref).toMatch(/^agent\//u); + expect(ref).not.toMatch(/[\s~^:?*[\\]/u); + expect(ref).not.toContain('..'); + expect(ref).not.toContain('//'); + expect(ref).not.toContain('@{'); + expect(ref.endsWith('.lock')).toBe(false); + expect(ref.endsWith('.')).toBe(false); + expect(ref.endsWith('/')).toBe(false); + expect(ref.endsWith('-')).toBe(false); +} + +describe('branchType', () => { + it('should name a Bug branch bug, which is the segment the ticket asks for.', () => { + // Nothing validates a branch name, so the branch is where the ticket's own vocabulary can be + // honoured literally. The header cannot afford to — see `commitType`. + expect(branchType('Bug')).toBe('bug'); + }); + + it('should map the user-facing types to feat so a merge still cuts a release.', () => { + expect(branchType('Feature')).toBe('feat'); + expect(branchType('Story')).toBe('feat'); + expect(branchType('Product Requirement')).toBe('feat'); + }); + + it('should map the internal types to chore.', () => { + expect(branchType('Task')).toBe('chore'); + expect(branchType('Tech Requirement')).toBe('chore'); + expect(branchType('Epic')).toBe('chore'); + }); + + it('should not care about casing or extra whitespace in the issue type.', () => { + expect(branchType(' tech requirement ')).toBe('chore'); + }); + + it('should default an unmapped type to chore rather than to feat.', () => { + // A guessed `feat:` on an unfamiliar issue type cuts a release on a real repo. A missed + // release is fixable; a published one is not. + expect(branchType('Spike')).toBe('chore'); + expect(branchType('')).toBe('chore'); + }); +}); + +describe('commitType', () => { + it('should write a Bug as fix, because bug is not a conventional-commit type.', () => { + // `bug` is in neither `@map-colonies/commitlint-config`'s `type-enum` nor release-please's + // vocabulary: a `bug:` header is rejected by the org's own `commit-msg` hook, and a merged + // one would ship with no patch release and no changelog line. + expect(commitType('Bug')).toBe('fix'); + expect(ORG_TYPE_ENUM).toContain(commitType('Bug')); + }); + + it('should keep the real type for everything else rather than flattening to chore.', () => { + expect(commitType('Feature')).toBe('feat'); + expect(commitType('Story')).toBe('feat'); + expect(commitType('Task')).toBe('chore'); + expect(commitType('Spike')).toBe('chore'); + }); + + it('should only ever produce a type the org config accepts.', () => { + for (const issueType of ['Bug', 'Feature', 'Story', 'Product Requirement', 'Task', 'Tech Requirement', 'Epic', 'Spike', '']) { + expect(ORG_TYPE_ENUM).toContain(commitType(issueType)); + } + }); +}); + +describe('featureTitle', () => { + it('should drop the repo prefix so the branch slug is about the feature.', () => { + expect(featureTitle('raster-shared: add a retry to the fetch helper')).toBe('add a retry to the fetch helper'); + }); + + it('should keep the whole summary when there is no repo prefix.', () => { + expect(featureTitle('Fix the map menu spinner')).toBe('Fix the map menu spinner'); + }); + + it('should keep a prose colon, because that is not the title convention.', () => { + // Delegated to `parseRepoPrefix`, so the rule for what counts as a prefix lives in one place. + expect(featureTitle('Note to whoever picks this up: the spinner leaks')).toBe('Note to whoever picks this up: the spinner leaks'); + }); + + it('should split on the first colon only, leaving later ones in the title.', () => { + expect(featureTitle('mc-mapproxy: fix the seed: retry loop')).toBe('fix the seed: retry loop'); + }); +}); + +describe('slugify', () => { + it('should fold accents rather than dropping the letters they sit on.', () => { + expect(slugify('Café résumé')).toBe('cafe-resume'); + }); + + it('should be empty for a title with nothing sluggable in it.', () => { + // A dangling dash on the end of a branch name is worse than no slug at all. + expect(slugify('בדיקה')).toBe(''); + expect(slugify('!!! ??? ...')).toBe(''); + }); + + it('should cut at a dash rather than mid-word.', () => { + const long = slugify('add a retry to the fetch helper so a flaky upstream does not fail the whole job'); + + expect(long.endsWith('-')).toBe(false); + expect('add-a-retry-to-the-fetch-helper-so-a-flaky-upstream-does-not'.startsWith(long)).toBe(true); + }); +}); + +describe('branchName', () => { + it('should put agent leftmost, then the type, then the key and a slug.', () => { + const branch = branchName( + ticket({ key: 'MAPCO-11436', issueType: 'Task', summary: 'developer-agent-bot: worker builds the branch, commit and PR in code' }) + ); + + expect(branch).toBe('agent/chore/MAPCO-11436-worker-builds-the-branch-commit-and-pr-in-code'); + + expectRefSafe(branch); + }); + + it('should use the real issue type in the branch, not a flattened one.', () => { + expect(branchName(ticket({ key: 'MAPCO-2', issueType: 'Bug', summary: 'raster-shared: retry loop spins' }))).toBe( + 'agent/bug/MAPCO-2-retry-loop-spins' + ); + expect(branchName(ticket({ key: 'MAPCO-3', issueType: 'Feature', summary: 'raster-shared: retry loop spins' }))).toBe( + 'agent/feat/MAPCO-3-retry-loop-spins' + ); + }); + + it('should stay a valid ref when the summary is entirely punctuation.', () => { + const branch = branchName(ticket({ key: 'MAPCO-9', summary: 'some-service: ***???...' })); + + expect(branch).toBe('agent/chore/MAPCO-9'); + + expectRefSafe(branch); + }); + + it('should stay a valid ref when the summary is not written in Latin script.', () => { + const branch = branchName(ticket({ key: 'MAPCO-9', issueType: 'Bug', summary: 'raster-shared: תקן את הלופ' })); + + expect(branch).toBe('agent/bug/MAPCO-9'); + + expectRefSafe(branch); + }); + + it('should not let a summary introduce a path segment of its own.', () => { + // Slashes in a title must not push `agent/` rightwards, or the prefix stops being something + // a branch-protection rule and a `git branch --list agent/*` can rely on. + const branch = branchName(ticket({ key: 'MAPCO-10', summary: 'some-service: fix feature/x and refs/heads/master handling' })); + + expect(branch.split('/')).toHaveLength(3); + expect(branch).toBe('agent/chore/MAPCO-10-fix-feature-x-and-refs-heads-master-handling'); + + expectRefSafe(branch); + }); + + it('should stay bounded for a summary nobody bounded.', () => { + const summary = `some-service: ${'refactor the tile aggregation pipeline so that every layer is re-projected exactly once '.repeat(5)}`; + + const branch = branchName(ticket({ key: 'MAPCO-11', summary })); + + expect(branch.length).toBeLessThanOrEqual(HEADER_LIMIT); + + expectRefSafe(branch); + }); + + it('should refuse to build a malformed ref out of a malformed key.', () => { + const branch = branchName(ticket({ key: ' ', summary: 'some-service: do the thing' })); + + expect(branch).toBe('agent/chore/no-key-do-the-thing'); + + expectRefSafe(branch); + }); + + it('should not carry the casing of a summary that repeats the repo name.', () => { + const branch = branchName(ticket({ key: 'MAPCO-12', summary: 'LLM-Configuration: Tidy The Prompts' })); + + expect(branch).toBe('agent/chore/MAPCO-12-tidy-the-prompts'); + }); +}); + +describe('commitTitle', () => { + it('should be a conventional commit using the real type and referencing the key.', () => { + const title = commitTitle( + ticket({ key: 'MAPCO-11436', issueType: 'Feature', summary: 'developer-agent-bot: Worker builds the branch, commit and PR in code' }) + ); + + expect(title).toBe('feat: worker builds the branch, commit and PR in code (MAPCO-11436)'); + + expectCommitlintClean(title); + }); + + it('should keep the key out of the head of the subject, which commitlint rejects.', () => { + // The defect this replaces: `chore: MAPCO-4 stop the loop` fails `subject-case`, because an + // upper-case first character makes `upperFirst(subject) === subject` true. That was every + // ticket, of every issue type — with the org's `commit-msg` hook installed in the clone, + // nothing was committed, pushed, opened or commented at all. + const title = commitTitle(ticket({ key: 'MAPCO-4', summary: 'x: stop the loop' })); + + expect(title).toBe('chore: stop the loop (MAPCO-4)'); + expect(title).not.toMatch(/^chore: MAPCO/u); + + expectCommitlintClean(title); + }); + + it('should not flatten a feature to chore, because a merged feat is meant to cut a release.', () => { + expect(commitTitle(ticket({ issueType: 'Story', summary: 'x: add a thing' }))).toMatch(/^feat: /u); + expect(commitTitle(ticket({ issueType: 'Bug', summary: 'x: stop the thing' }))).toMatch(/^fix: /u); + }); + + it('should never write bug as a type, whatever the branch is called.', () => { + const title = commitTitle(ticket({ key: 'MAPCO-2', issueType: 'Bug', summary: 'raster-shared: retry loop spins' })); + + expect(title).toBe('fix: retry loop spins (MAPCO-2)'); + expect(title).not.toMatch(/^bug:/u); + + expectCommitlintClean(title); + }); + + it('should drop a trailing full stop, which commitlint rejects.', () => { + expect(commitTitle(ticket({ key: 'MAPCO-4', summary: 'x: stop the retry loop spinning.' }))).toBe( + 'chore: stop the retry loop spinning (MAPCO-4)' + ); + }); + + it('should lower-case the first word, and an acronym as a whole word rather than to sLD.', () => { + // `chore: SLD parsing drops a rule` is rejected by the real linter too, so the acronym + // cannot simply be left alone; lowering it character by character would read as `sLD`. + expect(commitTitle(ticket({ key: 'MAPCO-5', summary: 'x: Stop the loop' }))).toBe('chore: stop the loop (MAPCO-5)'); + expect(commitTitle(ticket({ key: 'MAPCO-6', summary: 'x: SLD parsing drops a rule' }))).toBe('chore: sld parsing drops a rule (MAPCO-6)'); + }); + + it('should bound the header and cut it at a word boundary, keeping the key.', () => { + const title = commitTitle( + ticket({ key: 'MAPCO-7', summary: 'x: refactor the tile aggregation pipeline so every layer is re-projected exactly once' }) + ); + + expect(title).toBe('chore: refactor the tile aggregation pipeline so every layer (MAPCO-7)'); + + expectCommitlintClean(title); + }); + + it('should still say something when the summary has nothing left in it.', () => { + // An empty subject is `subject-empty`, which is an error. A sentence is not much, but it is + // a header the hook takes and a changelog line that parses. + const title = commitTitle(ticket({ key: 'MAPCO-8', summary: 'x: ...' })); + + expect(title).toBe('chore: apply the change described on the ticket (MAPCO-8)'); + + expectCommitlintClean(title); + }); + + it('should collapse a summary that spans several lines onto one subject.', () => { + expect(commitTitle(ticket({ key: 'MAPCO-13', summary: 'x: stop the loop\nand the other one' }))).toBe( + 'chore: stop the loop and the other one (MAPCO-13)' + ); + }); + + it('should stay a valid header for every issue type and every hostile summary.', () => { + // The same cross-product was run through the real `@map-colonies/commitlint-config` + // programmatically and rejected none of 264 headers. This is that check without the linter. + const summaries = [ + 'x: SLD parsing drops a rule', + 'x: !!! ???', + 'x: fix @alice retry helper', + 'x: HEAD', + 'x: École de cartographie', + 'x: תקן את הלופ', + 'x: 3d tiles support', + 'no prefix here at all', + 'x: --force the thing', + 'x: `backticks` and **stars**', + 'x: 🚀 ship it', + 'x: ABC DEF', + `x: ${'reproject every layer exactly once '.repeat(9)}`, + ]; + + const issueTypes = ['Bug', 'Feature', 'Story', 'Task', 'Epic', 'Spike']; + const headers = issueTypes.flatMap((issueType) => summaries.map((summary) => commitTitle(ticket({ key: 'MAPCO-11436', issueType, summary })))); + + expect(headers).toHaveLength(issueTypes.length * summaries.length); + + for (const header of headers) { + expectCommitlintClean(header); + } + }); +}); + +describe('ticketUrl', () => { + it('should point at the issue a human reads.', () => { + expect(ticketUrl('MAPCO-11436')).toBe('https://mapcolonies.atlassian.net/browse/MAPCO-11436'); + }); + + it('should not double the slash when the base carries one.', () => { + expect(ticketUrl('MAPCO-1', 'https://example.atlassian.net/browse/')).toBe('https://example.atlassian.net/browse/MAPCO-1'); + }); +}); + +describe('commitMessage', () => { + it('should lead with the conventional title and link the ticket in the body.', () => { + const message = commitMessage(ticket({ key: 'MAPCO-11436', issueType: 'Bug', summary: 'x: stop the loop' })); + const [subject, blank] = message.split('\n'); + + expect(subject).toBe('fix: stop the loop (MAPCO-11436)'); + expect(blank).toBe(''); + expect(message).toContain('Ticket: https://mapcolonies.atlassian.net/browse/MAPCO-11436'); + }); +}); diff --git a/tests/unit/vcs/paths.spec.ts b/tests/unit/vcs/paths.spec.ts new file mode 100644 index 0000000..64d9f20 --- /dev/null +++ b/tests/unit/vcs/paths.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { toRepoRelative } from '@src/vcs/paths'; + +const root = '/workspace/clone'; + +describe('toRepoRelative', () => { + it('should turn the absolute path a file tool reports into the one git prints.', () => { + // The whole reason this function exists: the Agent SDK's `Edit` and `Write` tools carry the + // absolute `file_path` they were given, `git status --porcelain` prints repo-relative paths, + // and a publish path that compares the two as strings stages nothing on every ticket. + expect(toRepoRelative('/workspace/clone/src/tiles.ts', root)).toBe('src/tiles.ts'); + }); + + it('should leave a path that is already repo-relative alone.', () => { + expect(toRepoRelative('src/tiles.ts', root)).toBe('src/tiles.ts'); + }); + + it('should normalise the noise a tool call can carry.', () => { + expect(toRepoRelative('./src/tiles.ts', root)).toBe('src/tiles.ts'); + expect(toRepoRelative('/workspace/clone//src/./tiles.ts', root)).toBe('src/tiles.ts'); + expect(toRepoRelative('src/nested/../tiles.ts', root)).toBe('src/tiles.ts'); + }); + + it('should refuse a path that resolves outside the checkout.', () => { + // A refusal and not a throw: the caller names it in a log line rather than losing the ticket + // over the model having mentioned a file somewhere else. + expect(toRepoRelative('../elsewhere/thing.ts', root)).toBeNull(); + expect(toRepoRelative('/etc/passwd', root)).toBeNull(); + expect(toRepoRelative('src/../../escape.ts', root)).toBeNull(); + expect(toRepoRelative('/workspace/clone-2/src/tiles.ts', root)).toBeNull(); + }); + + it('should refuse the checkout itself and a path that is only whitespace.', () => { + expect(toRepoRelative(root, root)).toBeNull(); + expect(toRepoRelative('.', root)).toBeNull(); + expect(toRepoRelative(' ', root)).toBeNull(); + expect(toRepoRelative('', root)).toBeNull(); + }); + + it('should keep a filename that merely starts with dots.', () => { + // `..gitkeep` relativises to `..gitkeep`, and a naive `startsWith('..')` would read that as + // an escape and silently drop a file the agent really did write. + expect(toRepoRelative('/workspace/clone/src/..gitkeep', root)).toBe('src/..gitkeep'); + expect(toRepoRelative('.github/workflows/ci.yaml', root)).toBe('.github/workflows/ci.yaml'); + }); +}); diff --git a/tests/unit/vcs/scratchRepo.spec.ts b/tests/unit/vcs/scratchRepo.spec.ts new file mode 100644 index 0000000..38e98be --- /dev/null +++ b/tests/unit/vcs/scratchRepo.spec.ts @@ -0,0 +1,314 @@ +import { execFile } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { afterAll, describe, expect, it } from 'vitest'; +import type { Repo } from '@src/github/types'; +import { CliGit, CREDENTIAL_FROM_ENV, GitGuardError, TOKEN_ENV } from '@src/vcs/cliGit'; +import type { TokenProvider } from '@src/vcs/types'; + +/** + * The acceptance criterion "pushing to master, merging and approving are attempted in a scratch + * repo and observed to fail", run rather than argued. + * + * Everything here is real: a real `git init --bare` remote, a real working tree, real hooks, the + * real `CliGit` talking to the real `git` binary. Nothing is mocked, because the whole point of + * the criterion is that the guards hold against git itself and not against a fake that agrees + * with them. It also caught two defects a mocked spec could not: the clone's own `commit-msg` + * hook killing every commit, and `git add --all` sweeping tool output into the diff. + * + * It lives under `tests/unit` deliberately. The `integration` project is the `runCycle` seam, + * and this depends on nothing outside this slice and no network — only on `git` being on PATH, + * which is a hard requirement of the slice anyway. + */ + +const run = promisify(execFile); + +const identity = { name: 'mapcolonies-developer-agent[bot]', email: '1234+mapcolonies-developer-agent[bot]@users.noreply.github.com' }; + +/** Every hook a target repo could plausibly have installed, each one refusing everything. */ +const HOSTILE_HOOKS = ['pre-commit', 'commit-msg', 'prepare-commit-msg', 'pre-push']; + +const roots: string[] = []; + +interface Scratch { + /** The working tree, as the verify slice would have left it. */ + readonly work: string; + /** A bare repository standing in for GitHub. */ + readonly remote: string; + readonly repo: Repo; + readonly tokens: TokenProvider & { readonly minted: string[] }; + readonly git: CliGit; +} + +async function git(args: readonly string[], cwd: string): Promise { + const { stdout } = await run('git', [...args], { cwd }); + + return stdout; +} + +/** Commit in the setup as a human would, with an identity but without the worker's guards. */ +async function humanCommit(work: string, message: string): Promise { + await git(['-c', 'user.name=Human', '-c', 'user.email=human@example.com', '-c', 'commit.gpgsign=false', 'commit', '--message', message], work); +} + +function fakeTokens(): TokenProvider & { readonly minted: string[] } { + const minted: string[] = []; + + return { + minted, + mint: async (): Promise => { + const token = `ghs-token-${minted.length + 1}`; + minted.push(token); + + return Promise.resolve(token); + }, + }; +} + +/** + * A fresh repository pair per test, so no test depends on what another one pushed. + * + * The hooks are installed the way the verify slice installs them for us by accident: `npm ci` in + * the clone runs `prepare`, which runs husky, which puts the target repo's own `pre-commit` and + * `commit-msg` in the checkout. Here every one of them exits 1, which is the worst case a real + * repo can present, and the publish path has to survive it. + * + * `prepare-commit-msg` is in the list because of what this spec found: `git commit --no-verify`, + * the first fix attempted for the hook problem, bypasses `pre-commit` and `commit-msg` only, and + * this test failed until `CliGit` stopped relying on it and pointed `core.hooksPath` at nothing. + */ +async function scratch(): Promise { + const root = await mkdtemp(join(tmpdir(), 'agent-scratch-')); + roots.push(root); + + const work = join(root, 'work'); + const remote = join(root, 'remote.git'); + const hooks = join(root, 'hooks'); + + await git(['init', '--bare', '--initial-branch=master', remote], root); + await git(['init', '--initial-branch=master', work], root); + + await writeFile(join(work, 'README.md'), '# scratch\n'); + await mkdir(join(work, 'src')); + await writeFile(join(work, 'src', 'tiles.ts'), 'export const tiles = 1;\n'); + await git(['add', '--all'], work); + await humanCommit(work, 'chore: initial'); + await git(['push', remote, 'refs/heads/master:refs/heads/master'], work); + + await mkdir(hooks); + for (const hook of HOSTILE_HOOKS) { + const path = join(hooks, hook); + + await writeFile(path, '#!/bin/sh\necho "this repo refuses machine commits" >&2\nexit 1\n'); + await chmod(path, 0o755); + } + // Absolute, so nothing in the caller's global git config can move it. + await git(['config', 'core.hooksPath', hooks], work); + + const repo: Repo = { + name: 'scratch', + fullName: 'MapColonies/scratch', + defaultBranch: 'master', + cloneUrl: pathToFileURL(remote).href, + }; + const tokens = fakeTokens(); + + return { work, remote, repo, tokens, git: new CliGit({ cwd: work, repo, identity, tokens }) }; +} + +/** + * `git credential fill`, driven exactly as `CliGit.push` drives its credential helper. + * + * `execFile` rather than the promisified form, because the request goes in on stdin and the + * promisified wrapper hands back no child to write to. + */ +async function credentialFill(cwd: string, env: NodeJS.ProcessEnv, request: string): Promise<{ stdout: string; argv: string[] }> { + const argv = [...CREDENTIAL_FROM_ENV, 'credential', 'fill']; + + return new Promise<{ stdout: string; argv: string[] }>((resolve, reject) => { + const child = execFile('git', argv, { cwd, env }, (err, stdout) => { + if (err) { + reject(err instanceof Error ? err : new Error('git credential fill failed')); + + return; + } + + resolve({ stdout, argv }); + }); + + child.stdin?.end(request); + }); +} + +/** The sha a ref points at in the bare remote, or null when the remote has no such ref. */ +async function remoteSha(remote: string, ref: string): Promise { + const stdout = await git(['ls-remote', remote, ref], remote); + const [line] = stdout.split('\n'); + + return line === undefined || line.trim() === '' ? null : (line.split('\t')[0] ?? null); +} + +describe('the worker against a real repository', () => { + afterAll(async () => { + await Promise.all(roots.map(async (root) => rm(root, { recursive: true, force: true }))); + }); + + it('should refuse every attempt to write the default branch, before git or a token is involved.', async () => { + const { git: subject, remote, tokens } = await scratch(); + const before = await remoteSha(remote, 'refs/heads/master'); + + // Every spelling of "put this on master" the caller could reach for. + await expect(subject.push('master')).rejects.toThrow(GitGuardError); + await expect(subject.push('refs/heads/master')).rejects.toThrow(GitGuardError); + await expect(subject.push('agent/../master')).rejects.toThrow(GitGuardError); + await expect(subject.push('agent/feat/x/../../../master')).rejects.toThrow(GitGuardError); + await expect(subject.push('HEAD')).rejects.toThrow(GitGuardError); + await expect(subject.push('--mirror')).rejects.toThrow(GitGuardError); + await expect(subject.createBranch('master')).rejects.toThrow(GitGuardError); + + // Refused in code, so no credential was ever minted for any of it. + expect(tokens.minted).toStrictEqual([]); + await expect(remoteSha(remote, 'refs/heads/master')).resolves.toBe(before); + }); + + it('should fail every attempt to merge, approve, force or delete, and leave master where it was.', async () => { + // The other half of the criterion, attempted rather than described. Each call is made + // through a loose record so that asking `CliGit` to merge is a runtime question instead of a + // compile error — a capability that does not exist can only be "observed to fail" if + // something actually tries to use it. + const { git: subject, work, remote } = await scratch(); + const masterBefore = await remoteSha(remote, 'refs/heads/master'); + const loose = subject as unknown as Record unknown) | undefined>; + + for (const act of ['merge', 'rebase', 'approve', 'review', 'forcePush', 'deleteBranch', 'reset', 'tag']) { + expect(loose[act]).toBeUndefined(); + expect(() => (loose[act] as (...args: unknown[]) => unknown)('master')).toThrow(TypeError); + } + + // The surface is the whole of it: four members, none of which is any of the above, so a + // later slice cannot reach for one by accident either. + const surface = Object.getOwnPropertyNames(Object.getPrototypeOf(subject) as object).filter((name) => name !== 'constructor'); + + expect([...surface].sort()).toStrictEqual(['changedFiles', 'commit', 'createBranch', 'push']); + + // And the only route to master the surface does have refuses, so nothing moved: not the + // remote's master, and not the local checkout either. + await expect(subject.push('master')).rejects.toThrow(GitGuardError); + await expect(remoteSha(remote, 'refs/heads/master')).resolves.toBe(masterBefore); + await expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], work)).resolves.toBe('master\n'); + }); + + it('should commit a filename that looks like a glob, because git stages it without complaint.', async () => { + // `app/[id]/page.tsx` is an ordinary file in a Next.js-shaped repository, and the first + // version of this slice refused to commit any path containing `[`, `]`, `*`, `?` or a + // backslash — a ticket on such a repo could never be published, and it burned an attempt + // against the MAPCO-11432 cap on the way. Run against the real git binary, because the + // question was always what git does rather than what the guard believes. + const { git: subject, work } = await scratch(); + const globbish = 'app/[id]/page.tsx'; + + await mkdir(join(work, 'app', '[id]'), { recursive: true }); + await writeFile(join(work, globbish), 'export default function Page() {}\n'); + await subject.createBranch('agent/feat/MAPCO-1-render-the-page'); + + const sha = await subject.commit('feat: render the page (MAPCO-1)', [globbish]); + + await expect(git(['show', '--pretty=format:', '--name-only', sha], work)).resolves.toBe(`${globbish}\n`); + await expect(subject.changedFiles()).resolves.toStrictEqual([]); + }); + + it('should commit and push the agent branch even when every hook in the clone refuses.', async () => { + const { git: subject, work, remote, tokens } = await scratch(); + const branch = 'agent/chore/MAPCO-11436-worker-builds-the-branch'; + const masterBefore = await remoteSha(remote, 'refs/heads/master'); + + // What the verify slice leaves behind: the model's edit, plus the `package-lock.json` that + // `npm install` writes into a repo which does not commit one and does not gitignore it. + await writeFile(join(work, 'src', 'tiles.ts'), 'export const tiles = 2;\n'); + await writeFile(join(work, 'package-lock.json'), '{ "lockfileVersion": 3 }\n'); + // A new file in a directory that did not exist before, which porcelain collapses to `sld/` + // unless it is asked not to. + await mkdir(join(work, 'sld')); + await writeFile(join(work, 'sld', 'parse.ts'), 'export const parse = 1;\n'); + + const changed = await subject.changedFiles(); + + expect(changed).toContain('src/tiles.ts'); + expect(changed).toContain('sld/parse.ts'); + expect(changed).toContain('package-lock.json'); + + await subject.createBranch(branch); + const sha = await subject.commit('chore: worker builds the branch (MAPCO-11436)', ['src/tiles.ts', 'sld/parse.ts']); + await subject.push(branch); + + // The commit exists, is authored by the App, and contains only what the agent wrote — the + // generated lockfile is still sitting in the working tree, uncommitted and unpushed. + expect(sha).toMatch(/^[0-9a-f]{40}$/u); + await expect(git(['show', '--pretty=format:', '--name-only', sha], work)).resolves.toBe(`sld/parse.ts\nsrc/tiles.ts\n`); + await expect(subject.changedFiles()).resolves.toStrictEqual(['package-lock.json']); + await expect(git(['log', '-1', '--format=%an <%ae>'], work)).resolves.toBe(`${identity.name} <${identity.email}>\n`); + + // The branch arrived on the remote, under `agent/`, and master did not move. + await expect(remoteSha(remote, `refs/heads/${branch}`)).resolves.toBe(sha); + await expect(remoteSha(remote, 'refs/heads/master')).resolves.toBe(masterBefore); + expect(tokens.minted).toStrictEqual(['ghs-token-1']); + }); + + it('should leave the identity out of the checkout so the next ticket cannot inherit it.', async () => { + const { git: subject, work } = await scratch(); + + await writeFile(join(work, 'src', 'tiles.ts'), 'export const tiles = 3;\n'); + await subject.createBranch('agent/chore/MAPCO-1-do-the-thing'); + await subject.commit('chore: do the thing (MAPCO-1)', ['src/tiles.ts']); + + // `git config user.name` in a container is shared by every ticket that container handles. + await expect(git(['config', '--local', '--get-regexp', '^user\\.'], work).catch(() => 'unset')).resolves.toBe('unset'); + }); + + it('should give the real git the minted token through the environment and never in its argv.', async () => { + // The token used to be interpolated into the push URL, where `/proc//cmdline` makes it + // readable by every process on the host — including a same-user one the model can arrange + // through the clone's own test script. This is the replacement, run against the real git: + // the helper in the argv names a variable, git executes it, and the value comes back out of + // the environment. Asserting the string alone would prove nothing about what `sh -c` does + // with it. + const { work } = await scratch(); + const token = 'ghs-token-under-test'; + + const { stdout, argv } = await credentialFill( + work, + /* eslint-disable-next-line @typescript-eslint/naming-convention -- git's own environment variable */ + { ...process.env, [TOKEN_ENV]: token, GIT_TERMINAL_PROMPT: '0' }, + 'protocol=https\nhost=github.com\n\n' + ); + + expect(stdout).toContain('username=x-access-token'); + expect(stdout).toContain(`password=${token}`); + expect(argv.some((arg) => arg.includes(token))).toBe(false); + }); + + it('should report the checkout it works in, so an absolute path can be made relative to it.', async () => { + // The publish path matches the agent's absolute `file_path`s against git's repo-relative + // output, and the root is the only thing that can turn one into the other. + const { git: subject, work } = await scratch(); + + expect(subject.root).toBe(work); + }); + + it('should refuse a path that tries to climb out of the checkout.', async () => { + const { git: subject, work } = await scratch(); + + await writeFile(join(work, 'src', 'tiles.ts'), 'export const tiles = 4;\n'); + await subject.createBranch('agent/chore/MAPCO-1-do-the-thing'); + + await expect(subject.commit('chore: x (MAPCO-1)', ['../outside.ts'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', ['.git/config'])).rejects.toThrow(GitGuardError); + await expect(subject.commit('chore: x (MAPCO-1)', [])).rejects.toThrow(GitGuardError); + + // Nothing was staged by the attempts, so the tree is still exactly as the agent left it. + await expect(git(['diff', '--cached', '--name-only'], work)).resolves.toBe(''); + }); +});