diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping-cli.test.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping-cli.test.ts index 70bb8afd..565fe5fe 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping-cli.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping-cli.test.ts @@ -40,25 +40,12 @@ function fixture( writeFileSync( gh, `#!${process.execPath} -import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { fakeGitHub } from ${JSON.stringify(join(import.meta.dir, "shipping.test-helper.ts"))}; const state = JSON.parse(readFileSync(process.env.SHIPPING_STATE, 'utf8')); -const args = process.argv.slice(2); -const query = args.find(arg => arg.startsWith('query=')); -let result; -if (query.includes('disablePullRequestAutoMerge')) { - state.autoMergeRequest = null; - result = { disablePullRequestAutoMerge: { clientMutationId: null } }; -} else if (query.includes('dequeuePullRequest')) { - if (!query.includes('dequeuePullRequest(input:{id:$id})')) { - console.error("DequeuePullRequestInput requires id"); - process.exit(1); - } - state.mergeQueueEntry = null; - result = { dequeuePullRequest: { clientMutationId: null } }; -} else result = { repository: { pullRequest: state } }; -appendFileSync(process.env.SHIPPING_STATE + '.calls', query + '\\n'); +const response = fakeGitHub(state, process.argv.slice(2)); writeFileSync(process.env.SHIPPING_STATE, JSON.stringify(state)); -console.log(JSON.stringify({ data: result })); +console.log(JSON.stringify(response)); ` ); chmodSync(gh, 0o755); diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test-helper.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test-helper.ts new file mode 100644 index 00000000..c65ca787 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test-helper.ts @@ -0,0 +1,48 @@ +// The slice of GitHub's GraphQL schema the shipping service depends on: the +// one required input field of each mutation, and the pull request field the +// mutation clears. Regenerate the input names with +// gh api graphql -f query='{ __type(name:"DequeuePullRequestInput") { inputFields { name } } }' +const MUTATIONS: Record = { + disablePullRequestAutoMerge: { + input: "pullRequestId", + clears: "autoMergeRequest", + }, + dequeuePullRequest: { input: "id", clears: "mergeQueueEntry" }, +}; + +const MUTATION = + /mutation\s*\w*\s*\(\s*\$id\s*:\s*ID!\s*\)\s*\{\s*(\w+)\(\s*input\s*:\s*\{\s*(\w+)\s*:\s*\$id\s*\}\s*\)/; + +/** + * Answer one `gh api graphql` call the way GitHub would. + * + * A query returns only the pull request fields it selects. A mutation is + * refused unless it declares `$id` as `ID!`, names a known mutation and its + * required input field, and carries this pull request's node id. + */ +export function fakeGitHub( + pullRequest: Record, + argv: readonly string[] +): { data: Record } { + const query = argv.find((arg) => arg.startsWith("query=")) ?? ""; + if (!query.includes("mutation")) { + // Drop nested selections, so `mergeQueueEntry { id }` does not count as + // selecting the pull request's own `id`. + const selection = query.replace(/\{[^{}]*\}/g, ""); + const selected = Object.entries(pullRequest).filter(([field]) => + new RegExp(`\\b${field}\\b`).test(selection) + ); + return { + data: { repository: { pullRequest: Object.fromEntries(selected) } }, + }; + } + const [, name = "", input = ""] = MUTATION.exec(query) ?? []; + const contract = MUTATIONS[name]; + if (!contract) throw new Error(`not a mutation GitHub accepts: ${query}`); + if (input !== contract.input) + throw new Error(`${name} input requires ${contract.input}, got ${input}`); + if (!argv.includes(`id=${pullRequest.id}`)) + throw new Error("Could not resolve to a node with the given id"); + pullRequest[contract.clears] = null; + return { data: { [name]: { clientMutationId: null } } }; +} diff --git a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test.ts b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test.ts index 6d698335..cd36f6a0 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/watch-pr/shipping.test.ts @@ -8,6 +8,7 @@ import { type LandingRecord, type ShippingService, } from "./shipping.ts"; +import { fakeGitHub } from "./shipping.test-helper.ts"; const context = { owner: "owner", repo: "repo", number: parsePrNumber(1) }; const empty: LandingRecord = { @@ -227,25 +228,14 @@ describe("shipping GitHub boundary", () => { }); it("uses separate mutations for both pending mechanisms", async () => { - let autoMergeRequest: { enabledAt: string } | null = { enabledAt: "now" }; - let mergeQueueEntry: { id: string } | null = { id: "queue" }; - const service = new GhShippingService(async (args) => { - const query = args.find((arg) => arg.startsWith("query=")) ?? ""; - if (query.includes("disablePullRequestAutoMerge")) { - expect(args).toContain("id=pr-id"); - autoMergeRequest = null; - return { - data: { disablePullRequestAutoMerge: { clientMutationId: null } }, - }; - } - if (query.includes("dequeuePullRequest")) { - expect(query).toContain("dequeuePullRequest(input:{id:$id})"); - expect(args).toContain("id=pr-id"); - mergeQueueEntry = null; - return { data: { dequeuePullRequest: { clientMutationId: null } } }; - } - return response({ ...raw, autoMergeRequest, mergeQueueEntry }); - }); + const pullRequest = { + ...raw, + autoMergeRequest: { enabledAt: "now" }, + mergeQueueEntry: { id: "queue" }, + }; + const service = new GhShippingService(async (args) => + fakeGitHub(pullRequest, args) + ); const expected = await service.inspect(context); expect(await cancelPending(service, expected)).toEqual({ kind: "cancelled",