@open-mercato/cezar 0.9.2 → 0.10.0-pr892.1298
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 +21 -0
- package/README.md +169 -38
- package/dist/agent-config/account-identity.d.ts +44 -0
- package/dist/agent-config/account-identity.js +128 -0
- package/dist/agent-config/account-identity.js.map +1 -0
- package/dist/agent-config/catalog.d.ts +8 -0
- package/dist/agent-config/catalog.js +18 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/model-settings/claude.d.ts +2 -0
- package/dist/agent-config/model-settings/claude.js +11 -0
- package/dist/agent-config/model-settings/claude.js.map +1 -0
- package/dist/agent-config/model-settings/codex.d.ts +2 -0
- package/dist/agent-config/model-settings/codex.js +16 -0
- package/dist/agent-config/model-settings/codex.js.map +1 -0
- package/dist/agent-config/model-settings/opencode.d.ts +2 -0
- package/dist/agent-config/model-settings/opencode.js +8 -0
- package/dist/agent-config/model-settings/opencode.js.map +1 -0
- package/dist/agent-config/model-settings/pi.d.ts +12 -0
- package/dist/agent-config/model-settings/pi.js +18 -0
- package/dist/agent-config/model-settings/pi.js.map +1 -0
- package/dist/agent-config/model-settings/shared.d.ts +9 -0
- package/dist/agent-config/model-settings/shared.js +93 -0
- package/dist/agent-config/model-settings/shared.js.map +1 -0
- package/dist/agent-config/model-settings/types.d.ts +9 -0
- package/dist/agent-config/model-settings/types.js +2 -0
- package/dist/agent-config/model-settings/types.js.map +1 -0
- package/dist/agent-config/models.d.ts +9 -0
- package/dist/agent-config/models.js +32 -0
- package/dist/agent-config/models.js.map +1 -0
- package/dist/agent-config/service.js +4 -4
- package/dist/agent-config/service.js.map +1 -1
- package/dist/automations/types.d.ts +4 -0
- package/dist/automations/types.js +2 -1
- package/dist/automations/types.js.map +1 -1
- package/dist/config.d.ts +10 -1
- package/dist/config.js +48 -5
- package/dist/config.js.map +1 -1
- package/dist/contract/agent-config.d.ts +2 -0
- package/dist/contract/agent-profiles.d.ts +347 -0
- package/dist/contract/automations.d.ts +24 -0
- package/dist/contract/events.d.ts +59 -0
- package/dist/contract/github.d.ts +88 -0
- package/dist/contract/health.d.ts +7 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +1005 -555
- package/dist/contract/projects.d.ts +33 -5
- package/dist/contract/runs.d.ts +247 -0
- package/dist/contract/skills.d.ts +7 -0
- package/dist/contract/workflows.d.ts +6 -0
- package/dist/contract/workspace.d.ts +137 -2
- package/dist/core/agent-env.js +41 -16
- package/dist/core/agent-env.js.map +1 -1
- package/dist/core/agent-model-policy.d.ts +10 -0
- package/dist/core/agent-model-policy.js +36 -0
- package/dist/core/agent-model-policy.js.map +1 -0
- package/dist/core/agent-profiles.d.ts +62 -0
- package/dist/core/agent-profiles.js +90 -0
- package/dist/core/agent-profiles.js.map +1 -0
- package/dist/core/agent-runner.d.ts +37 -6
- package/dist/core/agent-runner.js +35 -3
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/backend-detect.d.ts +5 -5
- package/dist/core/backend-detect.js +44 -7
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.js +10 -5
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.js +13 -3
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/codex-app-server-transport.js +7 -2
- package/dist/core/codex-app-server-transport.js.map +1 -1
- package/dist/core/model-identity.d.ts +23 -11
- package/dist/core/model-identity.js +41 -24
- package/dist/core/model-identity.js.map +1 -1
- package/dist/core/model-presets.d.ts +8 -2
- package/dist/core/model-presets.js +51 -8
- package/dist/core/model-presets.js.map +1 -1
- package/dist/core/opencode-model-catalog.d.ts +32 -0
- package/dist/core/opencode-model-catalog.js +153 -0
- package/dist/core/opencode-model-catalog.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +2 -0
- package/dist/core/opencode-server-runner.js +42 -11
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/core/pi-runner.d.ts +24 -0
- package/dist/core/pi-runner.js +349 -0
- package/dist/core/pi-runner.js.map +1 -0
- package/dist/core/pi-ui-mapper.d.ts +34 -0
- package/dist/core/pi-ui-mapper.js +270 -0
- package/dist/core/pi-ui-mapper.js.map +1 -0
- package/dist/core/provider-auth.d.ts +95 -3
- package/dist/core/provider-auth.js +234 -15
- package/dist/core/provider-auth.js.map +1 -1
- package/dist/core/provider-availability.d.ts +1 -1
- package/dist/core/provider-availability.js +2 -1
- package/dist/core/provider-availability.js.map +1 -1
- package/dist/core/runner-factory.js +3 -0
- package/dist/core/runner-factory.js.map +1 -1
- package/dist/core/shell-env.d.ts +28 -0
- package/dist/core/shell-env.js +56 -0
- package/dist/core/shell-env.js.map +1 -0
- package/dist/core/ui-events.d.ts +1 -1
- package/dist/core/usage-limit.d.ts +42 -0
- package/dist/core/usage-limit.js +209 -0
- package/dist/core/usage-limit.js.map +1 -0
- package/dist/git-diff-base.d.ts +51 -26
- package/dist/git-diff-base.js +163 -37
- package/dist/git-diff-base.js.map +1 -1
- package/dist/git-worktree.d.ts +15 -6
- package/dist/git-worktree.js +10 -3
- package/dist/git-worktree.js.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +53 -5
- package/dist/paths.js +63 -9
- package/dist/paths.js.map +1 -1
- package/dist/planner.js +6 -0
- package/dist/planner.js.map +1 -1
- package/dist/release/snapshot.d.ts +13 -2
- package/dist/release/snapshot.js +25 -1
- package/dist/release/snapshot.js.map +1 -1
- package/dist/runs/agent-tmpdir.d.ts +41 -0
- package/dist/runs/agent-tmpdir.js +183 -0
- package/dist/runs/agent-tmpdir.js.map +1 -0
- package/dist/runs/auto-name.js +6 -1
- package/dist/runs/auto-name.js.map +1 -1
- package/dist/runs/event-history.d.ts +50 -0
- package/dist/runs/event-history.js +613 -0
- package/dist/runs/event-history.js.map +1 -0
- package/dist/runs/run-index.d.ts +19 -0
- package/dist/runs/run-index.js +43 -0
- package/dist/runs/run-index.js.map +1 -0
- package/dist/runs/store.d.ts +78 -19
- package/dist/runs/store.js +175 -43
- package/dist/runs/store.js.map +1 -1
- package/dist/server/capabilities.d.ts +15 -1
- package/dist/server/capabilities.js +16 -1
- package/dist/server/capabilities.js.map +1 -1
- package/dist/server/forge/github.d.ts +169 -0
- package/dist/server/forge/github.js +511 -0
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/index.d.ts +9 -0
- package/dist/server/forge/index.js +14 -0
- package/dist/server/forge/index.js.map +1 -1
- package/dist/server/git-changes.d.ts +7 -5
- package/dist/server/git-changes.js +15 -10
- package/dist/server/git-changes.js.map +1 -1
- package/dist/server/github.d.ts +2 -2
- package/dist/server/github.js +1 -1
- package/dist/server/github.js.map +1 -1
- package/dist/server/open-in-app.d.ts +2 -1
- package/dist/server/open-in-app.js +3 -1
- package/dist/server/open-in-app.js.map +1 -1
- package/dist/server/open-in-terminal.d.ts +46 -1
- package/dist/server/open-in-terminal.js +72 -13
- package/dist/server/open-in-terminal.js.map +1 -1
- package/dist/server/provider-action-gate.js +3 -1
- package/dist/server/provider-action-gate.js.map +1 -1
- package/dist/server/server.d.ts +2206 -434
- package/dist/server/server.js +1219 -136
- package/dist/server/server.js.map +1 -1
- package/dist/server-install/engine.js +5 -18
- package/dist/server-install/engine.js.map +1 -1
- package/dist/server-install/steps.js +6 -2
- package/dist/server-install/steps.js.map +1 -1
- package/dist/skills.js +4 -0
- package/dist/skills.js.map +1 -1
- package/dist/workflows/run.d.ts +229 -9
- package/dist/workflows/run.js +877 -147
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +3 -0
- package/dist/workflows/types.js +11 -2
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/agent-accounts.d.ts +153 -0
- package/dist/workspace/agent-accounts.js +304 -0
- package/dist/workspace/agent-accounts.js.map +1 -0
- package/dist/workspace/agent-profiles.d.ts +78 -0
- package/dist/workspace/agent-profiles.js +115 -0
- package/dist/workspace/agent-profiles.js.map +1 -0
- package/dist/workspace/config.d.ts +27 -1
- package/dist/workspace/config.js +83 -2
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/projects-cli.js +54 -4
- package/dist/workspace/projects-cli.js.map +1 -1
- package/dist/workspace/projects.d.ts +19 -1
- package/dist/workspace/projects.js +34 -1
- package/dist/workspace/projects.js.map +1 -1
- package/dist/workspace/semaphore.d.ts +46 -1
- package/dist/workspace/semaphore.js +41 -4
- package/dist/workspace/semaphore.js.map +1 -1
- package/dist/workspace/ui-state.d.ts +3 -2
- package/dist/workspace/ui-state.js +3 -2
- package/dist/workspace/ui-state.js.map +1 -1
- package/package.json +3 -3
- package/scripts/mock-claude.mjs +20 -0
- package/scripts/mock-pi-rpc.mjs +83 -0
- package/web/dist/assets/{alert-dialog-ghffK6g9.js → alert-dialog-Byp42_IG.js} +1 -1
- package/web/dist/assets/arrow-down-CGDbDYKD.js +1 -0
- package/web/dist/assets/arrow-left-ESCjr1Hq.js +1 -0
- package/web/dist/assets/centered-state-LfAW-igm.js +43 -0
- package/web/dist/assets/chevron-right-Du-P1IUd.js +1 -0
- package/web/dist/assets/{collapsible-B0JVzd0B.js → collapsible-CBiiqDJM.js} +1 -1
- package/web/dist/assets/{commit-list-wm6DPHNs.js → commit-list-6PPve7PE.js} +1 -1
- package/web/dist/assets/{compare-variants-DbdP4Y5T.js → compare-variants-BDnp6mXJ.js} +1 -1
- package/web/dist/assets/{diff-Bizc96qe.js → diff-_jK0OWpF.js} +2 -2
- package/web/dist/assets/{diff-stat-Dyfg3ltF.js → diff-stat-CIrTu4Ym.js} +1 -1
- package/web/dist/assets/{diff-view-CG8ek6tC.js → diff-view-COA_5IyY.js} +1 -1
- package/web/dist/assets/dropdown-menu-BofYpFFw.js +1 -0
- package/web/dist/assets/editable-title-_J0zFDl-.js +1 -0
- package/web/dist/assets/{ellipsis-vertical-DpGTRqC2.js → ellipsis-vertical-BeMR-l62.js} +1 -1
- package/web/dist/assets/{file-DlUGpUlb.js → file-DY996Pee.js} +1 -1
- package/web/dist/assets/{folder-BAY6Vddy.js → folder-NO3Ec-TY.js} +1 -1
- package/web/dist/assets/git-toolbar-BqJeUWcD.js +1 -0
- package/web/dist/assets/github-C96HPV9E.js +1 -0
- package/web/dist/assets/{image-preview-BWGk5fPX.js → image-preview-D9cCDfKQ.js} +1 -1
- package/web/dist/assets/index-Bx-grqa9.css +2 -0
- package/web/dist/assets/index-DoVvGDfW.js +7 -0
- package/web/dist/assets/{markdown-BGBOGCA_.js → markdown-DDzk11zj.js} +1 -1
- package/web/dist/assets/new-task-form-BNLLgC6P.js +1 -0
- package/web/dist/assets/pill-CVM1EmV4.js +1 -0
- package/web/dist/assets/{project-router-Cx8NPoqn.js → project-router-LRCtP48W.js} +1 -1
- package/web/dist/assets/prompt-templates-DFOvYgtW.js +15 -0
- package/web/dist/assets/{refresh-cw-BoGdAykg.js → refresh-cw-D1ys6QY2.js} +1 -1
- package/web/dist/assets/repo-git-CzUnsgWY.js +1 -0
- package/web/dist/assets/{run-diff-EcOkf3ti.js → run-diff-BlnqwfSS.js} +2 -2
- package/web/dist/assets/run-header-Bn7cN0Z1.js +1 -0
- package/web/dist/assets/{search-x-X3EpVhgs.js → search-x-D6FSpxId.js} +1 -1
- package/web/dist/assets/{skill-empty-hint-CZLALx6l.js → skill-empty-hint-iwpCcqgI.js} +1 -1
- package/web/dist/assets/skills-CezbOaol.js +1 -0
- package/web/dist/assets/{sparkles-Dt4q7pbG.js → sparkles-MrQrgCI9.js} +1 -1
- package/web/dist/assets/{square-terminal-hwBL8XsC.js → square-terminal-TTH64lO7.js} +1 -1
- package/web/dist/assets/tab-link-FYksz037.js +1 -0
- package/web/dist/assets/task-changes-ClWlnzBL.js +1 -0
- package/web/dist/assets/{task-commits-BTTA5k5T.js → task-commits-Crbrl86b.js} +1 -1
- package/web/dist/assets/{task-files-Da8_4C9g.js → task-files-uSCWyD0-.js} +2 -2
- package/web/dist/assets/task-thread-DPmamBA1.js +9 -0
- package/web/dist/assets/{textarea-CUdokhkv.js → textarea-B9Ahj4E4.js} +1 -1
- package/web/dist/assets/thread-loading-BH_ypQoy.js +1 -0
- package/web/dist/assets/{trash-2-DhVIdWv8.js → trash-2-D8S3V3fu.js} +1 -1
- package/web/dist/assets/{triangle-alert-XYT0DGCI.js → triangle-alert-bB7p9n5E.js} +1 -1
- package/web/dist/assets/{upload-BKjN59-1.js → upload-Bd5oRLPV.js} +1 -1
- package/web/dist/assets/{use-desktop-Bf5LS_P4.js → use-desktop-BNvbRBeZ.js} +1 -1
- package/web/dist/assets/{use-submit-shortcut-CzVyoAMd.js → use-submit-shortcut-B-xhu4Bf.js} +1 -1
- package/web/dist/assets/utils-BhpbVMwN.js +64 -0
- package/web/dist/assets/{workflows-o2QU_pvU.js → workflows-RmfbM9Vy.js} +3 -3
- package/web/dist/assets/{zoomable-image-7WyUiaVD.js → zoomable-image-BCW6ZBV0.js} +1 -1
- package/web/dist/index.html +24 -23
- package/web/dist/assets/arrow-down-Cz8qb71e.js +0 -1
- package/web/dist/assets/arrow-left-wcYKQXI_.js +0 -1
- package/web/dist/assets/centered-state-ZtnFHA-n.js +0 -43
- package/web/dist/assets/chevron-right-G4caF2QV.js +0 -1
- package/web/dist/assets/circle-check--KvBxXWp.js +0 -1
- package/web/dist/assets/circle-x-C3EFZ3xf.js +0 -1
- package/web/dist/assets/dropdown-menu-C1Dlf35P.js +0 -1
- package/web/dist/assets/editable-title-5rC_QRBZ.js +0 -1
- package/web/dist/assets/git-pull-request-agOG0iGM.js +0 -1
- package/web/dist/assets/git-toolbar-DGiNMmtY.js +0 -1
- package/web/dist/assets/github-Vp7lxYGQ.js +0 -1
- package/web/dist/assets/index-DSxyJpuK.css +0 -2
- package/web/dist/assets/index-DiawI6RX.js +0 -6
- package/web/dist/assets/new-task-form-BpAw_Dks.js +0 -1
- package/web/dist/assets/pill-Dvs-P0xy.js +0 -1
- package/web/dist/assets/prompt-templates-CSBYBR1I.js +0 -15
- package/web/dist/assets/repo-git-PmX-BNMo.js +0 -1
- package/web/dist/assets/run-header-BGiv6_qX.js +0 -1
- package/web/dist/assets/skills-CC91ouGH.js +0 -1
- package/web/dist/assets/tab-link-DIlR5T07.js +0 -1
- package/web/dist/assets/task-changes-XkHDxzQA.js +0 -1
- package/web/dist/assets/task-thread-DoNXUsHr.js +0 -9
- package/web/dist/assets/thread-loading-B8q8ukXJ.js +0 -1
- package/web/dist/assets/utils-DsLuRXwV.js +0 -64
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { REFERENCE_STATUS_MAX } from '../../contract/index.js';
|
|
4
5
|
import { autosaveCommit } from '../../git-worktree.js';
|
|
5
6
|
/**
|
|
6
7
|
* The GitHub forge driver — all `gh`-CLI logic in one place, moved here from
|
|
@@ -980,6 +981,516 @@ function mockGithubChecks(numbers) {
|
|
|
980
981
|
checks[n] = byNumber.get(n) ?? null;
|
|
981
982
|
return { available: true, checks };
|
|
982
983
|
}
|
|
984
|
+
/** Numbers per kind in one ref-status query — the same bound, and for the same reasons, as
|
|
985
|
+
* `GH_CHECKS_MAX`: aliases resolve independently, and the query size stays finite. Taken from the
|
|
986
|
+
* contract rather than restated, because the cockpit caps its batches by the same number and a
|
|
987
|
+
* client that guessed high would have every chip in a batch 400 instead of losing its tail. */
|
|
988
|
+
export const GH_REF_STATUS_MAX = REFERENCE_STATUS_MAX;
|
|
989
|
+
const ghRefStatusPrSchema = z
|
|
990
|
+
.object({
|
|
991
|
+
state: z.string(),
|
|
992
|
+
isDraft: z.boolean().nullish(),
|
|
993
|
+
reviewDecision: z.string().nullish(),
|
|
994
|
+
commits: z
|
|
995
|
+
.object({
|
|
996
|
+
nodes: z.array(z.object({
|
|
997
|
+
commit: z.object({
|
|
998
|
+
committedDate: z.string().nullish(),
|
|
999
|
+
statusCheckRollup: z.object({ state: z.string().nullish() }).nullish(),
|
|
1000
|
+
}),
|
|
1001
|
+
})),
|
|
1002
|
+
})
|
|
1003
|
+
.nullish(),
|
|
1004
|
+
reviews: z.object({ nodes: z.array(z.object({ submittedAt: z.string().nullish() })) }).nullish(),
|
|
1005
|
+
reviewRequests: z.object({ totalCount: z.number() }).nullish(),
|
|
1006
|
+
})
|
|
1007
|
+
.nullish();
|
|
1008
|
+
const ghRefStatusIssueSchema = z.object({ state: z.string(), stateReason: z.string().nullish() }).nullish();
|
|
1009
|
+
// The aliases hold two DIFFERENT shapes, so the record stays unvalidated here and each alias is
|
|
1010
|
+
// parsed by its own schema below. A union at this level would be wrong, not merely loose: zod
|
|
1011
|
+
// strips unknown keys, so a PR node matched by the issue arm would come back with `isDraft` and
|
|
1012
|
+
// `commits` silently removed.
|
|
1013
|
+
const ghRefStatusSchema = z.record(z.string(), z.unknown());
|
|
1014
|
+
/**
|
|
1015
|
+
* One alias per NUMBER, asking `issueOrPullRequest` rather than `pullRequest`/`issue`.
|
|
1016
|
+
*
|
|
1017
|
+
* Issues and pull requests share one numbering space in a repository, so a number is exactly one
|
|
1018
|
+
* of the two and asking which is a question the forge can answer — where asking `issue(number:)`
|
|
1019
|
+
* about a pull request is a question with no answer, and GitHub says so with a NOT_FOUND *error*
|
|
1020
|
+
* rather than a null. That mattered far more than it looks: `gh api graphql` exits non-zero the
|
|
1021
|
+
* moment a response carries an `errors` array, so one mis-guessed kind used to fail the whole
|
|
1022
|
+
* batch and report every reference in it as "not found" (see `refStatusGraphql`).
|
|
1023
|
+
*
|
|
1024
|
+
* It also fixes the reference whose kind the cockpit guessed wrong — `taskReferences` infers it
|
|
1025
|
+
* from which field carried the number, and a bare `#774` can land in either — because the answer
|
|
1026
|
+
* carries `__typename` and is filed under what the number REALLY is.
|
|
1027
|
+
*
|
|
1028
|
+
* `committedDate` and the last CHANGES_REQUESTED review's `submittedAt` cost nothing extra, riding
|
|
1029
|
+
* the same node, and are what let the precedence tell a review the author has already responded to
|
|
1030
|
+
* from one still about the code on screen.
|
|
1031
|
+
*/
|
|
1032
|
+
function refStatusQuery(numbers) {
|
|
1033
|
+
const aliases = numbers
|
|
1034
|
+
.map((n, i) => ` r${i}: issueOrPullRequest(number: ${n}) { __typename ... on PullRequest { state isDraft reviewDecision commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } } reviews(last: 1, states: CHANGES_REQUESTED) { nodes { submittedAt } } reviewRequests(first: 1) { totalCount } } ... on Issue { state stateReason } }`)
|
|
1035
|
+
.join('\n');
|
|
1036
|
+
return `query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n${aliases}\n }\n}`;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* The one place a PR's signals collapse into a single word.
|
|
1040
|
+
*
|
|
1041
|
+
* The question the chip answers is "what is this waiting on RIGHT NOW", so the ranking is by
|
|
1042
|
+
* freshness of the signal, not by how heavy a blocker it is:
|
|
1043
|
+
*
|
|
1044
|
+
* Read the ranking as **whose move is it**, which is the question a table is scanned for:
|
|
1045
|
+
*
|
|
1046
|
+
* 1. `merged` / `closed` — nobody's. Terminal, whatever checks or reviews say.
|
|
1047
|
+
* 2. `draft` — the author's, and they have said so themselves.
|
|
1048
|
+
* 3. `checks-pending` — the machine's. CI running means a commit was JUST pushed, the newest
|
|
1049
|
+
* thing that has happened to this PR, so it outranks a requested change unconditionally.
|
|
1050
|
+
* 4. `changes-requested` — the AUTHOR's, and only while that is still true: the review must be
|
|
1051
|
+
* about the code that is there now, with no re-review already asked for.
|
|
1052
|
+
* 5. `checks-failing` — the author's again. A reviewer cannot approve a red PR anyway.
|
|
1053
|
+
* 6. `review-required` — the REVIEWER's: they have been asked, or the author has answered and
|
|
1054
|
+
* the merge is now blocked on someone coming back to look.
|
|
1055
|
+
* 7. `ready` — open, not a draft, nothing failing, nothing running, nobody waited on.
|
|
1056
|
+
*
|
|
1057
|
+
* The subtle one is (4) → (6), and it is not a preference but a data finding. `reviewDecision`
|
|
1058
|
+
* stays `CHANGES_REQUESTED` after the author has responded — GitHub does not clear it until a
|
|
1059
|
+
* reviewer submits again — so on its own it points at the author forever. Two signals say the ball
|
|
1060
|
+
* has moved back:
|
|
1061
|
+
*
|
|
1062
|
+
* - **a pending review request** (`reviewRequests.totalCount`), which is the author clicking
|
|
1063
|
+
* re-request. Authoritative, and observed live alongside a stale `CHANGES_REQUESTED` and an
|
|
1064
|
+
* EMPTY `latestReviews` — the case that has no other tell.
|
|
1065
|
+
* - **a commit newer than the review**, the fallback for an author who pushed without clicking
|
|
1066
|
+
* anything.
|
|
1067
|
+
*
|
|
1068
|
+
* Either way the words change from "you owe edits" to "they owe a look", and so does the colour:
|
|
1069
|
+
* danger is the author's move, info is the reviewer's.
|
|
1070
|
+
*
|
|
1071
|
+
* Both timestamps are optional and their ABSENCE is conservative: with no dates to compare and no
|
|
1072
|
+
* re-request, a review counts as current, and the chip keeps pointing at the author.
|
|
1073
|
+
*
|
|
1074
|
+
* `reviewDecision` is null on a repo with no review policy, which is exactly why `ready` is not
|
|
1075
|
+
* spelled "approved": on such a repo a green PR IS ready, and no approval will ever arrive — but a
|
|
1076
|
+
* requested reviewer still moves it to `review-required`, because someone was explicitly asked.
|
|
1077
|
+
*/
|
|
1078
|
+
export function derivePrReferenceStatus(pr) {
|
|
1079
|
+
const state = pr.state.toUpperCase();
|
|
1080
|
+
if (state === 'MERGED')
|
|
1081
|
+
return 'merged';
|
|
1082
|
+
if (state === 'CLOSED')
|
|
1083
|
+
return 'closed';
|
|
1084
|
+
if (pr.isDraft)
|
|
1085
|
+
return 'draft';
|
|
1086
|
+
if (pr.checks === 'pending')
|
|
1087
|
+
return 'checks-pending';
|
|
1088
|
+
const decision = (pr.reviewDecision ?? '').toUpperCase();
|
|
1089
|
+
const changesRequested = decision === 'CHANGES_REQUESTED';
|
|
1090
|
+
// Has the author already answered the review — by asking for another look, or by pushing?
|
|
1091
|
+
const answered = pr.reviewRequested === true || pushedSince(pr.headCommittedAt, pr.changesRequestedAt);
|
|
1092
|
+
if (changesRequested && !answered)
|
|
1093
|
+
return 'changes-requested';
|
|
1094
|
+
if (pr.checks === 'failing')
|
|
1095
|
+
return 'checks-failing';
|
|
1096
|
+
// `APPROVED` is the forge saying the review requirement IS MET, and it outranks a pending
|
|
1097
|
+
// request: a reviewer left on the list after someone else approved is a courtesy ask, not an
|
|
1098
|
+
// unmet gate. (A repo needing two approvals reports `REVIEW_REQUIRED` until it has both, so
|
|
1099
|
+
// `APPROVED` never arrives early.) Without this, an approved, green, mergeable pull request
|
|
1100
|
+
// reads "waiting for review" for as long as anyone stays listed — which is indefinitely.
|
|
1101
|
+
if (decision === 'APPROVED')
|
|
1102
|
+
return 'ready';
|
|
1103
|
+
if (changesRequested || decision === 'REVIEW_REQUIRED' || pr.reviewRequested === true) {
|
|
1104
|
+
return 'review-required';
|
|
1105
|
+
}
|
|
1106
|
+
return 'ready';
|
|
1107
|
+
}
|
|
1108
|
+
/** Did a commit land AFTER the review? Unparseable or missing dates answer `false` — the
|
|
1109
|
+
* conservative direction, since it keeps a review current rather than silently demoting one. */
|
|
1110
|
+
function pushedSince(headCommittedAt, reviewedAt) {
|
|
1111
|
+
if (!headCommittedAt || !reviewedAt)
|
|
1112
|
+
return false;
|
|
1113
|
+
const head = Date.parse(headCommittedAt);
|
|
1114
|
+
const reviewed = Date.parse(reviewedAt);
|
|
1115
|
+
return Number.isFinite(head) && Number.isFinite(reviewed) && head > reviewed;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* An issue's two signals as one word. `NOT_PLANNED` is kept apart from `completed` because they
|
|
1119
|
+
* are opposite outcomes — "we did it" vs "we won't" — and a task whose issue was declined must
|
|
1120
|
+
* not read as a task that landed.
|
|
1121
|
+
*/
|
|
1122
|
+
export function deriveIssueReferenceStatus(issue) {
|
|
1123
|
+
if (issue.state.toUpperCase() !== 'CLOSED')
|
|
1124
|
+
return 'open';
|
|
1125
|
+
return (issue.stateReason ?? '').toUpperCase() === 'NOT_PLANNED' ? 'not-planned' : 'completed';
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Status per NUMBER, resolved to whatever that number actually is.
|
|
1129
|
+
*
|
|
1130
|
+
* Batched and aliased like `fetchPrChecks`: one subprocess per chunk, each alias resolving
|
|
1131
|
+
* independently, and a number the forge does not have simply staying absent from `resolved` (its
|
|
1132
|
+
* chip then renders neutral, as it did before this seam existed). A failed chunk costs only its
|
|
1133
|
+
* own numbers, and says so rather than letting them look absent. Exported for tests; `runGraphql`
|
|
1134
|
+
* is injected so this is testable without shelling out.
|
|
1135
|
+
*/
|
|
1136
|
+
export async function fetchRefStatuses(runGraphql, owner, name, numbers, chunkSize = GH_REF_STATUS_MAX) {
|
|
1137
|
+
const out = { resolved: {}, failed: [] };
|
|
1138
|
+
if (numbers.length === 0)
|
|
1139
|
+
return out;
|
|
1140
|
+
for (let i = 0; i < numbers.length; i += chunkSize) {
|
|
1141
|
+
const chunk = numbers.slice(i, i + chunkSize);
|
|
1142
|
+
try {
|
|
1143
|
+
const raw = JSON.parse(await runGraphql(refStatusQuery(chunk), { owner, name }));
|
|
1144
|
+
const repository = ghRefStatusSchema.parse(raw?.data?.repository ?? {});
|
|
1145
|
+
chunk.forEach((number, index) => {
|
|
1146
|
+
const node = repository[`r${index}`];
|
|
1147
|
+
// Absent alias → the number is not in this repository at all. Left out of the map, which
|
|
1148
|
+
// is what the route reports as "not found" and the chip paints as neutral.
|
|
1149
|
+
if (!node)
|
|
1150
|
+
return;
|
|
1151
|
+
if (node.__typename === 'PullRequest') {
|
|
1152
|
+
const pr = ghRefStatusPrSchema.parse(node);
|
|
1153
|
+
if (!pr)
|
|
1154
|
+
return;
|
|
1155
|
+
const head = pr.commits?.nodes[0]?.commit;
|
|
1156
|
+
const rollup = head?.statusCheckRollup;
|
|
1157
|
+
out.resolved[number] = {
|
|
1158
|
+
kind: 'pr',
|
|
1159
|
+
status: derivePrReferenceStatus({
|
|
1160
|
+
state: pr.state,
|
|
1161
|
+
isDraft: pr.isDraft,
|
|
1162
|
+
reviewDecision: pr.reviewDecision,
|
|
1163
|
+
// Adapt the single rollup state into the array shape `rollupToChecks` expects,
|
|
1164
|
+
// reusing the FAILURE/PENDING/SUCCESS vocabulary rather than duplicating it.
|
|
1165
|
+
checks: rollup ? rollupToChecks([{ state: rollup.state, status: null, conclusion: null }]) ?? null : null,
|
|
1166
|
+
headCommittedAt: head?.committedDate,
|
|
1167
|
+
// `reviews(last: 1, states: CHANGES_REQUESTED)` — the timestamp only. WHETHER changes
|
|
1168
|
+
// are requested stays `reviewDecision`'s answer, which is the one that accounts for
|
|
1169
|
+
// dismissed and superseded reviews.
|
|
1170
|
+
changesRequestedAt: pr.reviews?.nodes[0]?.submittedAt,
|
|
1171
|
+
reviewRequested: (pr.reviewRequests?.totalCount ?? 0) > 0,
|
|
1172
|
+
}),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
else if (node.__typename === 'Issue') {
|
|
1176
|
+
const issue = ghRefStatusIssueSchema.parse(node);
|
|
1177
|
+
if (!issue)
|
|
1178
|
+
return;
|
|
1179
|
+
out.resolved[number] = { kind: 'issue', status: deriveIssueReferenceStatus(issue) };
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
catch (err) {
|
|
1184
|
+
// A failed chunk costs only its own numbers; the rest still resolve. They are recorded as
|
|
1185
|
+
// FAILED rather than left absent, so nothing downstream mistakes them for "no such number".
|
|
1186
|
+
out.failed.push(...chunk);
|
|
1187
|
+
out.reason ??= firstLine(err instanceof Error ? err.message : String(err));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return out;
|
|
1191
|
+
}
|
|
1192
|
+
// Per-reference cache: keyed `repoRoot␀number` — by NUMBER, not by kind, because the kind is now
|
|
1193
|
+
// something the forge answers rather than something the caller asserts. Same 60 s TTL and bounded
|
|
1194
|
+
// shape as the checks cache; `null` is a cached "this repository has no such number", so a
|
|
1195
|
+
// transcript-scraped number from another repo is not re-queried on every table repaint.
|
|
1196
|
+
const refStatusCache = new Map();
|
|
1197
|
+
const REF_STATUS_CACHE_MAX = 500;
|
|
1198
|
+
/** Test-only: drop the per-reference cache so cases don't leak state into each other. */
|
|
1199
|
+
export function __clearRefStatusCacheForTests() {
|
|
1200
|
+
refStatusCache.clear();
|
|
1201
|
+
}
|
|
1202
|
+
/** Test-only: warm the cache the way the lazy route would have, so a reader can be tested
|
|
1203
|
+
* without a forge behind it. */
|
|
1204
|
+
export function __seedRefStatusCacheForTests(repoRoot, entries) {
|
|
1205
|
+
for (const [number, resolved] of entries) {
|
|
1206
|
+
refStatusCache.set(refStatusKey(repoRoot, number), { at: Date.now(), resolved });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Forget what we knew about one reference, so the next read asks GitHub again.
|
|
1211
|
+
*
|
|
1212
|
+
* Called where cezar itself CHANGES a pull request — it merges one, it opens one — because those
|
|
1213
|
+
* are the only forge changes this process can know about without asking. Everything else has to
|
|
1214
|
+
* be polled (GitHub cannot push to a cockpit with no public endpoint), but waiting out a TTL to
|
|
1215
|
+
* notice our own merge is a self-inflicted staleness: for up to a minute every chip would keep
|
|
1216
|
+
* showing the pre-merge status of a pull request the user watched this server merge.
|
|
1217
|
+
*
|
|
1218
|
+
* Deleting rather than overwriting with a guessed `merged`: the forge is the authority on what a
|
|
1219
|
+
* reference is, and a mutation that reports success is still not the same as having read the
|
|
1220
|
+
* result. The next reader pays one query and gets the truth — after which the answer is `merged`,
|
|
1221
|
+
* `recheckAfterMs` goes null, and the cockpit stops polling that batch entirely. Invalidating here
|
|
1222
|
+
* therefore REDUCES long-run traffic rather than adding to it.
|
|
1223
|
+
*/
|
|
1224
|
+
export function forgetRefStatus(repoRoot, number) {
|
|
1225
|
+
refStatusCache.delete(refStatusKey(repoRoot, number));
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Everything the cache ALREADY knows about these numbers. Never spawns `gh`, never awaits.
|
|
1229
|
+
*
|
|
1230
|
+
* This is what lets a status ride along with the rows that carry the references, instead of the
|
|
1231
|
+
* cockpit fetching it separately a moment later: the run index reads whatever is warm and ships
|
|
1232
|
+
* it, and a cold entry is simply absent — the lazy `/github/ref-status` route stays the thing that
|
|
1233
|
+
* actually goes and asks.
|
|
1234
|
+
*
|
|
1235
|
+
* Because it cannot cost anything, the caller may pass a SUPERSET of the numbers it will really
|
|
1236
|
+
* display. That matters: deciding which of a run's references a chip shows is the cockpit's rule
|
|
1237
|
+
* (#407, #526), deliberately not duplicated server-side, and a cache read does not need to know —
|
|
1238
|
+
* it can look up every number a run mentions and let the client pick.
|
|
1239
|
+
*/
|
|
1240
|
+
export function readCachedRefStatuses(repoRoot, numbers) {
|
|
1241
|
+
const out = { prs: {}, issues: {} };
|
|
1242
|
+
const now = Date.now();
|
|
1243
|
+
for (const number of new Set(numbers)) {
|
|
1244
|
+
const hit = refStatusCache.get(refStatusKey(repoRoot, number));
|
|
1245
|
+
if (!hit || !hit.resolved || now - hit.at >= refStatusTtl(hit.resolved))
|
|
1246
|
+
continue;
|
|
1247
|
+
out[hit.resolved.kind === 'pr' ? 'prs' : 'issues'][number] = hit.resolved.status;
|
|
1248
|
+
}
|
|
1249
|
+
return out;
|
|
1250
|
+
}
|
|
1251
|
+
/** The `#N` in a forge URL — `…/pull/774` → 774. Null when the tail is not a number, so a URL
|
|
1252
|
+
* shape we do not recognise invalidates nothing rather than inventing a key. */
|
|
1253
|
+
export function refNumberFromUrl(url) {
|
|
1254
|
+
const last = /\/(\d+)\/?$/.exec(url.trim());
|
|
1255
|
+
const parsed = last ? Number(last[1]) : Number.NaN;
|
|
1256
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
1257
|
+
}
|
|
1258
|
+
/** A closed issue or an abandoned PR can be REOPENED, so this is long rather than forever — but
|
|
1259
|
+
* it is the rare event, and re-asking a hundred settled references every minute to catch it is
|
|
1260
|
+
* the wrong trade. */
|
|
1261
|
+
const REF_STATUS_CLOSED_TTL = 10 * 60_000;
|
|
1262
|
+
/** A merged pull request is merged forever: GitHub has no un-merge. Capped at a day only so a
|
|
1263
|
+
* long-lived server eventually re-reads rather than trusting a value from another era. */
|
|
1264
|
+
const REF_STATUS_MERGED_TTL = 24 * 60 * 60_000;
|
|
1265
|
+
/**
|
|
1266
|
+
* How long a cached answer stays fresh — by how changeable that answer IS.
|
|
1267
|
+
*
|
|
1268
|
+
* One TTL for everything gets both ends wrong: it re-asks about a merged PR (which cannot change)
|
|
1269
|
+
* every minute, and it is the only thing standing between a running CI job and a stale chip. What
|
|
1270
|
+
* a reader wants rechecked is precisely what is still moving.
|
|
1271
|
+
*
|
|
1272
|
+
* A number the repository does not have keeps the short TTL: it is usually a wrong number, but it
|
|
1273
|
+
* is also what a reference to a not-yet-created PR looks like, and re-asking is cheap.
|
|
1274
|
+
*/
|
|
1275
|
+
function refStatusTtl(entry) {
|
|
1276
|
+
if (!entry)
|
|
1277
|
+
return CACHE_MS;
|
|
1278
|
+
switch (entry.status) {
|
|
1279
|
+
case 'merged':
|
|
1280
|
+
return REF_STATUS_MERGED_TTL;
|
|
1281
|
+
case 'closed':
|
|
1282
|
+
case 'completed':
|
|
1283
|
+
case 'not-planned':
|
|
1284
|
+
return REF_STATUS_CLOSED_TTL;
|
|
1285
|
+
default:
|
|
1286
|
+
return CACHE_MS;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/** How long a status can be trusted to stay put — `null` when it can never change again. The
|
|
1290
|
+
* cadence half of `refStatusTtl`, and deliberately the same table: a value the cache would still
|
|
1291
|
+
* be serving is a value there is no point asking for. */
|
|
1292
|
+
function refStatusRecheckAfter(entry) {
|
|
1293
|
+
if (entry?.status === 'merged')
|
|
1294
|
+
return null; // GitHub has no un-merge
|
|
1295
|
+
return refStatusTtl(entry);
|
|
1296
|
+
}
|
|
1297
|
+
/** How long the WHOLE answer holds — the soonest any single reference in it could differ. `null`
|
|
1298
|
+
* only when every one of them is immutable, which is what tells the cockpit to stop scheduling. */
|
|
1299
|
+
function batchRecheckAfter(entries) {
|
|
1300
|
+
let soonest = null;
|
|
1301
|
+
for (const entry of entries) {
|
|
1302
|
+
const after = refStatusRecheckAfter(entry);
|
|
1303
|
+
if (after === null)
|
|
1304
|
+
continue;
|
|
1305
|
+
soonest = soonest === null ? after : Math.min(soonest, after);
|
|
1306
|
+
}
|
|
1307
|
+
return soonest;
|
|
1308
|
+
}
|
|
1309
|
+
/** A forge that could not be reached is worth retrying, and worth not hammering: a workspace with
|
|
1310
|
+
* no `gh` installed would otherwise spawn a subprocess a minute, forever, to be told the same
|
|
1311
|
+
* thing. Five minutes is the same order as the cockpit's own reconnect cadence. */
|
|
1312
|
+
const REF_STATUS_RETRY_MS = 5 * 60_000;
|
|
1313
|
+
/**
|
|
1314
|
+
* GraphQL through `gh`, tolerating a PARTIALLY failed response.
|
|
1315
|
+
*
|
|
1316
|
+
* `gh api graphql` exits non-zero whenever the reply carries an `errors` array — even when `data`
|
|
1317
|
+
* is fully populated for every alias that DID resolve. With one alias per reference that is not an
|
|
1318
|
+
* edge case, it is the normal case: a single number that no longer exists (or never did, having
|
|
1319
|
+
* been scraped from a transcript that named another repository) makes GitHub answer
|
|
1320
|
+
* `{data: {...everything else...}, errors: [NOT_FOUND]}` and `gh` exit 1.
|
|
1321
|
+
*
|
|
1322
|
+
* `execFile` rejects on a non-zero exit, so that used to throw away a whole batch's worth of
|
|
1323
|
+
* perfectly good statuses and report every reference in it as "not found" — including, in the bug
|
|
1324
|
+
* that produced this function, an open pull request in the project's own repository.
|
|
1325
|
+
*
|
|
1326
|
+
* So: a non-zero exit whose stdout still carries a usable `data.repository` is a partial success
|
|
1327
|
+
* and is used as-is. Anything else — no stdout, unparseable stdout, or a null `repository`, which
|
|
1328
|
+
* means the repo handle itself did not resolve — rethrows, so a real failure still degrades to
|
|
1329
|
+
* `{available: false}` instead of masquerading as "none of these exist".
|
|
1330
|
+
*/
|
|
1331
|
+
function refStatusGraphql(repoRoot) {
|
|
1332
|
+
return async (query, variables) => {
|
|
1333
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
1334
|
+
for (const [key, value] of Object.entries(variables))
|
|
1335
|
+
args.push('-f', `${key}=${value}`);
|
|
1336
|
+
try {
|
|
1337
|
+
return await gh(repoRoot, args);
|
|
1338
|
+
}
|
|
1339
|
+
catch (err) {
|
|
1340
|
+
const stdout = err.stdout;
|
|
1341
|
+
if (typeof stdout === 'string' && hasResolvedRepository(stdout))
|
|
1342
|
+
return stdout;
|
|
1343
|
+
throw err;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* The repo handle, memoized in the same map `resolveRepoHandle` uses — but rethrowing instead of
|
|
1349
|
+
* answering `null`.
|
|
1350
|
+
*
|
|
1351
|
+
* The memo is the point: without it every recheck tick spawned a second subprocess to re-learn an
|
|
1352
|
+
* `owner/name` that changes approximately never, doubling the cost of the one query that actually
|
|
1353
|
+
* carries information. The difference from `resolveRepoHandle` is the failure: this route turns a
|
|
1354
|
+
* `gh` error into the payload's `reason` — "gh CLI not found — install it and run `gh auth login`"
|
|
1355
|
+
* is what the chip's tooltip now shows a user — and a `null` would erase which failure it was.
|
|
1356
|
+
*/
|
|
1357
|
+
async function resolveRepoHandleStrict(repoRoot) {
|
|
1358
|
+
const memo = repoHandleCache.get(repoRoot);
|
|
1359
|
+
if (memo !== undefined)
|
|
1360
|
+
return memo;
|
|
1361
|
+
// A throw here is transient and deliberately NOT memoized, exactly as in `resolveRepoHandle`.
|
|
1362
|
+
const handle = parseOwnerName(await gh(repoRoot, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']));
|
|
1363
|
+
repoHandleCache.set(repoRoot, handle); // includes the permanent negative
|
|
1364
|
+
return handle;
|
|
1365
|
+
}
|
|
1366
|
+
/** Is this a reply we can still read aliases out of? `repository: null` says the handle did not
|
|
1367
|
+
* resolve, which is a real failure and must not be read as "the numbers are all missing". */
|
|
1368
|
+
function hasResolvedRepository(stdout) {
|
|
1369
|
+
try {
|
|
1370
|
+
const parsed = JSON.parse(stdout);
|
|
1371
|
+
const repository = parsed?.data?.repository;
|
|
1372
|
+
return typeof repository === 'object' && repository !== null;
|
|
1373
|
+
}
|
|
1374
|
+
catch {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Route-facing reference status. Resolves the repo handle once, serves fresh cache entries,
|
|
1380
|
+
* queries only the misses, and degrades to `{ available: false, reason }` when `gh` or the handle
|
|
1381
|
+
* is unavailable — never a throw, never a 5xx.
|
|
1382
|
+
*
|
|
1383
|
+
* The two request lists are merged into ONE set of numbers: issues and pull requests share a
|
|
1384
|
+
* repository's numbering space, so a number is one thing and asking about it twice would be asking
|
|
1385
|
+
* the same question twice. What comes back is filed by what each number turned out to BE, which is
|
|
1386
|
+
* why a chip whose kind the cockpit guessed wrong still gets the right status.
|
|
1387
|
+
*/
|
|
1388
|
+
export async function fetchGithubRefStatus(repoRoot, input) {
|
|
1389
|
+
const asPrs = sanitizeRefNumbers(input.prs);
|
|
1390
|
+
const asIssues = sanitizeRefNumbers(input.issues);
|
|
1391
|
+
if (process.env.CEZ_DRY_RUN === '1')
|
|
1392
|
+
return mockGithubRefStatus(asPrs, asIssues);
|
|
1393
|
+
const wanted = [...new Set([...asPrs, ...asIssues])];
|
|
1394
|
+
const resolved = { prs: {}, issues: {} };
|
|
1395
|
+
// Every reference in the answer, resolved or not — what the recheck cadence is computed from.
|
|
1396
|
+
const entries = [];
|
|
1397
|
+
const file = (number, entry) => {
|
|
1398
|
+
entries.push(entry);
|
|
1399
|
+
if (entry)
|
|
1400
|
+
resolved[entry.kind === 'pr' ? 'prs' : 'issues'][number] = entry.status;
|
|
1401
|
+
};
|
|
1402
|
+
const misses = [];
|
|
1403
|
+
const now = Date.now();
|
|
1404
|
+
for (const n of wanted) {
|
|
1405
|
+
const hit = refStatusCache.get(refStatusKey(repoRoot, n));
|
|
1406
|
+
if (!hit || now - hit.at >= refStatusTtl(hit.resolved))
|
|
1407
|
+
misses.push(n);
|
|
1408
|
+
else
|
|
1409
|
+
file(n, hit.resolved);
|
|
1410
|
+
}
|
|
1411
|
+
if (misses.length === 0) {
|
|
1412
|
+
return { available: true, prs: resolved.prs, issues: resolved.issues, recheckAfterMs: batchRecheckAfter(entries) };
|
|
1413
|
+
}
|
|
1414
|
+
try {
|
|
1415
|
+
const ownerName = await resolveRepoHandleStrict(repoRoot);
|
|
1416
|
+
if (!ownerName) {
|
|
1417
|
+
return { available: false, reason: 'repository handle unavailable', recheckAfterMs: REF_STATUS_RETRY_MS };
|
|
1418
|
+
}
|
|
1419
|
+
const batch = await fetchRefStatuses(refStatusGraphql(repoRoot), ownerName.owner, ownerName.name, misses);
|
|
1420
|
+
const failed = new Set(batch.failed);
|
|
1421
|
+
// Stamped when the answer ARRIVED, not when the request was assembled: `now` above predates
|
|
1422
|
+
// the round trip, and dating an entry by it would age a slow query's results by its own
|
|
1423
|
+
// duration — shortening the TTL of exactly the answers that cost the most to get.
|
|
1424
|
+
const storedAt = Date.now();
|
|
1425
|
+
for (const n of misses) {
|
|
1426
|
+
// A number we could not ask about is NOT cached: caching it would pin "this repository has
|
|
1427
|
+
// no such number" for a minute on the strength of a network blip.
|
|
1428
|
+
if (failed.has(n))
|
|
1429
|
+
continue;
|
|
1430
|
+
const entry = batch.resolved[n] ?? null;
|
|
1431
|
+
file(n, entry);
|
|
1432
|
+
refStatusCache.set(refStatusKey(repoRoot, n), { at: storedAt, resolved: entry });
|
|
1433
|
+
}
|
|
1434
|
+
while (refStatusCache.size > REF_STATUS_CACHE_MAX) {
|
|
1435
|
+
const oldest = refStatusCache.keys().next().value;
|
|
1436
|
+
if (oldest === undefined)
|
|
1437
|
+
break;
|
|
1438
|
+
refStatusCache.delete(oldest);
|
|
1439
|
+
}
|
|
1440
|
+
// Anything unasked makes the whole answer `unavailable`, deliberately. The alternative is a
|
|
1441
|
+
// payload where a number we could not reach is indistinguishable from one that does not exist,
|
|
1442
|
+
// and the cockpit would paint "not found on this repository" over a perfectly good PR — the
|
|
1443
|
+
// exact defect this route was reported for. The successes are cached either way, so the next
|
|
1444
|
+
// request costs only the numbers that failed and usually answers in full.
|
|
1445
|
+
if (failed.size > 0) {
|
|
1446
|
+
return {
|
|
1447
|
+
available: false,
|
|
1448
|
+
reason: batch.reason ?? 'GitHub could not be reached',
|
|
1449
|
+
recheckAfterMs: REF_STATUS_RETRY_MS,
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
return { available: true, prs: resolved.prs, issues: resolved.issues, recheckAfterMs: batchRecheckAfter(entries) };
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1456
|
+
return {
|
|
1457
|
+
available: false,
|
|
1458
|
+
reason: /ENOENT/.test(message)
|
|
1459
|
+
? 'gh CLI not found — install it and run `gh auth login`'
|
|
1460
|
+
: firstLine(message),
|
|
1461
|
+
recheckAfterMs: REF_STATUS_RETRY_MS,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
/** NUL separator, as everywhere else here: two projects each having a #42 must not collide. */
|
|
1466
|
+
function refStatusKey(repoRoot, number) {
|
|
1467
|
+
return `${repoRoot}\0#${number}`;
|
|
1468
|
+
}
|
|
1469
|
+
function sanitizeRefNumbers(numbers) {
|
|
1470
|
+
return [...new Set(numbers ?? [])].filter((n) => Number.isInteger(n) && n > 0).slice(0, GH_REF_STATUS_MAX);
|
|
1471
|
+
}
|
|
1472
|
+
/** CEZ_DRY_RUN=1 — statuses derived from the mock catalog so the offline demo paints real chips. */
|
|
1473
|
+
function mockGithubRefStatus(prs, issues) {
|
|
1474
|
+
const catalog = mockGithub();
|
|
1475
|
+
const byPr = new Map(catalog.prs.map((p) => [p.number, p]));
|
|
1476
|
+
const byIssue = new Set(catalog.issues.map((i) => i.number));
|
|
1477
|
+
const out = { available: true, prs: {}, issues: {}, recheckAfterMs: CACHE_MS };
|
|
1478
|
+
for (const n of prs) {
|
|
1479
|
+
const pr = byPr.get(n);
|
|
1480
|
+
if (pr) {
|
|
1481
|
+
out.prs[n] = derivePrReferenceStatus({
|
|
1482
|
+
state: 'OPEN',
|
|
1483
|
+
isDraft: pr.isDraft,
|
|
1484
|
+
reviewDecision: null,
|
|
1485
|
+
checks: (pr.checks ?? null),
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
for (const n of issues)
|
|
1490
|
+
if (byIssue.has(n))
|
|
1491
|
+
out.issues[n] = 'open';
|
|
1492
|
+
return out;
|
|
1493
|
+
}
|
|
983
1494
|
/**
|
|
984
1495
|
* The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
|
|
985
1496
|
* filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
|