@open-mercato/cezar 0.9.1 → 0.9.2
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/README.md +19 -11
- package/dist/agent-config/catalog.d.ts +1 -1
- package/dist/agent-config/files.d.ts +1 -1
- package/dist/agent-config/service.d.ts +1 -1
- package/dist/agent-config/validate.d.ts +1 -1
- package/dist/automations/coordinator.d.ts +26 -0
- package/dist/automations/coordinator.js +66 -0
- package/dist/automations/coordinator.js.map +1 -0
- package/dist/automations/github-poller.d.ts +74 -0
- package/dist/automations/github-poller.js +234 -0
- package/dist/automations/github-poller.js.map +1 -0
- package/dist/automations/scheduler.d.ts +42 -0
- package/dist/automations/scheduler.js +192 -0
- package/dist/automations/scheduler.js.map +1 -0
- package/dist/automations/store.d.ts +65 -0
- package/dist/automations/store.js +298 -0
- package/dist/automations/store.js.map +1 -0
- package/dist/automations/task-template.d.ts +18 -0
- package/dist/automations/task-template.js +95 -0
- package/dist/automations/task-template.js.map +1 -0
- package/dist/automations/types.d.ts +251 -0
- package/dist/automations/types.js +158 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/config.d.ts +9 -111
- package/dist/contract/agent-config.d.ts +151 -0
- package/dist/contract/automations.d.ts +911 -0
- package/dist/contract/events.d.ts +45 -0
- package/dist/contract/github.d.ts +467 -0
- package/dist/contract/health.d.ts +96 -0
- package/dist/contract/index.d.ts +15 -0
- package/dist/contract/index.js +1421 -0
- package/dist/contract/projects.d.ts +182 -0
- package/dist/contract/repo.d.ts +234 -0
- package/dist/contract/runs.d.ts +1226 -0
- package/dist/contract/skills.d.ts +225 -0
- package/dist/contract/workflows.d.ts +212 -0
- package/dist/contract/workspace.d.ts +442 -0
- package/dist/core/agent-env.d.ts +1 -1
- package/dist/core/agent-runner.d.ts +12 -1
- package/dist/core/agent-runner.js +13 -0
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/ask.d.ts +45 -124
- package/dist/core/ask.js +103 -13
- package/dist/core/ask.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +2 -2
- package/dist/core/claude-cli-runner.js +52 -9
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/claude-ui-mapper.d.ts +1 -1
- package/dist/core/codex-app-server-runner.d.ts +1 -1
- package/dist/core/codex-app-server-runner.js +55 -3
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/codex-app-server-transport.d.ts +6 -1
- package/dist/core/codex-app-server-transport.js +12 -3
- package/dist/core/codex-app-server-transport.js.map +1 -1
- package/dist/core/codex-model-catalog.d.ts +1 -1
- package/dist/core/codex-ui-mapper.d.ts +5 -1
- package/dist/core/codex-ui-mapper.js +74 -17
- package/dist/core/codex-ui-mapper.js.map +1 -1
- package/dist/core/model-identity.d.ts +1 -1
- package/dist/core/model-presets.d.ts +2 -2
- package/dist/core/model-presets.js +1 -1
- package/dist/core/opencode-server-runner.d.ts +2 -2
- package/dist/core/opencode-ui-mapper.d.ts +4 -1
- package/dist/core/opencode-ui-mapper.js +45 -7
- package/dist/core/opencode-ui-mapper.js.map +1 -1
- package/dist/core/provider-auth.d.ts +62 -0
- package/dist/core/provider-auth.js +290 -0
- package/dist/core/provider-auth.js.map +1 -0
- package/dist/core/provider-availability.d.ts +3 -0
- package/dist/core/provider-availability.js +13 -0
- package/dist/core/provider-availability.js.map +1 -0
- package/dist/core/runner-factory.d.ts +1 -1
- package/dist/core/runner-model-catalog.d.ts +1 -1
- package/dist/core/tool-display.d.ts +1 -1
- package/dist/core/ui-events.d.ts +1 -1
- package/dist/core/ui-events.js +1 -1
- package/dist/git-diff-base.d.ts +60 -0
- package/dist/git-diff-base.js +54 -0
- package/dist/git-diff-base.js.map +1 -0
- package/dist/git-worktree.d.ts +37 -6
- package/dist/git-worktree.js +39 -9
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +2 -2
- package/dist/handoff.js +1 -1
- package/dist/index.js +65 -8
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +16 -1
- package/dist/paths.js +24 -1
- package/dist/paths.js.map +1 -1
- package/dist/planner.d.ts +2 -2
- package/dist/planner.js +1 -1
- package/dist/planner.js.map +1 -1
- package/dist/release/manifests.d.ts +81 -0
- package/dist/release/manifests.js +74 -0
- package/dist/release/manifests.js.map +1 -0
- package/dist/release/snapshot.d.ts +9 -23
- package/dist/release/snapshot.js +9 -22
- package/dist/release/snapshot.js.map +1 -1
- package/dist/release/stable.d.ts +15 -29
- package/dist/release/stable.js +14 -27
- package/dist/release/stable.js.map +1 -1
- package/dist/runs/auto-name.d.ts +1 -1
- package/dist/runs/retention.d.ts +1 -1
- package/dist/runs/store.d.ts +142 -336
- package/dist/runs/store.js +136 -9
- package/dist/runs/store.js.map +1 -1
- package/dist/runs/ui-event-sink.d.ts +1 -1
- package/dist/server/app-type.d.ts +21 -0
- package/dist/server/app-type.js +2 -0
- package/dist/server/app-type.js.map +1 -0
- package/dist/server/capabilities.d.ts +6 -5
- package/dist/server/capabilities.js +11 -0
- package/dist/server/capabilities.js.map +1 -1
- package/dist/server/forge/github.d.ts +58 -151
- package/dist/server/forge/github.js +530 -6
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/index.d.ts +10 -3
- package/dist/server/forge/index.js +14 -1
- package/dist/server/forge/index.js.map +1 -1
- package/dist/server/forge/types.d.ts +86 -1
- package/dist/server/git-changes.d.ts +8 -8
- package/dist/server/git-changes.js +14 -17
- package/dist/server/git-changes.js.map +1 -1
- package/dist/server/git.d.ts +2 -0
- package/dist/server/git.js +9 -0
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +3 -3
- package/dist/server/github.js +1 -1
- package/dist/server/github.js.map +1 -1
- package/dist/server/pr.d.ts +2 -2
- package/dist/server/project-context.d.ts +20 -3
- package/dist/server/project-context.js +30 -1
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/provider-action-gate.d.ts +8 -0
- package/dist/server/provider-action-gate.js +54 -0
- package/dist/server/provider-action-gate.js.map +1 -0
- package/dist/server/provider-auth-runtime.d.ts +21 -0
- package/dist/server/provider-auth-runtime.js +66 -0
- package/dist/server/provider-auth-runtime.js.map +1 -0
- package/dist/server/server.d.ts +11142 -47
- package/dist/server/server.js +1714 -806
- package/dist/server/server.js.map +1 -1
- package/dist/server/validators.d.ts +97 -0
- package/dist/server/validators.js +86 -0
- package/dist/server/validators.js.map +1 -0
- package/dist/server/ws.d.ts +2 -2
- package/dist/server/ws.js +2 -2
- package/dist/server/ws.js.map +1 -1
- package/dist/server-install/engine.d.ts +1 -1
- package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
- package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
- package/dist/server-install/platforms/ubuntu-vps.js +83 -1
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
- package/dist/server-install/state.d.ts +1 -1
- package/dist/server-install/steps.d.ts +2 -2
- package/dist/server-install/strategies.d.ts +1 -1
- package/dist/server-install/types.d.ts +38 -424
- package/dist/server-install/ui.d.ts +1 -1
- package/dist/skills-banner.d.ts +7 -6
- package/dist/skills-banner.js +7 -6
- package/dist/skills-banner.js.map +1 -1
- package/dist/skills-remote.d.ts +2 -2
- package/dist/skills-update.d.ts +88 -0
- package/dist/skills-update.js +392 -0
- package/dist/skills-update.js.map +1 -0
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -2
- package/dist/skills.js.map +1 -1
- package/dist/todos.d.ts +2 -28
- package/dist/todos.js +1 -1
- package/dist/ui-state.d.ts +16 -2
- package/dist/ui-state.js +14 -1
- package/dist/ui-state.js.map +1 -1
- package/dist/workflows/load.d.ts +1 -1
- package/dist/workflows/run.d.ts +53 -12
- package/dist/workflows/run.js +436 -64
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +59 -224
- package/dist/workflows/types.js +14 -0
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/config.d.ts +68 -206
- package/dist/workspace/config.js +145 -14
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/migrations.d.ts +1 -1
- package/dist/workspace/migrations.js +19 -4
- package/dist/workspace/migrations.js.map +1 -1
- package/dist/workspace/projects.d.ts +8 -2
- package/dist/workspace/projects.js +9 -3
- package/dist/workspace/projects.js.map +1 -1
- package/dist/workspace/semaphore.d.ts +6 -0
- package/dist/workspace/semaphore.js +14 -1
- package/dist/workspace/semaphore.js.map +1 -1
- package/dist/workspace/ui-state.d.ts +13 -3
- package/dist/workspace/ui-state.js +15 -5
- package/dist/workspace/ui-state.js.map +1 -1
- package/package.json +17 -39
- package/scripts/inline-contract.mjs +112 -0
- package/scripts/mock-claude.mjs +39 -1
- package/scripts/sync-readme.mjs +20 -0
- package/web/dist/assets/alert-dialog-ghffK6g9.js +1 -0
- package/web/dist/assets/arrow-down-Cz8qb71e.js +1 -0
- package/web/dist/assets/arrow-left-wcYKQXI_.js +1 -0
- package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
- package/web/dist/assets/centered-state-ZtnFHA-n.js +43 -0
- package/web/dist/assets/chevron-right-G4caF2QV.js +1 -0
- package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
- package/web/dist/assets/circle-check--KvBxXWp.js +1 -0
- package/web/dist/assets/circle-x-C3EFZ3xf.js +1 -0
- package/web/dist/assets/collapsible-B0JVzd0B.js +1 -0
- package/web/dist/assets/commit-list-wm6DPHNs.js +1 -0
- package/web/dist/assets/compare-variants-DbdP4Y5T.js +1 -0
- package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
- package/web/dist/assets/diff-Bizc96qe.js +3 -0
- package/web/dist/assets/diff-stat-Dyfg3ltF.js +1 -0
- package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-CG8ek6tC.js} +4 -4
- package/web/dist/assets/dropdown-menu-C1Dlf35P.js +1 -0
- package/web/dist/assets/editable-title-5rC_QRBZ.js +1 -0
- package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-DpGTRqC2.js} +1 -1
- package/web/dist/assets/{file-D4GBh1ao.js → file-DlUGpUlb.js} +1 -1
- package/web/dist/assets/folder-BAY6Vddy.js +1 -0
- package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-agOG0iGM.js} +1 -1
- package/web/dist/assets/git-toolbar-DGiNMmtY.js +1 -0
- package/web/dist/assets/github-Vp7lxYGQ.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
- package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
- package/web/dist/assets/image-preview-BWGk5fPX.js +1 -0
- package/web/dist/assets/index-DSxyJpuK.css +2 -0
- package/web/dist/assets/index-DiawI6RX.js +6 -0
- package/web/dist/assets/lib-BQXq3kEf.js +1 -0
- package/web/dist/assets/lib-BxQXEXDF.js +1 -0
- package/web/dist/assets/markdown-BGBOGCA_.js +2 -0
- package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
- package/web/dist/assets/new-task-form-BpAw_Dks.js +1 -0
- package/web/dist/assets/pill-Dvs-P0xy.js +1 -0
- package/web/dist/assets/project-router-Cx8NPoqn.js +1 -0
- package/web/dist/assets/prompt-templates-CSBYBR1I.js +15 -0
- package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
- package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-BoGdAykg.js} +1 -1
- package/web/dist/assets/repo-git-PmX-BNMo.js +1 -0
- package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
- package/web/dist/assets/run-diff-EcOkf3ti.js +3 -0
- package/web/dist/assets/run-header-BGiv6_qX.js +1 -0
- package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-X3EpVhgs.js} +1 -1
- package/web/dist/assets/skill-empty-hint-CZLALx6l.js +1 -0
- package/web/dist/assets/skills-BiWfHmCR.js +1 -0
- package/web/dist/assets/skills-CC91ouGH.js +1 -0
- package/web/dist/assets/sparkles-Dt4q7pbG.js +1 -0
- package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-hwBL8XsC.js} +1 -1
- package/web/dist/assets/tab-link-DIlR5T07.js +1 -0
- package/web/dist/assets/task-changes-XkHDxzQA.js +1 -0
- package/web/dist/assets/task-commits-BTTA5k5T.js +1 -0
- package/web/dist/assets/task-files-Da8_4C9g.js +2 -0
- package/web/dist/assets/task-thread-DoNXUsHr.js +9 -0
- package/web/dist/assets/textarea-CUdokhkv.js +1 -0
- package/web/dist/assets/thread-loading-B8q8ukXJ.js +1 -0
- package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-DhVIdWv8.js} +1 -1
- package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-XYT0DGCI.js} +1 -1
- package/web/dist/assets/{upload-CDdyPPo_.js → upload-BKjN59-1.js} +1 -1
- package/web/dist/assets/use-desktop-Bf5LS_P4.js +1 -0
- package/web/dist/assets/use-submit-shortcut-CzVyoAMd.js +1 -0
- package/web/dist/assets/utils-DsLuRXwV.js +64 -0
- package/web/dist/assets/workflows-o2QU_pvU.js +11 -0
- package/web/dist/assets/zoomable-image-7WyUiaVD.js +1 -0
- package/web/dist/index.html +32 -13
- package/scripts/dev.mjs +0 -74
- package/scripts/release-snapshot.mjs +0 -130
- package/scripts/release.mjs +0 -119
- package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
- package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
- package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
- package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
- package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
- package/web/dist/assets/dist-B6XQOhgM.js +0 -1
- package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
- package/web/dist/assets/github-DTTSG0ht.js +0 -1
- package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
- package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
- package/web/dist/assets/index-Cijmlz7q.js +0 -30
- package/web/dist/assets/index-DzroxqBG.css +0 -2
- package/web/dist/assets/lib-DPEZDBUN.js +0 -1
- package/web/dist/assets/lib-uHpeUPzK.js +0 -1
- package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
- package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
- package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
- package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
- package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
- package/web/dist/assets/run-header-Cg5incil.js +0 -1
- package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
- package/web/dist/assets/skills-PpP2owbM.js +0 -1
- package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
- package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
- package/web/dist/assets/task-commits-COkLaUri.js +0 -1
- package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
- package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
- package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
- package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
- package/web/dist/assets/utils-B8s5qIcK.js +0 -1
- package/web/dist/assets/workflows-BFk8C0co.js +0 -11
- package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
- /package/web/{open-mercato.svg → dist/open-mercato.svg} +0 -0
|
@@ -10,6 +10,127 @@ import { autosaveCommit } from '../../git-worktree.js';
|
|
|
10
10
|
* protected by BACKWARD_COMPATIBILITY.md — additive changes only.
|
|
11
11
|
*/
|
|
12
12
|
const exec = promisify(execFile);
|
|
13
|
+
export const GH_PR_DIFF_FILE_CAP = 300;
|
|
14
|
+
export const GH_PR_PATCH_CAP = 512 * 1024;
|
|
15
|
+
export const GH_PR_DIFF_JSON_CAP = 4 * 1024 * 1024;
|
|
16
|
+
const ghPrFileSchema = z.object({
|
|
17
|
+
filename: z.string().min(1),
|
|
18
|
+
previous_filename: z.string().optional(),
|
|
19
|
+
status: z.enum(['added', 'modified', 'removed', 'renamed', 'copied', 'changed']),
|
|
20
|
+
additions: z.number().int().nonnegative(),
|
|
21
|
+
deletions: z.number().int().nonnegative(),
|
|
22
|
+
patch: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
const ghPrHeadSchema = z.object({ headRefOid: z.string().regex(/^[0-9a-f]{40}$/i) });
|
|
25
|
+
export class GithubPrNotFoundError extends Error {
|
|
26
|
+
}
|
|
27
|
+
const prDiffCache = new Map();
|
|
28
|
+
/** Fetch no more than the three GitHub pages represented by the public 300-file response cap. */
|
|
29
|
+
export async function fetchPrFilePages(runPage) {
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (let page = 1; page <= GH_PR_DIFF_FILE_CAP / 100; page++) {
|
|
32
|
+
const next = z.array(z.unknown()).parse(JSON.parse(await runPage(page)));
|
|
33
|
+
rows.push(...next);
|
|
34
|
+
if (next.length < 100)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
return rows;
|
|
38
|
+
}
|
|
39
|
+
export async function fetchGithubPrDiff(repoRoot, number, refresh = false) {
|
|
40
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
41
|
+
return mockGithubPrDiff(number);
|
|
42
|
+
try {
|
|
43
|
+
const head = ghPrHeadSchema.parse(JSON.parse(await gh(repoRoot, ['pr', 'view', String(number), '--json', 'headRefOid']))).headRefOid;
|
|
44
|
+
const key = `${repoRoot}\0${number}\0${head}`;
|
|
45
|
+
const hit = prDiffCache.get(key);
|
|
46
|
+
if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
|
|
47
|
+
return hit.data;
|
|
48
|
+
const rows = z.array(ghPrFileSchema).parse(await fetchPrFilePages((page) => gh(repoRoot, ['api', `repos/{owner}/{repo}/pulls/${number}/files?per_page=100&page=${page}`], 30_000)));
|
|
49
|
+
const limited = rows.slice(0, GH_PR_DIFF_FILE_CAP);
|
|
50
|
+
// A full third page may have a successor. Without fetching a 301st file, conservatively call
|
|
51
|
+
// the response partial rather than claiming completeness we cannot prove.
|
|
52
|
+
let responseTruncated = rows.length >= GH_PR_DIFF_FILE_CAP;
|
|
53
|
+
const reasons = responseTruncated ? [`Only the first ${GH_PR_DIFF_FILE_CAP} files are shown.`] : [];
|
|
54
|
+
const files = limited.map((row) => {
|
|
55
|
+
let patch = row.patch;
|
|
56
|
+
let truncated = false;
|
|
57
|
+
let patchUnavailableReason;
|
|
58
|
+
if (patch !== undefined && Buffer.byteLength(patch, 'utf8') > GH_PR_PATCH_CAP) {
|
|
59
|
+
patch = undefined;
|
|
60
|
+
truncated = true;
|
|
61
|
+
patchUnavailableReason = 'too-large';
|
|
62
|
+
responseTruncated = true;
|
|
63
|
+
}
|
|
64
|
+
else if (patch === undefined) {
|
|
65
|
+
patchUnavailableReason = row.additions === 0 && row.deletions === 0 ? 'binary' : 'not-provided';
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
path: row.filename,
|
|
69
|
+
...(row.previous_filename ? { previousPath: row.previous_filename } : {}),
|
|
70
|
+
status: row.status,
|
|
71
|
+
additions: row.additions,
|
|
72
|
+
deletions: row.deletions,
|
|
73
|
+
...(patch !== undefined ? { patch } : {}),
|
|
74
|
+
...(patchUnavailableReason ? { patchUnavailableReason } : {}),
|
|
75
|
+
...(truncated ? { truncated: true } : {}),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
let kept = files;
|
|
79
|
+
while (kept.length > 0 &&
|
|
80
|
+
Buffer.byteLength(JSON.stringify({ available: true, number, headSha: head, files: kept }), 'utf8') >
|
|
81
|
+
GH_PR_DIFF_JSON_CAP) {
|
|
82
|
+
kept = kept.slice(0, -1);
|
|
83
|
+
responseTruncated = true;
|
|
84
|
+
}
|
|
85
|
+
if (kept.length < files.length)
|
|
86
|
+
reasons.push('The response size limit omitted some files.');
|
|
87
|
+
if (files.some((file) => file.truncated))
|
|
88
|
+
reasons.push('One or more patches exceeded the per-file limit.');
|
|
89
|
+
const data = {
|
|
90
|
+
available: true,
|
|
91
|
+
number,
|
|
92
|
+
headSha: head,
|
|
93
|
+
files: kept,
|
|
94
|
+
additions: rows.reduce((sum, row) => sum + row.additions, 0),
|
|
95
|
+
deletions: rows.reduce((sum, row) => sum + row.deletions, 0),
|
|
96
|
+
truncated: responseTruncated,
|
|
97
|
+
...(reasons.length ? { reason: reasons.join(' ') } : {}),
|
|
98
|
+
};
|
|
99
|
+
prDiffCache.set(key, { at: Date.now(), data });
|
|
100
|
+
while (prDiffCache.size > 50)
|
|
101
|
+
prDiffCache.delete(prDiffCache.keys().next().value);
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
if (/HTTP 404|Could not resolve to a PullRequest|no pull requests found/i.test(message)) {
|
|
107
|
+
throw new GithubPrNotFoundError(`Pull request #${number} was not found`);
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
available: false,
|
|
111
|
+
reason: /ENOENT/.test(message)
|
|
112
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
113
|
+
: firstLine(message),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function mockGithubPrDiff(number) {
|
|
118
|
+
return {
|
|
119
|
+
available: true,
|
|
120
|
+
number,
|
|
121
|
+
headSha: '0123456789abcdef0123456789abcdef01234567',
|
|
122
|
+
additions: 15,
|
|
123
|
+
deletions: 4,
|
|
124
|
+
truncated: true,
|
|
125
|
+
reason: 'One or more patches were not provided by GitHub.',
|
|
126
|
+
files: [
|
|
127
|
+
{ path: 'src/session.ts', status: 'modified', additions: 8, deletions: 3, patch: '@@ -1,3 +1,4 @@\n-old\n+new\n context' },
|
|
128
|
+
{ path: 'src/new-name.ts', previousPath: 'src/old-name.ts', status: 'renamed', additions: 7, deletions: 1, patch: '@@ -1 +1 @@\n-old name\n+new name' },
|
|
129
|
+
{ path: 'assets/logo.png', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'binary' },
|
|
130
|
+
{ path: 'generated/output.txt', status: 'modified', additions: 0, deletions: 0, patchUnavailableReason: 'too-large', truncated: true },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
13
134
|
// `gh … --json` output — validated at the boundary, extras stripped.
|
|
14
135
|
const ghAuthor = z.object({ login: z.string() }).nullish();
|
|
15
136
|
// `color` is the 6-hex GitHub label color (no `#`), '' when gh omits it.
|
|
@@ -31,11 +152,13 @@ export const ghCheckRunSchema = z.object({
|
|
|
31
152
|
conclusion: z.string().nullish(),
|
|
32
153
|
});
|
|
33
154
|
const ghStatusCheckRollup = z.array(ghCheckRunSchema).nullish();
|
|
155
|
+
// The list tier no longer requests `statusCheckRollup` (#664) — it is hydrated lazily per
|
|
156
|
+
// on-screen PR row (`fetchGithubChecks`). `checks` is set to `null` on list rows; the schema
|
|
157
|
+
// keeps no rollup field because the list call never asks for it.
|
|
34
158
|
const ghPrSchema = ghIssueSchema.extend({
|
|
35
159
|
isDraft: z.boolean().default(false),
|
|
36
160
|
additions: z.number().default(0),
|
|
37
161
|
deletions: z.number().default(0),
|
|
38
|
-
statusCheckRollup: ghStatusCheckRollup,
|
|
39
162
|
});
|
|
40
163
|
const ghPrViewSchema = z.object({
|
|
41
164
|
number: z.number(),
|
|
@@ -44,6 +167,39 @@ const ghPrViewSchema = z.object({
|
|
|
44
167
|
isDraft: z.boolean().default(false),
|
|
45
168
|
statusCheckRollup: ghStatusCheckRollup,
|
|
46
169
|
});
|
|
170
|
+
const mergeCheckSchema = z.object({
|
|
171
|
+
name: z.string().default('Check'),
|
|
172
|
+
state: z.string().nullish(),
|
|
173
|
+
status: z.string().nullish(),
|
|
174
|
+
conclusion: z.string().nullish(),
|
|
175
|
+
detailsUrl: z.string().nullish(),
|
|
176
|
+
});
|
|
177
|
+
const mergePrSchema = z.object({
|
|
178
|
+
number: z.number(),
|
|
179
|
+
title: z.string(),
|
|
180
|
+
url: z.string(),
|
|
181
|
+
state: z.string(),
|
|
182
|
+
isDraft: z.boolean().default(false),
|
|
183
|
+
headRefName: z.string(),
|
|
184
|
+
baseRefName: z.string(),
|
|
185
|
+
headRefOid: z.string().regex(/^[0-9a-f]{40}$/),
|
|
186
|
+
mergeable: z.string().nullish(),
|
|
187
|
+
mergeStateStatus: z.string().nullish(),
|
|
188
|
+
reviewDecision: z.string().nullish(),
|
|
189
|
+
statusCheckRollup: z.array(mergeCheckSchema).nullish(),
|
|
190
|
+
});
|
|
191
|
+
const repoMergePolicySchema = z.object({
|
|
192
|
+
allow_merge_commit: z.boolean().default(false),
|
|
193
|
+
allow_squash_merge: z.boolean().default(false),
|
|
194
|
+
allow_rebase_merge: z.boolean().default(false),
|
|
195
|
+
merge_commit_title: z.string().nullish(),
|
|
196
|
+
squash_merge_commit_title: z.string().nullish(),
|
|
197
|
+
});
|
|
198
|
+
const ghMergeResultSchema = z.object({
|
|
199
|
+
merged: z.boolean(),
|
|
200
|
+
message: z.string().nullish(),
|
|
201
|
+
sha: z.string().nullish(),
|
|
202
|
+
});
|
|
47
203
|
/** Exported for unit tests (#400) — collapses a zod-validated `statusCheckRollup` array down to
|
|
48
204
|
* the single enum the GitHub tab (list rows + detail badge) renders. */
|
|
49
205
|
export function rollupToChecks(rollup) {
|
|
@@ -147,9 +303,11 @@ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
|
|
|
147
303
|
}
|
|
148
304
|
try {
|
|
149
305
|
// No `comments` field — `gh … --json comments` ships full comment bodies.
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
306
|
+
// No `statusCheckRollup` either (#664): the CI rollup for every open PR was the
|
|
307
|
+
// dominant cost — it forced the 60 s budget below — and is now hydrated lazily per
|
|
308
|
+
// on-screen PR row via `fetchGithubChecks`. A big list still gets a little more wall
|
|
309
|
+
// clock than the default 30, but nothing like the old rollup walk.
|
|
310
|
+
const timeout = capped > 100 ? 30_000 : 15_000;
|
|
153
311
|
const fields = 'number,title,author,createdAt,labels,body,url';
|
|
154
312
|
// The repo handle first (cheap) so the counts GraphQL query — which needs owner/name —
|
|
155
313
|
// can run parallel to the two expensive list calls below.
|
|
@@ -168,7 +326,7 @@ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
|
|
|
168
326
|
const countsMaxPages = Math.min(GH_COUNTS_MAX_PAGES, Math.max(1, Math.ceil(capped / 100)));
|
|
169
327
|
const [issuesOut, prsOut, counts] = await Promise.all([
|
|
170
328
|
gh(repoRoot, ['issue', 'list', '--limit', String(capped), '--json', fields], timeout),
|
|
171
|
-
gh(repoRoot, ['pr', 'list', '--limit', String(capped), '--json', `${fields},isDraft,additions,deletions
|
|
329
|
+
gh(repoRoot, ['pr', 'list', '--limit', String(capped), '--json', `${fields},isDraft,additions,deletions`], timeout),
|
|
172
330
|
// Real comment counts (#499). Degrades to empty maps on its own — a failure here leaves
|
|
173
331
|
// every count at 0, never fails the tab. Skipped entirely if the handle isn't parseable.
|
|
174
332
|
ownerName
|
|
@@ -210,7 +368,9 @@ export async function fetchGithub(repoRoot, refresh = false, limit = 30) {
|
|
|
210
368
|
isDraft: p.isDraft,
|
|
211
369
|
additions: p.additions,
|
|
212
370
|
deletions: p.deletions,
|
|
213
|
-
|
|
371
|
+
// Hydrated lazily by `fetchGithubChecks` for on-screen rows (#664) — the list no
|
|
372
|
+
// longer pays for the CI rollup of every open PR.
|
|
373
|
+
checks: null,
|
|
214
374
|
};
|
|
215
375
|
});
|
|
216
376
|
const data = {
|
|
@@ -688,6 +848,138 @@ export async function fetchCommitChecks(runGraphql, owner, name, shas, chunkSize
|
|
|
688
848
|
}
|
|
689
849
|
return out;
|
|
690
850
|
}
|
|
851
|
+
/** PR numbers per checks query. Aliases resolve independently (a failed chunk costs only its own
|
|
852
|
+
* glyphs); bounded so an unbounded number list can't blow the query size limit. Also the route's
|
|
853
|
+
* hard cap on how many PRs one request may ask about. */
|
|
854
|
+
export const GH_CHECKS_MAX = 100;
|
|
855
|
+
/** One aliased `pullRequest(number:)` per PR; the rolled-up CI state lives on the head commit. */
|
|
856
|
+
function prChecksQuery(numbers) {
|
|
857
|
+
const aliases = numbers
|
|
858
|
+
.map((n, i) => ` p${i}: pullRequest(number: ${n}) { commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } }`)
|
|
859
|
+
.join('\n');
|
|
860
|
+
return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
|
|
861
|
+
}
|
|
862
|
+
const ghPrChecksSchema = z.record(z.string(), z
|
|
863
|
+
.object({
|
|
864
|
+
commits: z.object({
|
|
865
|
+
nodes: z.array(z.object({
|
|
866
|
+
commit: z.object({
|
|
867
|
+
statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
|
|
868
|
+
}),
|
|
869
|
+
})),
|
|
870
|
+
}),
|
|
871
|
+
})
|
|
872
|
+
.nullish());
|
|
873
|
+
/**
|
|
874
|
+
* Rolled-up CI state per PR number, as a `number → glyph` map.
|
|
875
|
+
*
|
|
876
|
+
* Batched and aliased so a 100-PR window costs one subprocess, not a hundred. Each alias resolves
|
|
877
|
+
* independently and an unknown number comes back `null` (alias resolved null → left absent), so
|
|
878
|
+
* partial results degrade cleanly. Degrades to an empty map on any failure — exactly as
|
|
879
|
+
* `fetchCommitChecks` does. Exported for tests; `runGraphql` is injected so this is testable
|
|
880
|
+
* without shelling out.
|
|
881
|
+
*/
|
|
882
|
+
export async function fetchPrChecks(runGraphql, owner, name, numbers, chunkSize = GH_CHECKS_MAX) {
|
|
883
|
+
const out = {};
|
|
884
|
+
if (numbers.length === 0)
|
|
885
|
+
return out;
|
|
886
|
+
for (let i = 0; i < numbers.length; i += chunkSize) {
|
|
887
|
+
const chunk = numbers.slice(i, i + chunkSize);
|
|
888
|
+
try {
|
|
889
|
+
const raw = JSON.parse(await runGraphql(prChecksQuery(chunk), { owner, name }));
|
|
890
|
+
const repository = ghPrChecksSchema.parse(raw?.data?.repository ?? {});
|
|
891
|
+
chunk.forEach((number, index) => {
|
|
892
|
+
const node = repository[`p${index}`];
|
|
893
|
+
if (!node)
|
|
894
|
+
return; // unknown PR → alias resolved null; leave the glyph absent
|
|
895
|
+
const rollup = node.commits.nodes[0]?.commit.statusCheckRollup;
|
|
896
|
+
// Adapt the single rollup state into the array shape `rollupToChecks` expects, reusing the
|
|
897
|
+
// FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
|
|
898
|
+
out[number] = rollup
|
|
899
|
+
? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null
|
|
900
|
+
: null; // no CI configured — distinct from absent
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// A failed chunk costs only its own glyphs; the rest still resolve.
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
// Per-PR checks cache: keyed `repoRoot␀number`, same 60 s TTL as the list cache but BOUNDED. Lets
|
|
910
|
+
// a repeated visible-window hydration within a minute serve cached glyphs instead of re-querying.
|
|
911
|
+
const checksCache = new Map();
|
|
912
|
+
const CHECKS_CACHE_MAX = 500;
|
|
913
|
+
/** Test hook — the per-PR checks cache would otherwise leak state across cases in one process. */
|
|
914
|
+
export function __clearChecksCacheForTests() {
|
|
915
|
+
checksCache.clear();
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Lazy checks glyphs for the given PR numbers (route-facing). Resolves the repo handle once,
|
|
919
|
+
* serves fresh cache entries, queries only the misses, and degrades to `{ available: false,
|
|
920
|
+
* reason }` when `gh` or the handle is unavailable — never a throw, never a 5xx (plan rule 7).
|
|
921
|
+
* Numbers are de-duplicated, validated, and capped at `GH_CHECKS_MAX`.
|
|
922
|
+
*/
|
|
923
|
+
export async function fetchGithubChecks(repoRoot, numbers) {
|
|
924
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
925
|
+
return mockGithubChecks(numbers);
|
|
926
|
+
const wanted = [...new Set(numbers)].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_CHECKS_MAX);
|
|
927
|
+
const checks = {};
|
|
928
|
+
const misses = [];
|
|
929
|
+
const now = Date.now();
|
|
930
|
+
for (const n of wanted) {
|
|
931
|
+
const hit = checksCache.get(`${repoRoot}\0${n}`);
|
|
932
|
+
if (hit && now - hit.at < CACHE_MS)
|
|
933
|
+
checks[n] = hit.glyph;
|
|
934
|
+
else
|
|
935
|
+
misses.push(n);
|
|
936
|
+
}
|
|
937
|
+
if (misses.length === 0)
|
|
938
|
+
return { available: true, checks };
|
|
939
|
+
try {
|
|
940
|
+
const repoOut = await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
|
|
941
|
+
const ownerName = parseOwnerName(repoOut);
|
|
942
|
+
if (!ownerName)
|
|
943
|
+
return { available: false, reason: 'repository handle unavailable' };
|
|
944
|
+
const runGraphql = (query, variables) => {
|
|
945
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
946
|
+
for (const [key, value] of Object.entries(variables))
|
|
947
|
+
args.push('-f', `${key}=${value}`);
|
|
948
|
+
return gh(repoRoot, args);
|
|
949
|
+
};
|
|
950
|
+
const fetched = await fetchPrChecks(runGraphql, ownerName.owner, ownerName.name, misses);
|
|
951
|
+
for (const n of misses) {
|
|
952
|
+
// Absent (unknown/closed PR) caches as `null` so a nonexistent number isn't re-queried.
|
|
953
|
+
const glyph = n in fetched ? fetched[n] : null;
|
|
954
|
+
checks[n] = glyph;
|
|
955
|
+
checksCache.set(`${repoRoot}\0${n}`, { at: now, glyph });
|
|
956
|
+
}
|
|
957
|
+
while (checksCache.size > CHECKS_CACHE_MAX) {
|
|
958
|
+
const oldest = checksCache.keys().next().value;
|
|
959
|
+
if (oldest === undefined)
|
|
960
|
+
break;
|
|
961
|
+
checksCache.delete(oldest);
|
|
962
|
+
}
|
|
963
|
+
return { available: true, checks };
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
967
|
+
return {
|
|
968
|
+
available: false,
|
|
969
|
+
reason: /ENOENT/.test(message)
|
|
970
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
971
|
+
: firstLine(message),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/** CEZ_DRY_RUN=1 — glyphs straight from the mock catalog so the offline demo shows checks. */
|
|
976
|
+
function mockGithubChecks(numbers) {
|
|
977
|
+
const byNumber = new Map(mockGithub().prs.map((p) => [p.number, (p.checks ?? null)]));
|
|
978
|
+
const checks = {};
|
|
979
|
+
for (const n of numbers)
|
|
980
|
+
checks[n] = byNumber.get(n) ?? null;
|
|
981
|
+
return { available: true, checks };
|
|
982
|
+
}
|
|
691
983
|
/**
|
|
692
984
|
* The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
|
|
693
985
|
* filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
|
|
@@ -1082,6 +1374,235 @@ export function detectGithubCached(repoRoot) {
|
|
|
1082
1374
|
}
|
|
1083
1375
|
return cached; // last-known value while revalidating; null only until the first probe warms
|
|
1084
1376
|
}
|
|
1377
|
+
const mergeStateCache = new Map();
|
|
1378
|
+
const mergeInflight = new Set();
|
|
1379
|
+
const MERGE_CACHE_MS = 15_000;
|
|
1380
|
+
function mergeCheckState(check) {
|
|
1381
|
+
const value = (check.conclusion || check.state || check.status || '').toUpperCase();
|
|
1382
|
+
if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(value))
|
|
1383
|
+
return 'passing';
|
|
1384
|
+
if (['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'CANCELLED'].includes(value))
|
|
1385
|
+
return 'failing';
|
|
1386
|
+
if (['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED', 'WAITING', 'REQUESTED'].includes(value))
|
|
1387
|
+
return 'pending';
|
|
1388
|
+
return 'unknown';
|
|
1389
|
+
}
|
|
1390
|
+
export function normalizeMergeState(raw, policyRaw, requirements = {
|
|
1391
|
+
readable: false,
|
|
1392
|
+
requiredChecks: [],
|
|
1393
|
+
}) {
|
|
1394
|
+
const pr = mergePrSchema.parse(raw);
|
|
1395
|
+
const policy = repoMergePolicySchema.parse(policyRaw);
|
|
1396
|
+
const state = pr.state.toUpperCase() === 'MERGED' ? 'merged' : pr.state.toUpperCase() === 'CLOSED' ? 'closed' : 'open';
|
|
1397
|
+
const mergeable = pr.mergeable?.toUpperCase() === 'MERGEABLE'
|
|
1398
|
+
? 'mergeable'
|
|
1399
|
+
: pr.mergeable?.toUpperCase() === 'CONFLICTING'
|
|
1400
|
+
? 'conflicting'
|
|
1401
|
+
: 'unknown';
|
|
1402
|
+
const reviewDecision = pr.reviewDecision?.toUpperCase() === 'APPROVED'
|
|
1403
|
+
? 'approved'
|
|
1404
|
+
: pr.reviewDecision?.toUpperCase() === 'CHANGES_REQUESTED'
|
|
1405
|
+
? 'changes-requested'
|
|
1406
|
+
: pr.reviewDecision?.toUpperCase() === 'REVIEW_REQUIRED'
|
|
1407
|
+
? 'review-required'
|
|
1408
|
+
: 'unknown';
|
|
1409
|
+
const checks = (pr.statusCheckRollup ?? []).map((check) => ({
|
|
1410
|
+
name: check.name,
|
|
1411
|
+
state: mergeCheckState(check),
|
|
1412
|
+
required: requirements.readable ? requirements.requiredChecks.includes(check.name) : null,
|
|
1413
|
+
...(check.detailsUrl?.startsWith('https://') || check.detailsUrl?.startsWith('http://')
|
|
1414
|
+
? { url: check.detailsUrl }
|
|
1415
|
+
: {}),
|
|
1416
|
+
}));
|
|
1417
|
+
const methods = [];
|
|
1418
|
+
if (policy.allow_squash_merge)
|
|
1419
|
+
methods.push('squash');
|
|
1420
|
+
if (policy.allow_merge_commit)
|
|
1421
|
+
methods.push('merge');
|
|
1422
|
+
if (policy.allow_rebase_merge)
|
|
1423
|
+
methods.push('rebase');
|
|
1424
|
+
const defaultMethod = policy.squash_merge_commit_title && methods.includes('squash')
|
|
1425
|
+
? 'squash'
|
|
1426
|
+
: policy.merge_commit_title && methods.includes('merge')
|
|
1427
|
+
? 'merge'
|
|
1428
|
+
: methods[0] ?? null;
|
|
1429
|
+
const blockers = [];
|
|
1430
|
+
let eligibility = 'ready';
|
|
1431
|
+
if (state !== 'open') {
|
|
1432
|
+
eligibility = 'terminal';
|
|
1433
|
+
blockers.push({ code: 'terminal', message: state === 'merged' ? 'This pull request is merged.' : 'This pull request is closed.' });
|
|
1434
|
+
}
|
|
1435
|
+
else if (pr.isDraft) {
|
|
1436
|
+
eligibility = 'blocked';
|
|
1437
|
+
blockers.push({ code: 'draft', message: 'Mark the pull request ready for review before merging.' });
|
|
1438
|
+
}
|
|
1439
|
+
else if (mergeable === 'conflicting') {
|
|
1440
|
+
eligibility = 'blocked';
|
|
1441
|
+
blockers.push({ code: 'conflicts', message: 'Conflicts must be resolved before merging.' });
|
|
1442
|
+
}
|
|
1443
|
+
else if (checks.some((check) => check.state === 'failing')) {
|
|
1444
|
+
eligibility = 'blocked';
|
|
1445
|
+
blockers.push({ code: 'checks-failing', message: 'One or more checks are failing.' });
|
|
1446
|
+
}
|
|
1447
|
+
else if (reviewDecision === 'changes-requested' || reviewDecision === 'review-required') {
|
|
1448
|
+
eligibility = 'blocked';
|
|
1449
|
+
blockers.push({ code: 'reviews', message: reviewDecision === 'changes-requested' ? 'Changes were requested.' : 'A required review is missing.' });
|
|
1450
|
+
}
|
|
1451
|
+
else if (reviewDecision === 'unknown' || !requirements.readable) {
|
|
1452
|
+
eligibility = 'unknown';
|
|
1453
|
+
blockers.push({
|
|
1454
|
+
code: 'rules-unknown',
|
|
1455
|
+
message: 'GitHub could not confirm review and branch-protection requirements.',
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
else if (checks.some((check) => check.state === 'pending') || pr.mergeStateStatus?.toUpperCase() === 'UNSTABLE') {
|
|
1459
|
+
eligibility = 'pending';
|
|
1460
|
+
blockers.push({ code: 'pending', message: 'Checks or GitHub mergeability are still pending.' });
|
|
1461
|
+
}
|
|
1462
|
+
else if (mergeable !== 'mergeable' ||
|
|
1463
|
+
!['CLEAN', 'HAS_HOOKS'].includes(pr.mergeStateStatus?.toUpperCase() ?? '') ||
|
|
1464
|
+
methods.length === 0) {
|
|
1465
|
+
eligibility = 'unknown';
|
|
1466
|
+
blockers.push({ code: 'unknown', message: 'GitHub could not confirm every merge requirement.' });
|
|
1467
|
+
}
|
|
1468
|
+
const canMerge = eligibility === 'ready';
|
|
1469
|
+
const canOverride = !canMerge &&
|
|
1470
|
+
state === 'open' &&
|
|
1471
|
+
!pr.isDraft &&
|
|
1472
|
+
mergeable !== 'conflicting' &&
|
|
1473
|
+
methods.length > 0;
|
|
1474
|
+
return {
|
|
1475
|
+
number: pr.number,
|
|
1476
|
+
title: pr.title,
|
|
1477
|
+
url: pr.url,
|
|
1478
|
+
state,
|
|
1479
|
+
isDraft: pr.isDraft,
|
|
1480
|
+
headRef: pr.headRefName,
|
|
1481
|
+
baseRef: pr.baseRefName,
|
|
1482
|
+
headSha: pr.headRefOid,
|
|
1483
|
+
mergeable,
|
|
1484
|
+
reviewDecision,
|
|
1485
|
+
checks,
|
|
1486
|
+
methods,
|
|
1487
|
+
defaultMethod,
|
|
1488
|
+
eligibility,
|
|
1489
|
+
blockers,
|
|
1490
|
+
canMerge,
|
|
1491
|
+
canOverride,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
async function fetchPrMergeState(repoRoot, repoRef, number, refresh = false) {
|
|
1495
|
+
if (process.env.CEZ_DRY_RUN === '1') {
|
|
1496
|
+
return {
|
|
1497
|
+
available: true,
|
|
1498
|
+
mergeState: normalizeMergeState({
|
|
1499
|
+
number,
|
|
1500
|
+
title: 'Dry-run pull request',
|
|
1501
|
+
url: `https://github.com/mock/repo/pull/${number}`,
|
|
1502
|
+
state: 'OPEN',
|
|
1503
|
+
isDraft: false,
|
|
1504
|
+
headRefName: 'feat/dry-run',
|
|
1505
|
+
baseRefName: 'main',
|
|
1506
|
+
headRefOid: '0123456789abcdef0123456789abcdef01234567',
|
|
1507
|
+
mergeable: 'MERGEABLE',
|
|
1508
|
+
mergeStateStatus: 'CLEAN',
|
|
1509
|
+
reviewDecision: 'APPROVED',
|
|
1510
|
+
statusCheckRollup: [{ name: 'test', conclusion: 'SUCCESS', detailsUrl: 'https://github.com/mock/repo/actions' }],
|
|
1511
|
+
}, { allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: true }, { readable: true, requiredChecks: ['test'] }),
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
if (!repoRef)
|
|
1515
|
+
return { available: false, reason: 'GitHub remote could not be resolved' };
|
|
1516
|
+
const key = `${repoRoot}:${number}`;
|
|
1517
|
+
const hit = mergeStateCache.get(key);
|
|
1518
|
+
if (!refresh && hit && Date.now() - hit.at < MERGE_CACHE_MS)
|
|
1519
|
+
return hit.value;
|
|
1520
|
+
try {
|
|
1521
|
+
const prOut = await gh(repoRoot, [
|
|
1522
|
+
'pr', 'view', String(number), '--json',
|
|
1523
|
+
'number,title,url,state,isDraft,headRefName,baseRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup',
|
|
1524
|
+
]);
|
|
1525
|
+
const parsedPr = mergePrSchema.parse(JSON.parse(prOut));
|
|
1526
|
+
const [policyOut, requiredChecks] = await Promise.all([
|
|
1527
|
+
gh(repoRoot, ['api', `repos/${repoRef.owner}/${repoRef.repo}`]),
|
|
1528
|
+
gh(repoRoot, [
|
|
1529
|
+
'api',
|
|
1530
|
+
`repos/${repoRef.owner}/${repoRef.repo}/branches/${encodeURIComponent(parsedPr.baseRefName)}/protection/required_status_checks`,
|
|
1531
|
+
'--jq',
|
|
1532
|
+
'[.contexts[]?, .checks[]?.context] | unique',
|
|
1533
|
+
])
|
|
1534
|
+
.then((output) => ({ readable: true, requiredChecks: z.array(z.string()).parse(JSON.parse(output)) }))
|
|
1535
|
+
.catch(() => ({ readable: false, requiredChecks: [] })),
|
|
1536
|
+
]);
|
|
1537
|
+
const value = {
|
|
1538
|
+
available: true,
|
|
1539
|
+
mergeState: normalizeMergeState(parsedPr, JSON.parse(policyOut), requiredChecks),
|
|
1540
|
+
};
|
|
1541
|
+
mergeStateCache.set(key, { at: Date.now(), value });
|
|
1542
|
+
return value;
|
|
1543
|
+
}
|
|
1544
|
+
catch (error) {
|
|
1545
|
+
return { available: false, reason: firstLine(error instanceof Error ? error.message : String(error)) };
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
export function evictGithubProjectCaches(repoRoot) {
|
|
1549
|
+
listCache.delete(repoRoot);
|
|
1550
|
+
mergeStateCache.forEach((_value, key) => {
|
|
1551
|
+
if (key.startsWith(`${repoRoot}:`))
|
|
1552
|
+
mergeStateCache.delete(key);
|
|
1553
|
+
});
|
|
1554
|
+
commentsCache.forEach((_value, key) => {
|
|
1555
|
+
if (key.startsWith(`${repoRoot}:`))
|
|
1556
|
+
commentsCache.delete(key);
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
async function mergePullRequest(repoRoot, repoRef, number, input) {
|
|
1560
|
+
const key = `${repoRoot}:${number}`;
|
|
1561
|
+
if (mergeInflight.has(key))
|
|
1562
|
+
return { merged: false, status: 409, error: 'A merge is already in progress.', code: 'concurrent' };
|
|
1563
|
+
mergeInflight.add(key);
|
|
1564
|
+
try {
|
|
1565
|
+
const fresh = await fetchPrMergeState(repoRoot, repoRef, number, true);
|
|
1566
|
+
if (!fresh.available)
|
|
1567
|
+
return { merged: false, status: 502, error: fresh.reason };
|
|
1568
|
+
const current = fresh.mergeState;
|
|
1569
|
+
if (current.headSha !== input.expectedHeadSha) {
|
|
1570
|
+
return { merged: false, status: 409, error: 'The pull request head changed. Review the new commits before merging.', code: 'stale-head', current };
|
|
1571
|
+
}
|
|
1572
|
+
if (!current.methods.includes(input.method)) {
|
|
1573
|
+
return { merged: false, status: 409, error: 'That merge method is no longer enabled.', code: 'disabled-method', current };
|
|
1574
|
+
}
|
|
1575
|
+
if (!mergePreflightAllowed(current, input.overrideRules)) {
|
|
1576
|
+
return { merged: false, status: 409, error: current.blockers[0]?.message ?? 'The pull request is not eligible to merge.', code: current.eligibility, current };
|
|
1577
|
+
}
|
|
1578
|
+
if (process.env.CEZ_DRY_RUN === '1') {
|
|
1579
|
+
evictGithubProjectCaches(repoRoot);
|
|
1580
|
+
return { merged: true, number, url: current.url, method: input.method, mergeCommitSha: 'abcdef0123456789abcdef0123456789abcdef01' };
|
|
1581
|
+
}
|
|
1582
|
+
if (!repoRef)
|
|
1583
|
+
return { merged: false, status: 404, error: 'GitHub repository not found.' };
|
|
1584
|
+
const out = await gh(repoRoot, [
|
|
1585
|
+
'api', '--method', 'PUT', `repos/${repoRef.owner}/${repoRef.repo}/pulls/${number}/merge`,
|
|
1586
|
+
'-f', `merge_method=${input.method}`, '-f', `sha=${input.expectedHeadSha}`,
|
|
1587
|
+
]);
|
|
1588
|
+
const result = ghMergeResultSchema.parse(JSON.parse(out));
|
|
1589
|
+
if (!result.merged)
|
|
1590
|
+
return { merged: false, status: 409, error: result.message ?? 'GitHub refused the merge.', code: 'github-blocked', current };
|
|
1591
|
+
evictGithubProjectCaches(repoRoot);
|
|
1592
|
+
return { merged: true, number, url: current.url, method: input.method, ...(result.sha ? { mergeCommitSha: result.sha } : {}) };
|
|
1593
|
+
}
|
|
1594
|
+
catch (error) {
|
|
1595
|
+
const message = firstLine(error instanceof Error ? error.message : String(error));
|
|
1596
|
+
const status = /403|permission|forbidden/i.test(message) ? 403 : /404|not found/i.test(message) ? 404 : 502;
|
|
1597
|
+
return { merged: false, status, error: status === 403 ? 'GitHub permission denied.' : status === 404 ? 'Pull request or repository not found.' : 'GitHub could not complete the merge.' };
|
|
1598
|
+
}
|
|
1599
|
+
finally {
|
|
1600
|
+
mergeInflight.delete(key);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
export function mergePreflightAllowed(current, overrideRules = false) {
|
|
1604
|
+
return current.canMerge || (overrideRules && current.canOverride);
|
|
1605
|
+
}
|
|
1085
1606
|
const GH_PR_STATES = {
|
|
1086
1607
|
MERGED: 'merged',
|
|
1087
1608
|
CLOSED: 'closed',
|
|
@@ -1093,6 +1614,7 @@ export function createGithubDriver(repoRoot, repoRef) {
|
|
|
1093
1614
|
detectCached: () => detectGithubCached(repoRoot),
|
|
1094
1615
|
listIssues: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).issues,
|
|
1095
1616
|
listPRs: async (opts) => (await fetchGithub(repoRoot, opts?.refresh, opts?.limit)).prs,
|
|
1617
|
+
prDiff: (number, opts) => fetchGithubPrDiff(repoRoot, number, opts?.refresh),
|
|
1096
1618
|
createPR: (input) => createDraftPr(input),
|
|
1097
1619
|
// Null covers everything from "no PR yet" to "gh missing" — the callers
|
|
1098
1620
|
// (Create PR → View PR flip) treat all of it as "nothing to link".
|
|
@@ -1114,6 +1636,8 @@ export function createGithubDriver(repoRoot, repoRef) {
|
|
|
1114
1636
|
return null;
|
|
1115
1637
|
}
|
|
1116
1638
|
},
|
|
1639
|
+
prMergeState: (number, opts) => fetchPrMergeState(repoRoot, repoRef, number, opts?.refresh),
|
|
1640
|
+
mergePR: (number, input) => mergePullRequest(repoRoot, repoRef, number, input),
|
|
1117
1641
|
viewUrl: (kind, ref) => {
|
|
1118
1642
|
if (!repoRef)
|
|
1119
1643
|
return null;
|