@defrex/autobuild 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHubForge (SPEC §3.2): the Forge adapter for GitHub, speaking the GitHub
|
|
3
|
+
* REST API through an injectable transport seam. All remote access is
|
|
4
|
+
* kernel-side plumbing (SPEC §8.6 [D7]) — agents never touch the remote, so
|
|
5
|
+
* forge credentials never enter the sandbox.
|
|
6
|
+
*
|
|
7
|
+
* The adapter never routes API calls through `gh` or reads a local checkout
|
|
8
|
+
* for them: the same adapter serves a checkout-bound dispatcher and a
|
|
9
|
+
* checkout-less (`--repository`) one. Credentials come from `GITHUB_TOKEN` /
|
|
10
|
+
* `GH_TOKEN`, falling back to the gh CLI's stored login (`gh auth token`) so
|
|
11
|
+
* a local operator needs only `gh auth login`. The one deliberate subprocess
|
|
12
|
+
* exception on the API side is that probe; `pushBranch` remains `git push`
|
|
13
|
+
* from the workspace — it is only ever called from worktree-side terminals
|
|
14
|
+
* and contract fixtures; `vercel-sandbox` builds publish through the
|
|
15
|
+
* sandbox's receive-pack proxy and the host never pushes.
|
|
16
|
+
*
|
|
17
|
+
* Native auto-merge is the one operation GitHub exposes only through its
|
|
18
|
+
* GraphQL schema (no REST route exists), so its enable/disable mutations
|
|
19
|
+
* travel over the same transport to `POST /graphql`.
|
|
20
|
+
*
|
|
21
|
+
* Repository coordinates (owner/name) resolve lazily and memoized: explicit
|
|
22
|
+
* constructor option → `AB_REPOSITORY` (normalized) → `git remote get-url
|
|
23
|
+
* origin` in `repoRoot` (checkout mode) → hard error at first use. In origin
|
|
24
|
+
* mode the dispatcher exports `AB_REPOSITORY`, so no git call ever runs.
|
|
25
|
+
*/
|
|
26
|
+
import { z } from 'zod'
|
|
27
|
+
import { isValidGitBranchName, normalizeGitRemoteUrl } from '../../kernel/origin'
|
|
28
|
+
import type { Exec } from '../workspace/git-worktree'
|
|
29
|
+
import {
|
|
30
|
+
GitHubApiError,
|
|
31
|
+
createGitHubFetchTransport,
|
|
32
|
+
githubTokenSource,
|
|
33
|
+
restPlanLimitation,
|
|
34
|
+
type GitHubRequest,
|
|
35
|
+
type GitHubTokenSource,
|
|
36
|
+
type GitHubRequestOpts,
|
|
37
|
+
type GitHubResponse,
|
|
38
|
+
} from './github-transport'
|
|
39
|
+
import {
|
|
40
|
+
classifyAutoMergeEnable,
|
|
41
|
+
mergeStateStatuses,
|
|
42
|
+
type MergeGatePresence,
|
|
43
|
+
type MergeStateStatus,
|
|
44
|
+
} from '../../kernel/auto-merge'
|
|
45
|
+
import type {
|
|
46
|
+
AutoMergeDeferralReason,
|
|
47
|
+
AutoMergeResult,
|
|
48
|
+
ClosePrResult,
|
|
49
|
+
PrAttachmentHosting,
|
|
50
|
+
Forge,
|
|
51
|
+
PrRef,
|
|
52
|
+
PrState,
|
|
53
|
+
} from '../types'
|
|
54
|
+
import { GitHubPrAttachmentHosting } from './github-pr-attachments'
|
|
55
|
+
|
|
56
|
+
/** One subprocess seam for every forge command: argv array, optional cwd,
|
|
57
|
+
* no shell. Git commands name their cwd; the gh credential probe needs none.
|
|
58
|
+
* The contract is the workspace module's, re-exported so the forge and its
|
|
59
|
+
* callers name one type. */
|
|
60
|
+
export type { Exec, ExecResult } from '../workspace/git-worktree'
|
|
61
|
+
|
|
62
|
+
export const bunExec: Exec = async (cmd, opts) => {
|
|
63
|
+
if (opts.signal?.aborted === true) {
|
|
64
|
+
throw new Error(`forge command aborted before launch: ${cmd.join(' ')}`)
|
|
65
|
+
}
|
|
66
|
+
const proc = Bun.spawn(cmd, {
|
|
67
|
+
cwd: opts.cwd,
|
|
68
|
+
stdout: 'pipe',
|
|
69
|
+
stderr: 'pipe',
|
|
70
|
+
stdin: 'ignore',
|
|
71
|
+
})
|
|
72
|
+
const abort = (): void => {
|
|
73
|
+
try {
|
|
74
|
+
proc.kill('SIGKILL')
|
|
75
|
+
} catch {
|
|
76
|
+
// A process that exited concurrently is already cancelled sufficiently.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
opts.signal?.addEventListener('abort', abort, { once: true })
|
|
80
|
+
try {
|
|
81
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
82
|
+
new Response(proc.stdout).text(),
|
|
83
|
+
new Response(proc.stderr).text(),
|
|
84
|
+
proc.exited,
|
|
85
|
+
])
|
|
86
|
+
return { stdout, stderr, exitCode }
|
|
87
|
+
} finally {
|
|
88
|
+
opts.signal?.removeEventListener('abort', abort)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Repository coordinates ───────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface RepoCoordinates {
|
|
95
|
+
owner: string
|
|
96
|
+
name: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Owner/name from an `owner/name` slug or any remote URL spelling. */
|
|
100
|
+
export function parseRepoCoordinates(input: string): RepoCoordinates | null {
|
|
101
|
+
const normalized = normalizeGitRemoteUrl(input)
|
|
102
|
+
try {
|
|
103
|
+
const url = new URL(normalized)
|
|
104
|
+
if (url.hostname !== '') {
|
|
105
|
+
const parts = url.pathname.split('/').filter((part) => part !== '')
|
|
106
|
+
if (parts.length === 2) return { owner: parts[0]!, name: parts[1]! }
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Slug fallback below.
|
|
111
|
+
}
|
|
112
|
+
const parts = normalized.split('/')
|
|
113
|
+
if (parts.length === 2 && parts[0] !== '' && parts[1] !== '') {
|
|
114
|
+
return { owner: parts[0]!, name: parts[1]! }
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Response shapes ──────────────────────────────────────────────────────────
|
|
120
|
+
//
|
|
121
|
+
// REST responses are the forge's full objects, so shapes validate every field
|
|
122
|
+
// these methods rely on and pass unknown fields through — an added GitHub
|
|
123
|
+
// field cannot break a poll, but a changed relied-on field still fails loudly.
|
|
124
|
+
|
|
125
|
+
const restPrRef = z
|
|
126
|
+
.object({
|
|
127
|
+
number: z.number().int().positive(),
|
|
128
|
+
html_url: z.string().min(1),
|
|
129
|
+
head: z.object({ sha: z.string().min(1) }).passthrough(),
|
|
130
|
+
})
|
|
131
|
+
.passthrough()
|
|
132
|
+
|
|
133
|
+
const restPrState = z
|
|
134
|
+
.object({
|
|
135
|
+
state: z.enum(['open', 'closed']),
|
|
136
|
+
merged: z.boolean(),
|
|
137
|
+
mergeable: z.boolean().nullable(),
|
|
138
|
+
merge_commit_sha: z.string().min(1).nullable(),
|
|
139
|
+
})
|
|
140
|
+
.passthrough()
|
|
141
|
+
|
|
142
|
+
const restNativeAutoMerge = z
|
|
143
|
+
.object({
|
|
144
|
+
node_id: z.string().min(1),
|
|
145
|
+
auto_merge: z.object({}).passthrough().nullable(),
|
|
146
|
+
})
|
|
147
|
+
.passthrough()
|
|
148
|
+
|
|
149
|
+
const restAutoMergeView = z
|
|
150
|
+
.object({
|
|
151
|
+
node_id: z.string().min(1),
|
|
152
|
+
auto_merge: z.object({}).passthrough().nullable(),
|
|
153
|
+
mergeable_state: z.string().min(1),
|
|
154
|
+
head: z.object({ ref: z.string().min(1), sha: z.string().min(1) }).passthrough(),
|
|
155
|
+
base: z.object({ ref: z.string().min(1) }).passthrough(),
|
|
156
|
+
})
|
|
157
|
+
.passthrough()
|
|
158
|
+
|
|
159
|
+
const restRepositoryAutoMerge = z.object({ allow_auto_merge: z.boolean() }).passthrough()
|
|
160
|
+
|
|
161
|
+
const restBranchHead = z
|
|
162
|
+
.object({ commit: z.object({ sha: z.string().min(1) }).passthrough() })
|
|
163
|
+
.passthrough()
|
|
164
|
+
|
|
165
|
+
const restRulesetRules = z.array(
|
|
166
|
+
z
|
|
167
|
+
.object({
|
|
168
|
+
type: z.string().min(1),
|
|
169
|
+
parameters: z.unknown().optional(),
|
|
170
|
+
})
|
|
171
|
+
.passthrough(),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// ── Ruleset gate classification (unchanged from the gh implementation) ───────
|
|
175
|
+
|
|
176
|
+
const pullRequestRuleParameters = z
|
|
177
|
+
.object({
|
|
178
|
+
required_approving_review_count: z.number().int().nonnegative(),
|
|
179
|
+
require_code_owner_review: z.boolean(),
|
|
180
|
+
require_last_push_approval: z.boolean(),
|
|
181
|
+
required_review_thread_resolution: z.boolean(),
|
|
182
|
+
})
|
|
183
|
+
.passthrough()
|
|
184
|
+
|
|
185
|
+
const requiredStatusRuleParameters = z
|
|
186
|
+
.object({ required_status_checks: z.array(z.unknown()) })
|
|
187
|
+
.passthrough()
|
|
188
|
+
const requiredDeploymentsRuleParameters = z
|
|
189
|
+
.object({ required_deployment_environments: z.array(z.string()) })
|
|
190
|
+
.passthrough()
|
|
191
|
+
const requiredWorkflowsRuleParameters = z.object({ workflows: z.array(z.unknown()) }).passthrough()
|
|
192
|
+
const requiredCodeScanningRuleParameters = z
|
|
193
|
+
.object({
|
|
194
|
+
code_scanning_tools: z.array(z.unknown()).optional(),
|
|
195
|
+
required_code_scanning_tools: z.array(z.unknown()).optional(),
|
|
196
|
+
})
|
|
197
|
+
.refine(
|
|
198
|
+
(value) =>
|
|
199
|
+
value.code_scanning_tools !== undefined || value.required_code_scanning_tools !== undefined,
|
|
200
|
+
{ message: 'a code-scanning tools array is required' },
|
|
201
|
+
)
|
|
202
|
+
.passthrough()
|
|
203
|
+
const mergeQueueRuleParameters = z.object({}).passthrough()
|
|
204
|
+
|
|
205
|
+
const STRUCTURAL_RULE_TYPES = new Set([
|
|
206
|
+
'creation',
|
|
207
|
+
'update',
|
|
208
|
+
'deletion',
|
|
209
|
+
'required_linear_history',
|
|
210
|
+
'non_fast_forward',
|
|
211
|
+
'commit_message_pattern',
|
|
212
|
+
'commit_author_email_pattern',
|
|
213
|
+
'committer_email_pattern',
|
|
214
|
+
'branch_name_pattern',
|
|
215
|
+
'tag_name_pattern',
|
|
216
|
+
'file_path_restriction',
|
|
217
|
+
'max_file_path_length',
|
|
218
|
+
'file_extension_restriction',
|
|
219
|
+
'max_file_size',
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
function parseRuleParameters<S extends z.ZodType>(
|
|
223
|
+
schema: S,
|
|
224
|
+
parameters: unknown,
|
|
225
|
+
type: string,
|
|
226
|
+
): z.infer<S> {
|
|
227
|
+
const parsed = schema.safeParse(parameters)
|
|
228
|
+
if (!parsed.success) {
|
|
229
|
+
throw new Error(`unexpected parameters for active GitHub ruleset rule ${type}: ${parsed.error}`)
|
|
230
|
+
}
|
|
231
|
+
return parsed.data
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether any active repository/organization rule matching one branch
|
|
235
|
+
* carries a real merge-blocking requirement. Unknown future types fail closed. */
|
|
236
|
+
export function rulesetsHaveMergeGate(rules: z.infer<typeof restRulesetRules>): boolean {
|
|
237
|
+
let present = false
|
|
238
|
+
for (const rule of rules) {
|
|
239
|
+
switch (rule.type) {
|
|
240
|
+
case 'merge_queue':
|
|
241
|
+
parseRuleParameters(mergeQueueRuleParameters, rule.parameters, rule.type)
|
|
242
|
+
present = true
|
|
243
|
+
break
|
|
244
|
+
case 'required_signatures':
|
|
245
|
+
present = true
|
|
246
|
+
break
|
|
247
|
+
case 'required_status_checks': {
|
|
248
|
+
const parameters = parseRuleParameters(
|
|
249
|
+
requiredStatusRuleParameters,
|
|
250
|
+
rule.parameters,
|
|
251
|
+
rule.type,
|
|
252
|
+
)
|
|
253
|
+
present = parameters.required_status_checks.length > 0 || present
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
case 'required_deployments': {
|
|
257
|
+
const parameters = parseRuleParameters(
|
|
258
|
+
requiredDeploymentsRuleParameters,
|
|
259
|
+
rule.parameters,
|
|
260
|
+
rule.type,
|
|
261
|
+
)
|
|
262
|
+
present = parameters.required_deployment_environments.length > 0 || present
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
case 'workflows':
|
|
266
|
+
case 'required_workflows': {
|
|
267
|
+
const parameters = parseRuleParameters(
|
|
268
|
+
requiredWorkflowsRuleParameters,
|
|
269
|
+
rule.parameters,
|
|
270
|
+
rule.type,
|
|
271
|
+
)
|
|
272
|
+
present = parameters.workflows.length > 0 || present
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
case 'required_code_scanning':
|
|
276
|
+
case 'code_scanning': {
|
|
277
|
+
const parameters = parseRuleParameters(
|
|
278
|
+
requiredCodeScanningRuleParameters,
|
|
279
|
+
rule.parameters,
|
|
280
|
+
rule.type,
|
|
281
|
+
)
|
|
282
|
+
const tools =
|
|
283
|
+
parameters.code_scanning_tools ?? parameters.required_code_scanning_tools ?? []
|
|
284
|
+
present = tools.length > 0 || present
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
case 'pull_request': {
|
|
288
|
+
const parameters = parseRuleParameters(
|
|
289
|
+
pullRequestRuleParameters,
|
|
290
|
+
rule.parameters,
|
|
291
|
+
rule.type,
|
|
292
|
+
)
|
|
293
|
+
present =
|
|
294
|
+
parameters.required_approving_review_count > 0 ||
|
|
295
|
+
parameters.require_code_owner_review ||
|
|
296
|
+
parameters.require_last_push_approval ||
|
|
297
|
+
parameters.required_review_thread_resolution ||
|
|
298
|
+
present
|
|
299
|
+
break
|
|
300
|
+
}
|
|
301
|
+
default:
|
|
302
|
+
if (!STRUCTURAL_RULE_TYPES.has(rule.type)) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`unknown active GitHub ruleset rule type ${JSON.stringify(rule.type)}; ` +
|
|
305
|
+
'cannot prove the branch has no merge-blocking gate',
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return present
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Native auto-merge mutations (GraphQL) ────────────────────────────────────
|
|
314
|
+
//
|
|
315
|
+
// GitHub's REST API has no auto-merge route: PUT/DELETE
|
|
316
|
+
// /repos/{o}/{r}/pulls/{n}/auto-merge respond 404 (route-not-found) while
|
|
317
|
+
// adjacent PR routes on the same PR respond 401 without auth. `gh` itself
|
|
318
|
+
// drives native auto-merge through the GraphQL mutations below, so the forge
|
|
319
|
+
// does too — same transport seam, same token.
|
|
320
|
+
|
|
321
|
+
const ENABLE_AUTO_MERGE_MUTATION = `
|
|
322
|
+
mutation ($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) {
|
|
323
|
+
enablePullRequestAutoMerge(
|
|
324
|
+
input: {pullRequestId: $pullRequestId, mergeMethod: $mergeMethod}
|
|
325
|
+
) {
|
|
326
|
+
pullRequest { id }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
`
|
|
330
|
+
|
|
331
|
+
const DISABLE_AUTO_MERGE_MUTATION = `
|
|
332
|
+
mutation ($pullRequestId: ID!) {
|
|
333
|
+
disablePullRequestAutoMerge(input: {pullRequestId: $pullRequestId}) {
|
|
334
|
+
pullRequest { id }
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
`
|
|
338
|
+
|
|
339
|
+
/** A successful mutation payload: the mutated PR's node id. A null payload
|
|
340
|
+
* (mutation executed but returned nothing) fails the strict parse. */
|
|
341
|
+
const autoMergeMutationPayload = z.object({
|
|
342
|
+
pullRequest: z.object({ id: z.string().min(1) }),
|
|
343
|
+
})
|
|
344
|
+
const enableAutoMergeData = z.object({ enablePullRequestAutoMerge: autoMergeMutationPayload })
|
|
345
|
+
const disableAutoMergeData = z.object({ disablePullRequestAutoMerge: autoMergeMutationPayload })
|
|
346
|
+
|
|
347
|
+
// ── Classic branch protection (REST) ─────────────────────────────────────────
|
|
348
|
+
//
|
|
349
|
+
// The documented Branch-protection `protection` object (GET
|
|
350
|
+
// /repos/{o}/{r}/branches/{b}). Subsections are present-or-null; a subsection
|
|
351
|
+
// missing entirely from the response is UNPROVEN, not absent — the fail-closed
|
|
352
|
+
// posture the GraphQL probe had via its hard parse errors.
|
|
353
|
+
|
|
354
|
+
type ClassicGateResult = { kind: 'proved'; gate: boolean } | { kind: 'unproven'; detail: string }
|
|
355
|
+
|
|
356
|
+
const requiredStatusChecksKey = 'required_status_checks'
|
|
357
|
+
const reviewsKey = 'required_pull_request_reviews'
|
|
358
|
+
const restrictionsKey = 'restrictions'
|
|
359
|
+
|
|
360
|
+
function classifyClassicProtection(protection: unknown): ClassicGateResult {
|
|
361
|
+
if (protection === null) return { kind: 'proved', gate: false }
|
|
362
|
+
if (typeof protection !== 'object') {
|
|
363
|
+
return { kind: 'unproven', detail: `protection is ${JSON.stringify(protection)}` }
|
|
364
|
+
}
|
|
365
|
+
const raw = protection as Record<string, unknown>
|
|
366
|
+
// An unprotected branch is not rendered as `protection: null`: GitHub
|
|
367
|
+
// answers `{ enabled: false, required_status_checks: { enforcement_level:
|
|
368
|
+
// "off", ... } }` with the other subsections omitted. `enabled: false` is
|
|
369
|
+
// the documented proof that classic protection is absent.
|
|
370
|
+
if (raw.enabled === false) return { kind: 'proved', gate: false }
|
|
371
|
+
const statusChecks = raw.required_status_checks
|
|
372
|
+
const reviews = raw.required_pull_request_reviews
|
|
373
|
+
const restrictions = raw.restrictions
|
|
374
|
+
// Type-validate every subsection that IS present: GitHub renders unset
|
|
375
|
+
// subsections as null, so anything else here is a shape this classifier
|
|
376
|
+
// cannot read. Malformed-but-present never proves presence or absence.
|
|
377
|
+
if (statusChecks !== undefined && statusChecks !== null && typeof statusChecks !== 'object') {
|
|
378
|
+
return { kind: 'unproven', detail: 'required_status_checks is neither null nor an object' }
|
|
379
|
+
}
|
|
380
|
+
if (restrictions !== undefined && restrictions !== null && typeof restrictions !== 'object') {
|
|
381
|
+
return { kind: 'unproven', detail: 'restrictions is neither null nor an object' }
|
|
382
|
+
}
|
|
383
|
+
if (reviews !== undefined && reviews !== null && typeof reviews !== 'object') {
|
|
384
|
+
return {
|
|
385
|
+
kind: 'unproven',
|
|
386
|
+
detail: 'required_pull_request_reviews is neither null nor an object',
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Presence proofs — one subsection that demonstrably carries a requirement
|
|
390
|
+
// settles presence regardless of what the response did with the others.
|
|
391
|
+
if (statusChecks !== undefined && statusChecks !== null) {
|
|
392
|
+
return { kind: 'proved', gate: true }
|
|
393
|
+
}
|
|
394
|
+
if (restrictions !== undefined && restrictions !== null) {
|
|
395
|
+
return { kind: 'proved', gate: true }
|
|
396
|
+
}
|
|
397
|
+
// Absence proofs — to conclude "no gate" every subsection that could carry
|
|
398
|
+
// one must have been rendered; a missing subsection is auth- or plan-scoped
|
|
399
|
+
// in a way we cannot prove, so each names itself precisely.
|
|
400
|
+
if (!(requiredStatusChecksKey in raw)) {
|
|
401
|
+
return {
|
|
402
|
+
kind: 'unproven',
|
|
403
|
+
detail: 'protection.required_status_checks missing from response',
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (!(restrictionsKey in raw)) {
|
|
407
|
+
return { kind: 'unproven', detail: 'protection.restrictions missing from response' }
|
|
408
|
+
}
|
|
409
|
+
if (!(reviewsKey in raw)) {
|
|
410
|
+
return {
|
|
411
|
+
kind: 'unproven',
|
|
412
|
+
detail:
|
|
413
|
+
'protection.required_pull_request_reviews missing from response; ' +
|
|
414
|
+
'a classic reviews requirement could not be ruled out',
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (reviews === null) return { kind: 'proved', gate: false }
|
|
418
|
+
const reviewFields = reviews as Record<string, unknown>
|
|
419
|
+
for (const field of [
|
|
420
|
+
'required_approving_review_count',
|
|
421
|
+
'require_code_owner_reviews',
|
|
422
|
+
'require_last_push_approval',
|
|
423
|
+
] as const) {
|
|
424
|
+
if (!(field in reviewFields)) {
|
|
425
|
+
return { kind: 'unproven', detail: `required_pull_request_reviews.${field} missing` }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const count = reviewFields.required_approving_review_count
|
|
429
|
+
const codeOwners = reviewFields.require_code_owner_reviews
|
|
430
|
+
const lastPush = reviewFields.require_last_push_approval
|
|
431
|
+
if (
|
|
432
|
+
typeof count !== 'number' ||
|
|
433
|
+
typeof codeOwners !== 'boolean' ||
|
|
434
|
+
typeof lastPush !== 'boolean'
|
|
435
|
+
) {
|
|
436
|
+
return {
|
|
437
|
+
kind: 'unproven',
|
|
438
|
+
detail: 'required_pull_request_reviews fields have unexpected types',
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { kind: 'proved', gate: count > 0 || codeOwners || lastPush }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** REST `mergeable_state` → the kernel's canonical GraphQL-spelling enum.
|
|
445
|
+
* The value sets differ in spelling only; unrecognized values throw so the
|
|
446
|
+
* caller's fail-closed handling defers instead of ever mapping to DIRECT. */
|
|
447
|
+
function restMergeState(raw: string): MergeStateStatus {
|
|
448
|
+
const mapped = (
|
|
449
|
+
{
|
|
450
|
+
behind: 'BEHIND',
|
|
451
|
+
blocked: 'BLOCKED',
|
|
452
|
+
clean: 'CLEAN',
|
|
453
|
+
dirty: 'DIRTY',
|
|
454
|
+
draft: 'DRAFT',
|
|
455
|
+
has_hooks: 'HAS_HOOKS',
|
|
456
|
+
unknown: 'UNKNOWN',
|
|
457
|
+
unstable: 'UNSTABLE',
|
|
458
|
+
} as Record<string, MergeStateStatus>
|
|
459
|
+
)[raw]
|
|
460
|
+
if (mapped === undefined) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`unknown GitHub mergeable_state ${JSON.stringify(raw)}; ` +
|
|
463
|
+
`known states: ${mergeStateStatuses.join(', ')}`,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
return mapped
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Forge adapter ────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
|
472
|
+
|
|
473
|
+
/** Hard cap on the janitor poll's per-PR ETag cache. One small entry per PR
|
|
474
|
+
* ever polled in a dispatcher process lifetime makes hitting this in practice
|
|
475
|
+
* implausible; the cap is hygiene, not a load-bearing bound. */
|
|
476
|
+
const PR_STATE_CACHE_CAP = 512
|
|
477
|
+
|
|
478
|
+
export class GitHubForge implements Forge {
|
|
479
|
+
readonly name = 'github'
|
|
480
|
+
readonly prAttachments: PrAttachmentHosting
|
|
481
|
+
|
|
482
|
+
private readonly transport: GitHubRequest
|
|
483
|
+
/** Only `pushBranch` still runs a local command (`git push` from the
|
|
484
|
+
* workspace); every API operation goes through the transport. */
|
|
485
|
+
private readonly exec: Exec
|
|
486
|
+
private readonly explicitRepository?: string
|
|
487
|
+
private readonly repoRoot?: string
|
|
488
|
+
private readonly env: Readonly<Record<string, string | undefined>>
|
|
489
|
+
private coordinates?: RepoCoordinates | null
|
|
490
|
+
/** Per-PR ETag cache for the janitor poll, keyed `owner/name#number`.
|
|
491
|
+
* Conditional revalidation per GitHub's documented best practices: every
|
|
492
|
+
* REST response carries an `ETag`, and re-GETting with `If-None-Match`
|
|
493
|
+
* answers 304 Not Modified — which does not count against the primary rate
|
|
494
|
+
* limit — whenever the representation is unchanged. The cache lives only
|
|
495
|
+
* for this adapter instance (the dispatcher constructs one forge and holds
|
|
496
|
+
* it across janitor ticks), so a process restart is cache loss: exactly
|
|
497
|
+
* today's first-poll behavior. Capped with oldest-inserted eviction; a miss
|
|
498
|
+
* is an ordinary unconditional poll, so eviction is harmless by
|
|
499
|
+
* construction. */
|
|
500
|
+
private readonly prStateCache = new Map<string, { etag: string; state: PrState }>()
|
|
501
|
+
/** Bounded waits between mergeability re-queries on the UNKNOWN path. */
|
|
502
|
+
private readonly mergeabilityRetryDelaysMs: number[]
|
|
503
|
+
|
|
504
|
+
constructor(
|
|
505
|
+
opts: {
|
|
506
|
+
transport?: GitHubRequest
|
|
507
|
+
exec?: Exec
|
|
508
|
+
/** Literal token, or a resolver. Absent: the adapter resolves
|
|
509
|
+
* `GITHUB_TOKEN`, `GH_TOKEN`, then the gh CLI login lazily through its
|
|
510
|
+
* own exec seam. */
|
|
511
|
+
token?: GitHubTokenSource
|
|
512
|
+
repository?: string
|
|
513
|
+
repoRoot?: string
|
|
514
|
+
env?: Readonly<Record<string, string | undefined>>
|
|
515
|
+
/** Bounded waits between mergeability re-queries when GitHub reports
|
|
516
|
+
* `mergeable_state: 'unknown'` (mergeability still being computed).
|
|
517
|
+
* Two retries default; tests inject `[0, 0]` so no test sleeps. */
|
|
518
|
+
mergeabilityRetryDelaysMs?: number[]
|
|
519
|
+
} = {},
|
|
520
|
+
) {
|
|
521
|
+
this.exec = opts.exec ?? bunExec
|
|
522
|
+
this.env = opts.env ?? {}
|
|
523
|
+
// Credential order: explicit token → GITHUB_TOKEN/GH_TOKEN → the gh CLI's
|
|
524
|
+
// stored login, probed lazily through this adapter's exec seam on the
|
|
525
|
+
// first request. A request that still goes out anonymously carries the
|
|
526
|
+
// miss reason in its error, so nothing is probed at construction time.
|
|
527
|
+
this.transport =
|
|
528
|
+
opts.transport ??
|
|
529
|
+
createGitHubFetchTransport({
|
|
530
|
+
token: opts.token !== undefined ? opts.token : githubTokenSource(this.env, this.exec),
|
|
531
|
+
})
|
|
532
|
+
if (opts.repository !== undefined && opts.repository !== '') {
|
|
533
|
+
this.explicitRepository = opts.repository
|
|
534
|
+
} else if (this.env.AB_REPOSITORY !== undefined && this.env.AB_REPOSITORY !== '') {
|
|
535
|
+
this.explicitRepository = this.env.AB_REPOSITORY
|
|
536
|
+
}
|
|
537
|
+
this.repoRoot = opts.repoRoot
|
|
538
|
+
this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? [500, 1500]
|
|
539
|
+
this.prAttachments = new GitHubPrAttachmentHosting({ transport: this.transport })
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Memoized owner/name. `null` means "could not prove coordinates" — the
|
|
543
|
+
* caller decides whether that is fatal (direct calls) or a deferral
|
|
544
|
+
* (auto-merge gate probes). */
|
|
545
|
+
private async repoCoordinates(): Promise<RepoCoordinates | null> {
|
|
546
|
+
this.coordinates ??= await this.resolveCoordinates()
|
|
547
|
+
return this.coordinates
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async resolveCoordinates(): Promise<RepoCoordinates | null> {
|
|
551
|
+
if (this.explicitRepository !== undefined) return parseRepoCoordinates(this.explicitRepository)
|
|
552
|
+
// Worktree/checkout mode only: the last-resort probe reads the checkout's
|
|
553
|
+
// origin. Origin mode always exports AB_REPOSITORY, so this never runs.
|
|
554
|
+
if (this.repoRoot !== undefined) {
|
|
555
|
+
try {
|
|
556
|
+
const result = await this.exec(['git', 'remote', 'get-url', 'origin'], {
|
|
557
|
+
cwd: this.repoRoot,
|
|
558
|
+
signal: undefined,
|
|
559
|
+
})
|
|
560
|
+
if (result.exitCode === 0) {
|
|
561
|
+
const raw = result.stdout.trim()
|
|
562
|
+
if (raw !== '') return parseRepoCoordinates(raw)
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
// Fall through to the error.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return null
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private requireCoordinates(): Promise<RepoCoordinates> {
|
|
572
|
+
return this.repoCoordinates().then((coordinates) => {
|
|
573
|
+
if (coordinates === null) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
'GitHub forge could not resolve the repository owner/name — pass ' +
|
|
576
|
+
"--repository <origin>, export AB_REPOSITORY, or set the checkout's origin remote",
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
return coordinates
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private async request(
|
|
584
|
+
method: string,
|
|
585
|
+
path: string,
|
|
586
|
+
opts?: GitHubRequestOpts,
|
|
587
|
+
): Promise<GitHubResponse> {
|
|
588
|
+
return this.transport(method, path, opts)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private parse<T>(schema: z.ZodType<T>, response: GitHubResponse, operation: string): T {
|
|
592
|
+
const parsed = schema.safeParse(response.json)
|
|
593
|
+
if (!parsed.success) {
|
|
594
|
+
throw new Error(`unexpected GitHub response for ${operation}: ${parsed.error.message}`)
|
|
595
|
+
}
|
|
596
|
+
return parsed.data
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private async getJson<T>(
|
|
600
|
+
schema: z.ZodType<T>,
|
|
601
|
+
path: string,
|
|
602
|
+
operation: string,
|
|
603
|
+
opts?: GitHubRequestOpts,
|
|
604
|
+
): Promise<T> {
|
|
605
|
+
return this.parse(schema, await this.request('GET', path, opts), operation)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** One GraphQL operation over the shared transport: POST the document,
|
|
609
|
+
* validate the envelope, and surface the API-level `errors` array as a
|
|
610
|
+
* thrown error — GitHub answers HTTP 200 even when the mutation failed. */
|
|
611
|
+
private async graphql<T>(
|
|
612
|
+
operation: string,
|
|
613
|
+
schema: z.ZodType<T>,
|
|
614
|
+
query: string,
|
|
615
|
+
variables: Record<string, unknown>,
|
|
616
|
+
): Promise<T> {
|
|
617
|
+
const response = await this.request('POST', 'graphql', { body: { query, variables } })
|
|
618
|
+
const envelope = z
|
|
619
|
+
.object({
|
|
620
|
+
data: z.unknown().optional(),
|
|
621
|
+
errors: z.array(z.object({ message: z.string() }).passthrough()).optional(),
|
|
622
|
+
})
|
|
623
|
+
.safeParse(response.json)
|
|
624
|
+
if (!envelope.success) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`unexpected GitHub GraphQL envelope for ${operation}: ${envelope.error.message}`,
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
const errors = envelope.data.errors
|
|
630
|
+
if (errors !== undefined && errors.length > 0) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`GitHub GraphQL ${operation} failed: ${errors.map((e) => e.message).join('; ')}`,
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
if (envelope.data.data === undefined || envelope.data.data === null) {
|
|
636
|
+
throw new Error(`GitHub GraphQL ${operation} returned no data`)
|
|
637
|
+
}
|
|
638
|
+
const parsed = schema.safeParse(envelope.data.data)
|
|
639
|
+
if (!parsed.success) {
|
|
640
|
+
throw new Error(`unexpected GitHub GraphQL data for ${operation}: ${parsed.error.message}`)
|
|
641
|
+
}
|
|
642
|
+
return parsed.data
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Forge operations ───────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
/** [D1]: rebase is banned and branches are never rewritten — never force.
|
|
648
|
+
* Only ever called from worktree-side terminals and contract fixtures. */
|
|
649
|
+
async pushBranch(workspacePath: string, branch: string): Promise<void> {
|
|
650
|
+
const result = await this.exec(['git', 'push', '-u', 'origin', `HEAD:refs/heads/${branch}`], {
|
|
651
|
+
cwd: workspacePath,
|
|
652
|
+
signal: undefined,
|
|
653
|
+
})
|
|
654
|
+
if (result.exitCode !== 0) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`forge command failed (exit ${result.exitCode}): git push -u origin HEAD:refs/heads/${branch}\n${result.stderr.trim()}`,
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async openPr(opts: {
|
|
662
|
+
workspacePath: string
|
|
663
|
+
head: string
|
|
664
|
+
base: string
|
|
665
|
+
title: string
|
|
666
|
+
body: string
|
|
667
|
+
mergeMessage?: string
|
|
668
|
+
}): Promise<PrRef> {
|
|
669
|
+
// Idempotent by head branch (SPEC §8.7 crash paths): finalize's `ab done`
|
|
670
|
+
// opens the PR BEFORE appending finalize.completed, so a crash or store
|
|
671
|
+
// failure between the two makes the retry call openPr again. Adopting the
|
|
672
|
+
// open PR instead of erroring on the duplicate makes the re-run a
|
|
673
|
+
// harmless retry — the same rationale that makes push-before-event safe
|
|
674
|
+
// for implement.
|
|
675
|
+
const { owner, name } = await this.requireCoordinates()
|
|
676
|
+
const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
|
|
677
|
+
const listed = await this.getJson(
|
|
678
|
+
z.array(restPrRef),
|
|
679
|
+
`${repoPath}/pulls?head=${encodeURIComponent(`${owner}:${opts.head}`)}&state=open`,
|
|
680
|
+
'open PR probe',
|
|
681
|
+
)
|
|
682
|
+
const existing = listed[0]
|
|
683
|
+
if (existing !== undefined) {
|
|
684
|
+
return {
|
|
685
|
+
number: existing.number,
|
|
686
|
+
url: existing.html_url,
|
|
687
|
+
headSha: existing.head.sha,
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const created = this.parse(
|
|
691
|
+
restPrRef,
|
|
692
|
+
await this.request('POST', `${repoPath}/pulls`, {
|
|
693
|
+
body: { title: opts.title, head: opts.head, base: opts.base, body: opts.body },
|
|
694
|
+
}),
|
|
695
|
+
'PR create',
|
|
696
|
+
)
|
|
697
|
+
return { number: created.number, url: created.html_url, headSha: created.head.sha }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Janitor poll (SPEC §15.7): merged / closed / mergeability for one PR.
|
|
701
|
+
* With a cached ETag the read revalidates via `If-None-Match`: a 304 Not
|
|
702
|
+
* Modified returns the cached previous result — the same result the previous
|
|
703
|
+
* poll produced — and a 200 refreshes the cached ETag alongside the parsed
|
|
704
|
+
* state (terminal `merged`/`closed` results included, uniformly). Without a
|
|
705
|
+
* cached ETag (first poll, cache loss) or when the provider omits an `ETag`
|
|
706
|
+
* response header, this is byte-for-byte the unconditional poll: 304
|
|
707
|
+
* handling only ever applies to requests that carry conditional headers. */
|
|
708
|
+
async getPrState(_workspacePath: string, number: number): Promise<PrState> {
|
|
709
|
+
const { owner, name } = await this.requireCoordinates()
|
|
710
|
+
const prPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`
|
|
711
|
+
const cacheKey = `${owner}/${name}#${number}`
|
|
712
|
+
const cached = this.prStateCache.get(cacheKey)
|
|
713
|
+
const response =
|
|
714
|
+
cached !== undefined
|
|
715
|
+
? await this.request('GET', prPath, { headers: { 'If-None-Match': cached.etag } })
|
|
716
|
+
: await this.request('GET', prPath)
|
|
717
|
+
if (response.status === 304 && cached !== undefined) {
|
|
718
|
+
return cached.state
|
|
719
|
+
}
|
|
720
|
+
const view = this.parse(restPrState, response, `PR #${number} poll`)
|
|
721
|
+
let state: PrState
|
|
722
|
+
if (view.merged) {
|
|
723
|
+
// §15.7 [D1]: pr.merged records the squash commit as the landing
|
|
724
|
+
// point — a merged PR without one is unusable, not mappable.
|
|
725
|
+
if (view.merge_commit_sha === null) {
|
|
726
|
+
throw new Error(`GitHub reports PR #${number} merged with no merge_commit_sha`)
|
|
727
|
+
}
|
|
728
|
+
state = { state: 'merged', sha: view.merge_commit_sha }
|
|
729
|
+
} else {
|
|
730
|
+
switch (view.state) {
|
|
731
|
+
case 'closed':
|
|
732
|
+
state = { state: 'closed' }
|
|
733
|
+
break
|
|
734
|
+
case 'open':
|
|
735
|
+
state = { state: 'open', mergeable: view.mergeable }
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const etag = response.headers.etag
|
|
739
|
+
if (etag === undefined || etag === '') {
|
|
740
|
+
// No revalidation token on this 200: drop any stale entry so a future
|
|
741
|
+
// poll can never replay it against a changed representation.
|
|
742
|
+
this.prStateCache.delete(cacheKey)
|
|
743
|
+
} else {
|
|
744
|
+
this.prStateCache.set(cacheKey, { etag, state })
|
|
745
|
+
// Hard capacity cap with oldest-inserted eviction (JS Map iteration
|
|
746
|
+
// order). The just-inserted entry is newest, so it survives.
|
|
747
|
+
while (this.prStateCache.size > PR_STATE_CACHE_CAP) {
|
|
748
|
+
const oldest = this.prStateCache.keys().next()
|
|
749
|
+
if (oldest.done === true) break
|
|
750
|
+
this.prStateCache.delete(oldest.value)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return state
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async closePr(workspacePath: string, number: number): Promise<ClosePrResult> {
|
|
757
|
+
const before = await this.getPrState(workspacePath, number)
|
|
758
|
+
if (before.state === 'merged' || before.state === 'closed') return before
|
|
759
|
+
|
|
760
|
+
const { owner, name } = await this.requireCoordinates()
|
|
761
|
+
const prPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`
|
|
762
|
+
let closeError: unknown
|
|
763
|
+
try {
|
|
764
|
+
await this.request('PATCH', prPath, { body: { state: 'closed' } })
|
|
765
|
+
} catch (error) {
|
|
766
|
+
closeError = error
|
|
767
|
+
}
|
|
768
|
+
// Re-read even after a mutation failure: GitHub may have merged the PR in
|
|
769
|
+
// the inspection-to-close race, and merged state must never be overwritten.
|
|
770
|
+
const after = await this.getPrState(workspacePath, number)
|
|
771
|
+
if (after.state === 'merged' || after.state === 'closed') return after
|
|
772
|
+
if (closeError !== undefined) throw closeError
|
|
773
|
+
throw new Error(`GitHub PR #${number} remained open after the close request`)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async deleteBranch(_workspacePath: string, branch: string): Promise<void> {
|
|
777
|
+
const ref = `refs/heads/${branch}`
|
|
778
|
+
if (!isValidGitBranchName(branch)) {
|
|
779
|
+
throw new Error(`GitHub branch cleanup rejected invalid ref ${ref}`)
|
|
780
|
+
}
|
|
781
|
+
const { owner, name } = await this.requireCoordinates()
|
|
782
|
+
const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
|
|
783
|
+
try {
|
|
784
|
+
await this.request('GET', `${repoPath}/git/ref/heads/${branch}`)
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (error instanceof GitHubApiError && error.status === 404) return // idempotent
|
|
787
|
+
throw error
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
await this.request('DELETE', `${repoPath}/git/refs/heads/${branch}`)
|
|
791
|
+
} catch (error) {
|
|
792
|
+
// A probe/delete race (branch deleted elsewhere) is still a success.
|
|
793
|
+
if (error instanceof GitHubApiError && error.status === 404) return
|
|
794
|
+
throw error
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Read the provider's projected native desired state, plus the PR's node
|
|
799
|
+
* id — the GraphQL mutations key on the node id, not the PR number.
|
|
800
|
+
* Mutations are not acknowledgements: only an independent follow-up
|
|
801
|
+
* observation can make an `applied` result durable. */
|
|
802
|
+
private async nativeAutoMergeView(number: number): Promise<{ enabled: boolean; nodeId: string }> {
|
|
803
|
+
const { owner, name } = await this.requireCoordinates()
|
|
804
|
+
const view = await this.getJson(
|
|
805
|
+
restNativeAutoMerge,
|
|
806
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
|
|
807
|
+
`PR #${number} native auto-merge read`,
|
|
808
|
+
)
|
|
809
|
+
return { enabled: view.auto_merge !== null, nodeId: view.node_id }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Probe both GitHub gate systems for the PR's exact base branch. The one
|
|
813
|
+
* non-success ruleset response that proves absence is GitHub's documented
|
|
814
|
+
* account-plan limitation; even then classic protection must independently
|
|
815
|
+
* parse successfully. Every other uncertainty is returned as a typed,
|
|
816
|
+
* fail-closed deferral rather than escaping into finalize or the janitor. */
|
|
817
|
+
private async mergeGatePresence(
|
|
818
|
+
baseRefName: string,
|
|
819
|
+
): Promise<
|
|
820
|
+
| { kind: 'proved'; presence: MergeGatePresence }
|
|
821
|
+
| { kind: 'deferred'; reason: AutoMergeDeferralReason }
|
|
822
|
+
> {
|
|
823
|
+
const unproven = (detail: string) =>
|
|
824
|
+
({
|
|
825
|
+
kind: 'deferred',
|
|
826
|
+
reason: { code: 'unproven-gate-state', detail },
|
|
827
|
+
}) as const
|
|
828
|
+
const errorMessage = (error: unknown): string =>
|
|
829
|
+
error instanceof Error ? error.message : String(error)
|
|
830
|
+
|
|
831
|
+
const coordinates = await this.repoCoordinates()
|
|
832
|
+
if (coordinates === null) {
|
|
833
|
+
return unproven(
|
|
834
|
+
'GitHub auto-merge gate repository inspection failed: could not resolve the repository owner/name',
|
|
835
|
+
)
|
|
836
|
+
}
|
|
837
|
+
const { owner, name } = coordinates
|
|
838
|
+
const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
|
|
839
|
+
const branchPath = `${repoPath}/branches/${baseRefName}`
|
|
840
|
+
|
|
841
|
+
let classicGate: boolean | undefined
|
|
842
|
+
let classicError: string | undefined
|
|
843
|
+
try {
|
|
844
|
+
const branch = await this.request('GET', branchPath)
|
|
845
|
+
const view = branch.json as Record<string, unknown> | undefined
|
|
846
|
+
if (view?.protected === false) {
|
|
847
|
+
// The branch view's own flag proves classic protection absent even
|
|
848
|
+
// when the nested protection object is the unprotected stub.
|
|
849
|
+
classicGate = false
|
|
850
|
+
} else {
|
|
851
|
+
const protection = view?.protection
|
|
852
|
+
if (protection === undefined) {
|
|
853
|
+
throw new Error('branch response carries no protection object')
|
|
854
|
+
}
|
|
855
|
+
const classic = classifyClassicProtection(protection)
|
|
856
|
+
if (classic.kind === 'unproven') throw new Error(classic.detail)
|
|
857
|
+
classicGate = classic.gate
|
|
858
|
+
}
|
|
859
|
+
} catch (error) {
|
|
860
|
+
classicError = errorMessage(error)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
let rulesetGate = false
|
|
864
|
+
let planLimited = false
|
|
865
|
+
try {
|
|
866
|
+
const rules = await this.request('GET', `${repoPath}/rules/branches/${baseRefName}`)
|
|
867
|
+
rulesetGate = rulesetsHaveMergeGate(this.parse(restRulesetRules, rules, 'ruleset probe'))
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (
|
|
870
|
+
error instanceof GitHubApiError &&
|
|
871
|
+
error.status === 403 &&
|
|
872
|
+
restPlanLimitation.safeParse(error.body).success
|
|
873
|
+
) {
|
|
874
|
+
planLimited = true
|
|
875
|
+
} else {
|
|
876
|
+
return unproven(`GitHub auto-merge ruleset probe failed: ${errorMessage(error)}`)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (planLimited) {
|
|
881
|
+
if (classicGate === undefined) {
|
|
882
|
+
return {
|
|
883
|
+
kind: 'deferred',
|
|
884
|
+
reason: {
|
|
885
|
+
code: 'github-plan-limitation',
|
|
886
|
+
detail:
|
|
887
|
+
'GitHub returned its documented rulesets plan-limitation response, but classic ' +
|
|
888
|
+
`branch protection could not be proven absent: ${classicError ?? 'unknown failure'}`,
|
|
889
|
+
},
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return { kind: 'proved', presence: classicGate ? 'present' : 'absent' }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (classicGate === undefined) {
|
|
896
|
+
// The classic response was incomplete, but a ruleset probe that
|
|
897
|
+
// succeeded and found merge-blocking rules proves the branch is gated
|
|
898
|
+
// no matter what the classic response hid — native auto-merge is the
|
|
899
|
+
// correct application either way.
|
|
900
|
+
if (rulesetGate) return { kind: 'proved', presence: 'present' }
|
|
901
|
+
return unproven(
|
|
902
|
+
`GitHub auto-merge classic branch-protection probe failed: ${classicError ?? 'unknown failure'}; ` +
|
|
903
|
+
`the ruleset probe for branch '${baseRefName}' found no merge-blocking rules, ` +
|
|
904
|
+
'so a classic reviews requirement could not be ruled out',
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
kind: 'proved',
|
|
909
|
+
presence: classicGate || rulesetGate ? 'present' : 'absent',
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Enable-path PR inspection with bounded mergeability re-query. GitHub's
|
|
915
|
+
* `mergeable_state: 'unknown'` means mergeability is still being computed —
|
|
916
|
+
* transient, commonly observed just after required checks finish — so the
|
|
917
|
+
* same view is re-GET after short waits before concluding, stopping early if
|
|
918
|
+
* native auto-merge appears applied (another actor enabled it; the caller
|
|
919
|
+
* acknowledges adoption without a mutation). Unknown *unrecognized* state
|
|
920
|
+
* strings still throw into the caller's fail-closed handling — only the
|
|
921
|
+
* mapped UNKNOWN value re-queries.
|
|
922
|
+
*/
|
|
923
|
+
private async inspectAutoMergeView(
|
|
924
|
+
repoPath: string,
|
|
925
|
+
number: number,
|
|
926
|
+
): Promise<{
|
|
927
|
+
view: z.infer<typeof restAutoMergeView>
|
|
928
|
+
mergeState: MergeStateStatus
|
|
929
|
+
requeries: number
|
|
930
|
+
waitedMs: number
|
|
931
|
+
}> {
|
|
932
|
+
const view = await this.getJson(
|
|
933
|
+
restAutoMergeView,
|
|
934
|
+
`${repoPath}/pulls/${number}`,
|
|
935
|
+
`PR #${number} auto-merge inspection`,
|
|
936
|
+
)
|
|
937
|
+
let current = view
|
|
938
|
+
let requeries = 0
|
|
939
|
+
let waitedMs = 0
|
|
940
|
+
while (
|
|
941
|
+
current.auto_merge === null &&
|
|
942
|
+
restMergeState(current.mergeable_state) === 'UNKNOWN' &&
|
|
943
|
+
requeries < this.mergeabilityRetryDelaysMs.length
|
|
944
|
+
) {
|
|
945
|
+
const delay = this.mergeabilityRetryDelaysMs[requeries] ?? 0
|
|
946
|
+
await sleep(delay)
|
|
947
|
+
waitedMs += delay
|
|
948
|
+
requeries += 1
|
|
949
|
+
current = await this.getJson(
|
|
950
|
+
restAutoMergeView,
|
|
951
|
+
`${repoPath}/pulls/${number}`,
|
|
952
|
+
`PR #${number} auto-merge inspection`,
|
|
953
|
+
)
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
view: current,
|
|
957
|
+
mergeState: restMergeState(current.mergeable_state),
|
|
958
|
+
requeries,
|
|
959
|
+
waitedMs,
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Reconcile native auto-merge state. A native idempotent hit is acknowledged
|
|
965
|
+
* immediately. Otherwise enabling is classified from authoritative gate
|
|
966
|
+
* existence plus the complete current merge-state enum; only a proved
|
|
967
|
+
* ungated stable PR is returned as a direct candidate. A successful mutation
|
|
968
|
+
* is confirmed with a second native-state read before returning `applied`.
|
|
969
|
+
*/
|
|
970
|
+
async setAutoMerge(
|
|
971
|
+
_workspacePath: string,
|
|
972
|
+
number: number,
|
|
973
|
+
enabled: boolean,
|
|
974
|
+
): Promise<AutoMergeResult> {
|
|
975
|
+
// Cancellation must remain usable when GitHub adds a merge-state enum:
|
|
976
|
+
// inspect only the fields disabling actually needs. The mutation itself
|
|
977
|
+
// sits outside the enable path's catch on purpose — a failed cancellation
|
|
978
|
+
// is a hard janitor-tick error (as `gh pr merge --disable-auto` was
|
|
979
|
+
// before), never a silent deferral of live consent revocation.
|
|
980
|
+
if (!enabled) {
|
|
981
|
+
const native = await this.nativeAutoMergeView(number)
|
|
982
|
+
if (!native.enabled) {
|
|
983
|
+
return { kind: 'applied' }
|
|
984
|
+
}
|
|
985
|
+
await this.graphql(
|
|
986
|
+
'disablePullRequestAutoMerge',
|
|
987
|
+
disableAutoMergeData,
|
|
988
|
+
DISABLE_AUTO_MERGE_MUTATION,
|
|
989
|
+
{ pullRequestId: native.nodeId },
|
|
990
|
+
)
|
|
991
|
+
return (await this.nativeAutoMergeView(number)).enabled
|
|
992
|
+
? { kind: 'deferred' }
|
|
993
|
+
: { kind: 'applied' }
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
try {
|
|
997
|
+
const { owner, name } = await this.requireCoordinates()
|
|
998
|
+
const repoPath = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
|
|
999
|
+
const { view, mergeState, requeries, waitedMs } = await this.inspectAutoMergeView(
|
|
1000
|
+
repoPath,
|
|
1001
|
+
number,
|
|
1002
|
+
)
|
|
1003
|
+
if (view.auto_merge !== null) return { kind: 'applied' }
|
|
1004
|
+
|
|
1005
|
+
const gate = await this.mergeGatePresence(view.base.ref)
|
|
1006
|
+
if (gate.kind === 'deferred') return gate
|
|
1007
|
+
const disposition = classifyAutoMergeEnable(mergeState, gate.presence)
|
|
1008
|
+
switch (disposition.kind) {
|
|
1009
|
+
case 'native': {
|
|
1010
|
+
const repository = await this.getJson(
|
|
1011
|
+
restRepositoryAutoMerge,
|
|
1012
|
+
repoPath,
|
|
1013
|
+
'repository auto-merge read',
|
|
1014
|
+
)
|
|
1015
|
+
if (!repository.allow_auto_merge) {
|
|
1016
|
+
return {
|
|
1017
|
+
kind: 'deferred',
|
|
1018
|
+
reason: {
|
|
1019
|
+
code: 'repository-auto-merge-disabled',
|
|
1020
|
+
detail: 'GitHub reports allow_auto_merge=false; the PR was left open for a human',
|
|
1021
|
+
},
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
await this.graphql(
|
|
1025
|
+
'enablePullRequestAutoMerge',
|
|
1026
|
+
enableAutoMergeData,
|
|
1027
|
+
ENABLE_AUTO_MERGE_MUTATION,
|
|
1028
|
+
{ pullRequestId: view.node_id, mergeMethod: 'SQUASH' },
|
|
1029
|
+
)
|
|
1030
|
+
return (await this.nativeAutoMergeView(number)).enabled
|
|
1031
|
+
? { kind: 'applied' }
|
|
1032
|
+
: {
|
|
1033
|
+
kind: 'deferred',
|
|
1034
|
+
reason: {
|
|
1035
|
+
code: 'unproven-gate-state',
|
|
1036
|
+
detail:
|
|
1037
|
+
'enablePullRequestAutoMerge was accepted but the follow-up native read ' +
|
|
1038
|
+
`reports auto_merge unset for PR #${number}`,
|
|
1039
|
+
},
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
case 'direct':
|
|
1043
|
+
return { kind: 'ungated', headSha: view.head.sha }
|
|
1044
|
+
case 'deferred':
|
|
1045
|
+
if (mergeState === 'UNKNOWN') {
|
|
1046
|
+
// Not a persistent indeterminacy: mergeability was still being
|
|
1047
|
+
// computed (transient) after the bounded re-queries. pendingAutoMerge
|
|
1048
|
+
// keeps the consent pending, so a later janitor tick retries.
|
|
1049
|
+
return {
|
|
1050
|
+
kind: 'deferred',
|
|
1051
|
+
reason: {
|
|
1052
|
+
code: 'mergeability-uncomputed',
|
|
1053
|
+
detail:
|
|
1054
|
+
`GitHub reported mergeable_state 'UNKNOWN' for PR #${number} — mergeability ` +
|
|
1055
|
+
`was still being computed (transient) after ${requeries} re-quer${requeries === 1 ? 'y' : 'ies'} ` +
|
|
1056
|
+
`over ${waitedMs} ms; native auto-merge was not enabled`,
|
|
1057
|
+
},
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (mergeState === 'DIRTY') {
|
|
1061
|
+
// A persistent, definite state: GitHub has computed merge
|
|
1062
|
+
// conflicts with the base branch, so native auto-merge cannot be
|
|
1063
|
+
// enabled against it. The recovery is updating the branch or
|
|
1064
|
+
// resolving the conflicts — not gate-state investigation — and
|
|
1065
|
+
// pendingAutoMerge keeps the consent pending so a later janitor
|
|
1066
|
+
// tick re-examines it once the conflict is resolved.
|
|
1067
|
+
return {
|
|
1068
|
+
kind: 'deferred',
|
|
1069
|
+
reason: {
|
|
1070
|
+
code: 'merge-conflicts',
|
|
1071
|
+
detail:
|
|
1072
|
+
`GitHub reports mergeable_state 'DIRTY' for PR #${number} — the head branch has ` +
|
|
1073
|
+
`merge conflicts with '${view.base.ref}'; update the branch or resolve the conflicts ` +
|
|
1074
|
+
'and the pending consent will be re-examined on a later tick. ' +
|
|
1075
|
+
'Native auto-merge was not enabled',
|
|
1076
|
+
},
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
kind: 'deferred',
|
|
1081
|
+
reason: {
|
|
1082
|
+
code: 'unproven-gate-state',
|
|
1083
|
+
detail: `GitHub reports mergeable_state '${mergeState}' for PR #${number}; native auto-merge was not enabled`,
|
|
1084
|
+
},
|
|
1085
|
+
}
|
|
1086
|
+
case 'error':
|
|
1087
|
+
throw new Error(disposition.reason)
|
|
1088
|
+
}
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
return {
|
|
1091
|
+
kind: 'deferred',
|
|
1092
|
+
reason: {
|
|
1093
|
+
code: 'unproven-gate-state',
|
|
1094
|
+
detail:
|
|
1095
|
+
'GitHub auto-merge gate inspection or native application failed: ' +
|
|
1096
|
+
(error instanceof Error ? error.message : String(error)),
|
|
1097
|
+
},
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Normal guarded squash — no admin, force, rebase, or native-auto flag.
|
|
1103
|
+
* A 409 (head moved) surfaces as a hard error, preserving the contract's
|
|
1104
|
+
* moved-head rejection. */
|
|
1105
|
+
async squashMerge(
|
|
1106
|
+
_workspacePath: string,
|
|
1107
|
+
number: number,
|
|
1108
|
+
expectedHeadSha: string,
|
|
1109
|
+
): Promise<void> {
|
|
1110
|
+
const { owner, name } = await this.requireCoordinates()
|
|
1111
|
+
await this.request(
|
|
1112
|
+
'PUT',
|
|
1113
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}/merge`,
|
|
1114
|
+
{
|
|
1115
|
+
body: { merge_method: 'squash', sha: expectedHeadSha },
|
|
1116
|
+
},
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** The build's summary comment (SPEC §7.5) — links into the store. */
|
|
1121
|
+
async commentOnPr(_workspacePath: string, number: number, body: string): Promise<void> {
|
|
1122
|
+
const { owner, name } = await this.requireCoordinates()
|
|
1123
|
+
await this.request(
|
|
1124
|
+
'POST',
|
|
1125
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`,
|
|
1126
|
+
{ body: { body } },
|
|
1127
|
+
)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ── Optional capabilities (checkout-less dispatcher) ──────────────────────
|
|
1131
|
+
|
|
1132
|
+
/** Current tip of a remote branch. Throws (including 404) when the branch
|
|
1133
|
+
* does not exist; the provider seam maps that to `undefined`. */
|
|
1134
|
+
async remoteBranchSha(branch: string): Promise<string> {
|
|
1135
|
+
const { owner, name } = await this.requireCoordinates()
|
|
1136
|
+
const branchHead = await this.getJson(
|
|
1137
|
+
restBranchHead,
|
|
1138
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${branch}`,
|
|
1139
|
+
`remote branch ${branch}`,
|
|
1140
|
+
)
|
|
1141
|
+
return branchHead.commit.sha
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Raw file bytes from the repository. Ref omitted → the repository's
|
|
1145
|
+
* default branch. Throws (including 404) when the path does not exist. */
|
|
1146
|
+
async readFile(path: string, ref?: string): Promise<string> {
|
|
1147
|
+
const { owner, name } = await this.requireCoordinates()
|
|
1148
|
+
const query = ref !== undefined ? { ref } : undefined
|
|
1149
|
+
const response = await this.request(
|
|
1150
|
+
'GET',
|
|
1151
|
+
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path}`,
|
|
1152
|
+
{
|
|
1153
|
+
headers: { Accept: 'application/vnd.github.raw' },
|
|
1154
|
+
...(query !== undefined ? { query } : {}),
|
|
1155
|
+
},
|
|
1156
|
+
)
|
|
1157
|
+
if (response.bytes === undefined) {
|
|
1158
|
+
throw new Error(`GitHub contents read of ${path} returned no raw bytes`)
|
|
1159
|
+
}
|
|
1160
|
+
return new TextDecoder().decode(response.bytes)
|
|
1161
|
+
}
|
|
1162
|
+
}
|