@wardby/cli 0.4.1 → 0.5.1

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.
Files changed (221) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/generated/prisma/browser.d.ts +23 -0
  67. package/dist/generated/prisma/client.d.ts +23 -0
  68. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  69. package/dist/generated/prisma/internal/class.d.ts +33 -0
  70. package/dist/generated/prisma/internal/class.js +4 -4
  71. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  72. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  73. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  75. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  76. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  77. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  78. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  79. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  80. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  81. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  83. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  84. package/dist/generated/prisma/models/LocalReview.js +1 -0
  85. package/dist/generated/prisma/models/Run.d.ts +223 -0
  86. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  87. package/dist/generated/prisma/models.d.ts +3 -0
  88. package/dist/help-index.json +486 -37
  89. package/dist/import/neutral-schema.d.ts +2 -2
  90. package/dist/mcp/auth/access.d.ts +3 -1
  91. package/dist/mcp/auth/host-account-cli.js +2 -2
  92. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  93. package/dist/mcp/auth/repo-authorization.js +21 -0
  94. package/dist/mcp/index.js +4 -1
  95. package/dist/mcp/tools/agents.js +46 -4
  96. package/dist/mcp/tools/host-accounts.js +2 -2
  97. package/dist/mcp/tools/repositories.js +71 -12
  98. package/dist/mcp/tools/runs.js +23 -0
  99. package/dist/mcp/tools/trigger.js +153 -24
  100. package/dist/providers/engine/types.d.ts +11 -0
  101. package/dist/providers/executor/composition.js +12 -7
  102. package/dist/providers/executor/container.d.ts +40 -3
  103. package/dist/providers/executor/container.js +140 -9
  104. package/dist/providers/executor/types.d.ts +1 -1
  105. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  106. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  107. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  108. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  109. package/dist/providers/jobs/kubernetes-client.js +3 -0
  110. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  111. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  112. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  113. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  114. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  115. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  116. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  117. package/dist/providers/jobs/kubernetes.js +50 -0
  118. package/dist/providers/jobs/types.d.ts +7 -0
  119. package/dist/providers/review-host/ci.d.ts +7 -0
  120. package/dist/providers/review-host/ci.js +20 -0
  121. package/dist/providers/review-host/github-events.d.ts +7 -0
  122. package/dist/providers/review-host/github-events.js +31 -7
  123. package/dist/providers/review-host/github.d.ts +19 -2
  124. package/dist/providers/review-host/github.js +145 -2
  125. package/dist/providers/review-host/index.d.ts +8 -2
  126. package/dist/providers/review-host/index.js +14 -3
  127. package/dist/providers/review-host/local.d.ts +72 -0
  128. package/dist/providers/review-host/local.js +434 -0
  129. package/dist/providers/review-host/types.d.ts +65 -3
  130. package/dist/providers/review-host/types.js +5 -3
  131. package/dist/providers/vcs/git.d.ts +10 -20
  132. package/dist/providers/vcs/git.js +71 -127
  133. package/dist/providers/vcs/github-remote.d.ts +67 -0
  134. package/dist/providers/vcs/github-remote.js +201 -0
  135. package/dist/providers/vcs/github.d.ts +81 -0
  136. package/dist/providers/vcs/github.js +203 -6
  137. package/dist/providers/vcs/index.d.ts +16 -1
  138. package/dist/providers/vcs/index.js +43 -15
  139. package/dist/providers/vcs/local-remote.d.ts +44 -0
  140. package/dist/providers/vcs/local-remote.js +108 -0
  141. package/dist/providers/vcs/remote.d.ts +58 -0
  142. package/dist/providers/vcs/remote.js +1 -0
  143. package/dist/providers/vcs/routing.d.ts +33 -0
  144. package/dist/providers/vcs/routing.js +52 -0
  145. package/dist/providers/vcs/types.d.ts +12 -1
  146. package/dist/quickstart/coding-db.d.ts +11 -0
  147. package/dist/quickstart/coding-db.js +75 -0
  148. package/dist/quickstart/coding-doctor.d.ts +13 -0
  149. package/dist/quickstart/coding-doctor.js +88 -0
  150. package/dist/quickstart/coding-images.d.ts +15 -0
  151. package/dist/quickstart/coding-images.js +79 -0
  152. package/dist/quickstart/coding-seed.d.ts +63 -0
  153. package/dist/quickstart/coding-seed.js +85 -0
  154. package/dist/quickstart/coding.d.ts +66 -0
  155. package/dist/quickstart/coding.js +430 -0
  156. package/dist/quickstart/images.d.ts +19 -0
  157. package/dist/quickstart/images.js +63 -0
  158. package/dist/quickstart/index.js +61 -12
  159. package/dist/quickstart/starter-services.d.ts +45 -0
  160. package/dist/quickstart/starter-services.js +186 -0
  161. package/dist/quickstart-images.json +1 -0
  162. package/dist/serve.js +14 -1
  163. package/dist/viewer/api-schema.d.ts +176 -0
  164. package/dist/viewer/api-schema.js +27 -2
  165. package/dist/viewer/graph.d.ts +7 -2
  166. package/dist/viewer/graph.js +25 -11
  167. package/dist/viewer/http.d.ts +7 -1
  168. package/dist/viewer/http.js +8 -3
  169. package/dist/viewer/infra.d.ts +2 -0
  170. package/dist/viewer/infra.js +23 -0
  171. package/dist/viewer/run-detail.d.ts +2 -1
  172. package/dist/viewer/run-detail.js +2 -2
  173. package/docs/agent-recipes.md +127 -5
  174. package/docs/code-review-agents.md +293 -38
  175. package/docs/coding-agent-setup.md +141 -2
  176. package/docs/coding-packages.md +21 -0
  177. package/docs/coding-services.md +3 -0
  178. package/docs/coding-worker-isolation.md +56 -20
  179. package/docs/getting-started-gke.md +24 -0
  180. package/docs/getting-started.md +196 -10
  181. package/docs/jira-agents.md +26 -5
  182. package/docs/security-deployment.md +9 -0
  183. package/docs/viewer-api.md +21 -2
  184. package/help/admin-viewer.md +11 -1
  185. package/help/agent-recipes.md +30 -4
  186. package/help/builder-agent.md +4 -0
  187. package/help/code-review-agents.md +82 -2
  188. package/help/coding-packages.md +12 -1
  189. package/help/coding-services.md +4 -0
  190. package/help/cost-attribution.md +3 -1
  191. package/help/creating-agents.md +3 -0
  192. package/help/deploy-gke.md +2 -1
  193. package/help/errors/coding-provider-not-configured.md +60 -0
  194. package/help/errors/coding-turn-limit.md +25 -0
  195. package/help/errors/continuation-closed.md +75 -0
  196. package/help/errors/local-branch-conflict.md +37 -0
  197. package/help/errors/local-path-invalid.md +30 -0
  198. package/help/errors/local-ref-invalid.md +36 -0
  199. package/help/errors/local-ref-not-found.md +41 -0
  200. package/help/errors/local-repo-not-allowed.md +46 -0
  201. package/help/errors/local-repo-not-found.md +39 -0
  202. package/help/errors/model-unavailable.md +7 -6
  203. package/help/errors/vcs-github-not-configured.md +34 -0
  204. package/help/getting-started.md +28 -0
  205. package/help/github.md +22 -5
  206. package/help/jira.md +9 -2
  207. package/help/local-repositories.md +182 -0
  208. package/help/related-pull-requests.md +89 -0
  209. package/help/review-fix-rounds.md +69 -0
  210. package/help/troubleshooting/budgets.md +11 -0
  211. package/help/troubleshooting/coding-workers.md +22 -0
  212. package/help/troubleshooting/repository-access.md +5 -0
  213. package/package.json +3 -2
  214. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  215. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  216. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  217. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  218. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  219. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  220. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  221. package/prisma/schema.prisma +98 -1
@@ -35,6 +35,7 @@ import { parseWorkerDiagnosticLine } from "./docker.js";
35
35
  import { KubernetesAlreadyExistsError, KubernetesConflictError } from "./kubernetes-api.js";
36
36
  import { ENFORCEMENT_PROBE_DENY_REACHABLE, ENFORCEMENT_PROBE_PROVEN, ENFORCEMENT_PROBE_DENY_REFUSED, ENFORCEMENT_PROBE_PROXY_UNREACHABLE, KEEPER_CONTAINER, KEEPER_SEEDED_MARKER, KUBERNETES_ISOLATION_ERROR, STORAGE_ROOT, TOOL_RUNNER_CONTAINER, WORKER_CONTAINER, assertRunNetworkPolicyMatches, assertRunPodMatches, buildCapabilitySecret, buildRunNetworkPolicy, buildRunPod, enforcementProbeScript, kubernetesRunNames, kubernetesRunNamesForToken, runLabels, serviceContainerName, validateKubernetesSpec, } from "./kubernetes-isolation.js";
37
37
  import { claudeToolSetup } from "./claude-tool-setup.js";
38
+ import { podQuotaUsage, quotaShortfall } from "./kubernetes-quota.js";
38
39
  import { readProxyWitness } from "./kubernetes-witness.js";
39
40
  import { safeExtract } from "./safe-extract.js";
40
41
  import { reportServiceState, } from "./service-state.js";
@@ -185,6 +186,8 @@ const TOKEN = /^[a-f0-9]{20}$/;
185
186
  const WORKSPACE_STORAGE = `${STORAGE_ROOT}/workspace`;
186
187
  const INPUT_STORAGE = `${STORAGE_ROOT}/input`;
187
188
  const RESULT_PATH = `${STORAGE_ROOT}/output/result.json`;
189
+ /** Stands in for the proxy ClusterIP when building a pod only to count its resources. */
190
+ const QUOTA_PROBE_PROXY_IP = "10.0.0.1";
188
191
  const kubernetesLog = logger.child({ module: "kubernetes-jobs" });
189
192
  const PHASES = new Set(["provisioning", "active", "succeeded", "failed", "stopped", "lost", "removed"]);
190
193
  const TERMINAL_PHASES = new Set(["succeeded", "failed", "stopped", "lost", "removed"]);
@@ -427,6 +430,53 @@ export class KubernetesJobLauncher {
427
430
  return undefined;
428
431
  }
429
432
  }
433
+ /**
434
+ * Whether this spec's pod fits the free room in the namespace's ResourceQuota
435
+ * (KubernetesJobConfig.resourceQuota), counted from the same pod `launch` would create. Any
436
+ * doubt (no quota configured, unreadable, unparseable, a spec the pod builder rejects) answers
437
+ * yes, so the launch and the API server stay the authority.
438
+ */
439
+ async hasCapacityFor(spec) {
440
+ const quotaName = this.options.config.resourceQuota;
441
+ if (!quotaName)
442
+ return true;
443
+ let quota;
444
+ try {
445
+ quota = await this.api.readResourceQuota(this.namespace, quotaName);
446
+ }
447
+ catch {
448
+ this.warnOnce("kubernetes_resource_quota_unreadable: capacity is not checked before launch");
449
+ return true;
450
+ }
451
+ if (!quota) {
452
+ this.warnOnce(`kubernetes_resource_quota_missing: ${quotaName}; capacity is not checked before launch`);
453
+ return true;
454
+ }
455
+ let usage;
456
+ try {
457
+ const { runtimeClassName, priorityClassName, platform } = this.options.config;
458
+ // The proxy address only fills a hosts entry; it doesn't change what the pod requests.
459
+ const pod = buildRunPod(spec, {
460
+ namespace: this.namespace,
461
+ proxyIp: QUOTA_PROBE_PROXY_IP,
462
+ runtimeClassName,
463
+ priorityClassName,
464
+ platform,
465
+ });
466
+ usage = podQuotaUsage(pod);
467
+ }
468
+ catch {
469
+ return true;
470
+ }
471
+ return quotaShortfall(quota, usage) === null;
472
+ }
473
+ warnedOnce = new Set();
474
+ warnOnce(message) {
475
+ if (this.warnedOnce.has(message))
476
+ return;
477
+ this.warnedOnce.add(message);
478
+ this.warn(message);
479
+ }
430
480
  async launch(spec) {
431
481
  try {
432
482
  validateKubernetesSpec(spec);
@@ -81,6 +81,13 @@ export interface JobLauncher {
81
81
  * cannot know the handle in advance omit this, and callers persist only after `launch` returns.
82
82
  */
83
83
  plannedHandle?: (spec: JobSpec) => JobHandle | undefined;
84
+ /**
85
+ * Whether the cluster has room for this spec's job right now (Kubernetes: the namespace
86
+ * ResourceQuota). Asked before a run claims a slot; false keeps the run queued so it is retried
87
+ * later instead of failing when the job is refused. Absent, or any doubt, means yes: the launch
88
+ * itself stays the authority.
89
+ */
90
+ hasCapacityFor?: (spec: JobSpec) => Promise<boolean>;
84
91
  launch: (spec: JobSpec) => Promise<JobHandle>;
85
92
  status: (handle: JobHandle) => Promise<JobStatus>;
86
93
  collect: (handle: JobHandle) => Promise<JobResult>;
@@ -0,0 +1,7 @@
1
+ /** Host-neutral CI summary for repo_pr_read. */
2
+ import type { CiCheckView, CiState } from "./types.js";
3
+ /** Most CI results listed on one head. */
4
+ export declare const MAX_CI_CHECKS = 50;
5
+ export declare const FAILING_CONCLUSIONS: ReadonlySet<string>;
6
+ export declare const PASSING_CONCLUSIONS: ReadonlySet<string>;
7
+ export declare function summarizeCi(checks: readonly CiCheckView[]): Exclude<CiState, "unavailable">;
@@ -0,0 +1,20 @@
1
+ /** Most CI results listed on one head. */
2
+ export const MAX_CI_CHECKS = 50;
3
+ export const FAILING_CONCLUSIONS = new Set([
4
+ "failure",
5
+ "timed_out",
6
+ "action_required",
7
+ "startup_failure",
8
+ "error",
9
+ ]);
10
+ export const PASSING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
11
+ export function summarizeCi(checks) {
12
+ if (checks.length === 0)
13
+ return "none";
14
+ if (checks.some((c) => c.status === "completed" && FAILING_CONCLUSIONS.has(c.conclusion ?? "")))
15
+ return "failing";
16
+ if (checks.some((c) => c.status !== "completed"))
17
+ return "pending";
18
+ const allPassing = checks.every((c) => PASSING_CONCLUSIONS.has(c.conclusion ?? ""));
19
+ return allPassing && checks.some((c) => c.conclusion === "success") ? "passing" : "inconclusive";
20
+ }
@@ -1,6 +1,13 @@
1
1
  import type { HostEvent } from "./types.js";
2
2
  export declare function verifyGitHubSignature(rawBody: string, header: string | undefined, secret: string): boolean;
3
+ type Json = Record<string, unknown>;
4
+ /**
5
+ * The run id in a PR description's marker. Only a PR the App itself authored
6
+ * counts: anyone can write the marker into their own PR's description.
7
+ */
8
+ export declare function appMarkerRunId(pr: Json | null, slug: string): string | undefined;
3
9
  export declare function normalizeGitHubEvent(eventName: string, payload: unknown, app: {
4
10
  id: number;
5
11
  slug: string;
6
12
  }): HostEvent | null;
13
+ export {};
@@ -11,6 +11,8 @@ const SAFE_SHA = /^[0-9a-f]{40}$/;
11
11
  const PUSH_MAX_COMMITS = 2048;
12
12
  const SAFE_BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
13
13
  const PUSH_MAX_PATHS = 1000;
14
+ /** Pull requests one CI completion may re-review; a commit is rarely the head of more. */
15
+ const MAX_CI_PRS = 10;
14
16
  /** Mirrors the runId shape validated in providers/vcs (github.ts, git.ts). */
15
17
  const SAFE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
16
18
  /** The hidden marker a coding run writes at the top of every PR it opens (providers/vcs/github.ts); legacy prefix too. */
@@ -44,14 +46,10 @@ function subjectOf(item) {
44
46
  return typeof item?.title === "string" ? { title: item.title, body: text(item.body) } : undefined;
45
47
  }
46
48
  /**
47
- * The run that opened this PR. Only a PR the App itself authored counts:
48
- * anyone can write the marker into their own PR's description. Only an open
49
- * PR counts too: a merged or closed PR's branch is stale (a squash merge leaves
50
- * its commits behind), so a follow-up there starts from the default branch.
49
+ * The run id in a PR description's marker. Only a PR the App itself authored
50
+ * counts: anyone can write the marker into their own PR's description.
51
51
  */
52
- function priorRunIdOf(pr, slug) {
53
- if (pr?.state !== "open")
54
- return undefined;
52
+ export function appMarkerRunId(pr, slug) {
55
53
  const author = obj(pr?.user);
56
54
  if (author?.type !== "Bot" || typeof author.login !== "string")
57
55
  return undefined;
@@ -60,6 +58,16 @@ function priorRunIdOf(pr, slug) {
60
58
  const id = RUN_MARKER.exec(text(pr?.body))?.[1];
61
59
  return id && SAFE_RUN_ID.test(id) ? id : undefined;
62
60
  }
61
+ /**
62
+ * The run that opened this PR, for a follow-up. Only an open PR counts: a
63
+ * merged or closed PR's branch is stale (a squash merge leaves its commits
64
+ * behind), so a follow-up there starts from the default branch.
65
+ */
66
+ function priorRunIdOf(pr, slug) {
67
+ if (pr?.state !== "open")
68
+ return undefined;
69
+ return appMarkerRunId(pr, slug);
70
+ }
63
71
  /**
64
72
  * A cheap pre-filter only: author_association is not a permission (MEMBER is
65
73
  * any org member, COLLABORATOR includes read and triage). The real gate is the
@@ -134,6 +142,22 @@ export function normalizeGitHubEvent(eventName, payload, app) {
134
142
  const isFork = typeof headRepo !== "string" || headRepo.toLowerCase() !== repository;
135
143
  return { kind: "pr_updated", provider: "github", repository, prNumber, headSha, isFork };
136
144
  }
145
+ if (eventName === "check_suite") {
146
+ if (p.action !== "completed")
147
+ return null;
148
+ const suite = obj(p.check_suite);
149
+ // wardby's own review check finishing is not CI.
150
+ if (int(obj(suite?.app)?.id) === app.id)
151
+ return null;
152
+ const headSha = suite?.head_sha;
153
+ if (typeof headSha !== "string" || !SAFE_SHA.test(headSha))
154
+ return null;
155
+ const prs = Array.isArray(suite?.pull_requests) ? suite.pull_requests : [];
156
+ const prNumbers = [...new Set(prs.map((pr) => int(obj(pr)?.number)).filter((n) => n !== null))];
157
+ if (prNumbers.length === 0)
158
+ return null;
159
+ return { kind: "ci_completed", provider: "github", repository, headSha, prNumbers: prNumbers.slice(0, MAX_CI_PRS) };
160
+ }
137
161
  if (eventName === "check_run") {
138
162
  if (p.action !== "rerequested")
139
163
  return null;
@@ -4,8 +4,10 @@
4
4
  * the narrowest permission set that call needs (GitHubAppClient.withScopedToken),
5
5
  * and revokes it afterwards. See docs/private/2026-09-25-code-review-host-design.md §5.1.
6
6
  */
7
- import type { GitHubAppClient } from "../vcs/github.js";
8
- import { ReviewHostError, type CodeReviewHost, type CommentInput, type CommentRef, type CompleteCheckInput, type EditCommentInput, type FileListView, type FileReadResult, type HostPermission, type HostUser, type PublishReviewInput, type PublishReviewResult, type PullRequestHead, type PullRequestView, type StartCheckInput } from "./types.js";
7
+ import { type GitHubAppClient } from "../vcs/github.js";
8
+ import { ReviewHostError, type CiView, type CodeReviewHost, type CommentInput, type CommentRef, type CompleteCheckInput, type EditCommentInput, type FileListView, type FileReadResult, type HostPermission, type HostUser, type PublishReviewInput, type PublishReviewResult, type PullRequestHead, type PullRequestOrigin, type PullRequestView, type StartCheckInput } from "./types.js";
9
+ /** listFiles leaves out vendored trees and lockfiles; every host applies the same filter. */
10
+ export declare function skippedInListing(path: string): boolean;
9
11
  type Json = Record<string, unknown>;
10
12
  /** Maps GitHubAppClient's fixed error messages onto host-neutral codes; keeps the body-free message. */
11
13
  export declare function toReviewHostError(err: unknown): ReviewHostError;
@@ -21,7 +23,22 @@ export declare class GitHubReviewHost implements CodeReviewHost {
21
23
  login: string;
22
24
  }>;
23
25
  pullRequestHead(repository: string, prNumber: number): Promise<PullRequestHead>;
26
+ pullRequestOrigin(repository: string, prNumber: number): Promise<PullRequestOrigin>;
27
+ addLabel(repository: string, prNumber: number, label: string): Promise<void>;
28
+ replaceRelatedSection(repository: string, prNumber: number, input: {
29
+ expectedMarkerRunId: string;
30
+ block: string;
31
+ }): Promise<"updated" | "unchanged" | "skipped">;
24
32
  private head;
33
+ /**
34
+ * CI on one commit: check runs (Checks: read) minus wardby's own, then
35
+ * commit statuses (Commit statuses: read) best effort. Never throws: a
36
+ * failure reading check runs is state "unavailable", a failure reading
37
+ * statuses only sets statusesUnavailable. Both reads are attempted
38
+ * independently, so a failed checks read still mints (and attempts) the
39
+ * statuses token.
40
+ */
41
+ readCi(repository: string, headSha: string): Promise<CiView>;
25
42
  readPullRequest(repository: string, prNumber: number, opts: {
26
43
  sinceSha?: string;
27
44
  maxPatchChars: number;
@@ -1,5 +1,14 @@
1
+ /**
2
+ * GitHub implementation of CodeReviewHost, acting as the wardby GitHub App.
3
+ * Each method mints its own installation token scoped to one repository and
4
+ * the narrowest permission set that call needs (GitHubAppClient.withScopedToken),
5
+ * and revokes it afterwards. See docs/private/2026-09-25-code-review-host-design.md §5.1.
6
+ */
7
+ import { MAX_PULL_REQUEST_BODY_CHARS, upsertRelatedSection } from "../vcs/github.js";
1
8
  import { normalizeGitHubRepository } from "../../coding/protocol.js";
9
+ import { MAX_CI_CHECKS, summarizeCi } from "./ci.js";
2
10
  import { partitionComments } from "./diff-lines.js";
11
+ import { appMarkerRunId } from "./github-events.js";
3
12
  import { findingMarker, hasFindingMarker, hasReviewMarker, parseReviewMarker, renderInlineComment, renderSummaryComment, verdictConclusion, } from "./review-format.js";
4
13
  import { ReviewHostError, } from "./types.js";
5
14
  const READ = { contents: "read", pull_requests: "read" };
@@ -8,11 +17,22 @@ const MAX_FILE_PAGES = 3;
8
17
  const MAX_COMMENT_PAGES = 3;
9
18
  const MAX_LISTED_FILES = 1000;
10
19
  const VENDORED = /(^|\/)(node_modules|dist|\.venv)\//;
20
+ /** listFiles leaves out vendored trees and lockfiles; every host applies the same filter. */
21
+ export function skippedInListing(path) {
22
+ return VENDORED.test(path) || path.endsWith("package-lock.json");
23
+ }
11
24
  const CHECK_TEXT_LIMIT = 65_535;
12
25
  const COMMENT_WRITE = { issues: "write", pull_requests: "write" };
13
26
  const REVIEW_WRITE = { pull_requests: "write", checks: "write" };
14
27
  /** Always implicitly granted to an installation token; enough for the collaborator-permission endpoint. */
15
28
  const METADATA_READ = { metadata: "read" };
29
+ /** Editing a pull request's description: nothing else. */
30
+ const PR_WRITE = { pull_requests: "write" };
31
+ /** CI reads, each on its own token: Checks is already granted; Commit statuses may not be. */
32
+ const CHECKS_READ = { checks: "read" };
33
+ const STATUSES_READ = { statuses: "read" };
34
+ const CI_NAME_CHARS = 100;
35
+ const APP_SLUG = /^[a-z0-9-]{1,64}$/;
16
36
  const GITHUB_USER_ID = /^[1-9]\d{0,19}$/;
17
37
  const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
18
38
  /** Highest first: user.permissions booleans, which (unlike the legacy field) keep maintain and triage apart. */
@@ -103,6 +123,31 @@ function fileView(raw) {
103
123
  changes: typeof raw.changes === "number" ? raw.changes : num(raw.additions) + num(raw.deletions),
104
124
  };
105
125
  }
126
+ function checkRunView(raw) {
127
+ const status = raw.status === "completed" ? "completed" : raw.status === "in_progress" ? "in_progress" : "queued";
128
+ const app = raw.app && typeof raw.app === "object" ? raw.app : null;
129
+ return {
130
+ name: str(raw.name).slice(0, CI_NAME_CHARS),
131
+ kind: "check_run",
132
+ status,
133
+ conclusion: status === "completed" && typeof raw.conclusion === "string" ? raw.conclusion : null,
134
+ app: app && typeof app.slug === "string" && APP_SLUG.test(app.slug) ? app.slug : null,
135
+ };
136
+ }
137
+ function statusView(raw) {
138
+ const state = str(raw.state);
139
+ return {
140
+ name: str(raw.context).slice(0, CI_NAME_CHARS),
141
+ kind: "status",
142
+ status: state === "pending" ? "in_progress" : "completed",
143
+ conclusion: state === "pending" ? null : state,
144
+ app: null,
145
+ };
146
+ }
147
+ /** True for a check run wardby's own App created (its review checks, wardby/continuation). */
148
+ function fromApp(raw, appId) {
149
+ return !!raw.app && typeof raw.app === "object" && raw.app.id === appId;
150
+ }
106
151
  const THREADS_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
107
152
  repository(owner: $owner, name: $name) {
108
153
  pullRequest(number: $number) {
@@ -205,6 +250,48 @@ export class GitHubReviewHost {
205
250
  return this.head(repository, pr);
206
251
  });
207
252
  }
253
+ async pullRequestOrigin(repository, prNumber) {
254
+ const { slug } = await this.client.appIdentity();
255
+ return this.withToken(repository, READ, async (get) => {
256
+ const pr = record(await (await get(`${repoPath(repository)}/pulls/${prNumber}`)).json());
257
+ const labels = Array.isArray(pr.labels)
258
+ ? pr.labels
259
+ .map((l) => (l && typeof l === "object" ? str(l.name) : ""))
260
+ .filter((name) => name !== "")
261
+ : [];
262
+ const markerRunId = appMarkerRunId(pr, slug);
263
+ return {
264
+ ...this.head(repository, pr),
265
+ labels,
266
+ merged: pr.merged === true,
267
+ draft: pr.draft === true,
268
+ ...(markerRunId ? { markerRunId } : {}),
269
+ };
270
+ });
271
+ }
272
+ async addLabel(repository, prNumber, label) {
273
+ await this.withToken(repository, COMMENT_WRITE, async (get) => {
274
+ await get(`${repoPath(repository)}/issues/${prNumber}/labels`, { method: "POST", body: JSON.stringify({ labels: [label] }) }, [200]);
275
+ });
276
+ }
277
+ async replaceRelatedSection(repository, prNumber, input) {
278
+ const { slug } = await this.client.appIdentity();
279
+ const base = repoPath(repository);
280
+ return this.withToken(repository, PR_WRITE, async (get) => {
281
+ const pr = record(await (await get(`${base}/pulls/${prNumber}`)).json());
282
+ // Re-checked on the body actually being edited: only the App's own PR for this run.
283
+ if (pr.state !== "open" || appMarkerRunId(pr, slug) !== input.expectedMarkerRunId)
284
+ return "skipped";
285
+ const body = typeof pr.body === "string" ? pr.body : "";
286
+ const next = upsertRelatedSection(body, input.block);
287
+ if (next === null || next.length > MAX_PULL_REQUEST_BODY_CHARS)
288
+ return "skipped";
289
+ if (next === body)
290
+ return "unchanged";
291
+ await get(`${base}/pulls/${prNumber}`, { method: "PATCH", body: JSON.stringify({ body: next }) }, [200]);
292
+ return "updated";
293
+ });
294
+ }
208
295
  head(repository, pr) {
209
296
  const head = record(pr.head);
210
297
  const headRepo = head.repo && typeof head.repo === "object" ? record(head.repo) : null;
@@ -216,9 +303,64 @@ export class GitHubReviewHost {
216
303
  headRepo.full_name.toLowerCase() !== normalizeGitHubRepository(repository);
217
304
  return { headSha, isFork, state: str(pr.state) };
218
305
  }
306
+ /**
307
+ * CI on one commit: check runs (Checks: read) minus wardby's own, then
308
+ * commit statuses (Commit statuses: read) best effort. Never throws: a
309
+ * failure reading check runs is state "unavailable", a failure reading
310
+ * statuses only sets statusesUnavailable. Both reads are attempted
311
+ * independently, so a failed checks read still mints (and attempts) the
312
+ * statuses token.
313
+ */
314
+ async readCi(repository, headSha) {
315
+ const base = repoPath(repository);
316
+ let checks = [];
317
+ let truncated = false;
318
+ let unavailableReason;
319
+ try {
320
+ const { id: appId } = await this.client.appIdentity();
321
+ ({ checks, truncated } = await this.withToken(repository, CHECKS_READ, async (get) => {
322
+ const payload = record(await (await get(`${base}/commits/${headSha}/check-runs?filter=latest&per_page=100`)).json());
323
+ const runs = list(payload.check_runs ?? []);
324
+ const total = typeof payload.total_count === "number" ? payload.total_count : runs.length;
325
+ return { checks: runs.filter((r) => !fromApp(r, appId)).map(checkRunView), truncated: total > runs.length };
326
+ }));
327
+ }
328
+ catch (err) {
329
+ unavailableReason = toReviewHostError(err).code;
330
+ }
331
+ let statusesUnavailable = false;
332
+ try {
333
+ const statuses = await this.withToken(repository, STATUSES_READ, async (get) => {
334
+ const payload = record(await (await get(`${base}/commits/${headSha}/status?per_page=100`)).json());
335
+ return list(payload.statuses ?? []).map(statusView);
336
+ });
337
+ checks = [...checks, ...statuses];
338
+ }
339
+ catch {
340
+ statusesUnavailable = true;
341
+ }
342
+ if (unavailableReason !== undefined) {
343
+ return {
344
+ headSha,
345
+ state: "unavailable",
346
+ checks: [],
347
+ truncated: false,
348
+ statusesUnavailable: true,
349
+ unavailableReason,
350
+ };
351
+ }
352
+ const shown = checks.slice(0, MAX_CI_CHECKS);
353
+ return {
354
+ headSha,
355
+ state: summarizeCi(checks),
356
+ checks: shown,
357
+ truncated: truncated || shown.length < checks.length,
358
+ statusesUnavailable,
359
+ };
360
+ }
219
361
  async readPullRequest(repository, prNumber, opts) {
220
362
  const base = repoPath(repository);
221
- return this.withToken(repository, READ, async (get, graphql) => {
363
+ const view = await this.withToken(repository, READ, async (get, graphql) => {
222
364
  const pr = record(await (await get(`${base}/pulls/${prNumber}`)).json());
223
365
  const { headSha, isFork, state } = this.head(repository, pr);
224
366
  const prFiles = async () => {
@@ -298,6 +440,7 @@ export class GitHubReviewHost {
298
440
  openThreads,
299
441
  };
300
442
  });
443
+ return { ...view, ci: await this.readCi(repository, view.headSha) };
301
444
  }
302
445
  async readFile(repository, path, ref, window) {
303
446
  assertRelativePath(path);
@@ -346,7 +489,7 @@ export class GitHubReviewHost {
346
489
  const tree = record(await (await get(`${base}/git/trees/${encodeURIComponent(resolvedRef)}?recursive=1`)).json());
347
490
  const entries = list(tree.tree)
348
491
  .filter((e) => e.type === "blob" && typeof e.path === "string" && e.path.startsWith(pathPrefix))
349
- .filter((e) => !VENDORED.test(str(e.path)) && !str(e.path).endsWith("package-lock.json"));
492
+ .filter((e) => !skippedInListing(str(e.path)));
350
493
  return {
351
494
  ref: resolvedRef,
352
495
  count: entries.length,
@@ -1,9 +1,15 @@
1
+ import { type LocalReviewHostOptions } from "./local.js";
1
2
  import type { HostUserAuthorizerRegistry, ReviewHostRegistry } from "./types.js";
2
3
  export * from "./types.js";
3
4
  export { GitHubReviewHost } from "./github.js";
4
5
  export { GitHubUserAuthorizer } from "./github-user-auth.js";
5
- /** One host per configured provider; empty when no GitHub App is configured (the repo_* tools are then never offered). */
6
- export declare function buildReviewHosts(env?: NodeJS.ProcessEnv): ReviewHostRegistry;
6
+ export { LocalReviewHost } from "./local.js";
7
+ /**
8
+ * One host per configured provider: github when a GitHub App is configured,
9
+ * local always (the roots are re-read on every local call, so a link with no
10
+ * roots configured is refused with local_repo_not_allowed at call time).
11
+ */
12
+ export declare function buildReviewHosts(env: NodeJS.ProcessEnv, db: LocalReviewHostOptions["db"]): ReviewHostRegistry;
7
13
  /**
8
14
  * One identity-linking flow per provider whose App has OAuth client
9
15
  * credentials (GITHUB_APP_CLIENT_ID/SECRET). Empty = link_host_account is
@@ -1,20 +1,31 @@
1
1
  import { loadGitHubUserAuthConfig, loadGitHubVcsConfig } from "../../config/providers.js";
2
+ import { loadLocalRepoRoots } from "../../coding/local-repo.js";
2
3
  import { GitHubAppClient } from "../vcs/github.js";
3
4
  import { GitHubReviewHost } from "./github.js";
4
5
  import { GitHubUserAuthorizer } from "./github-user-auth.js";
6
+ import { LocalReviewHost } from "./local.js";
5
7
  export * from "./types.js";
6
8
  export { GitHubReviewHost } from "./github.js";
7
9
  export { GitHubUserAuthorizer } from "./github-user-auth.js";
10
+ export { LocalReviewHost } from "./local.js";
8
11
  function githubClient(env) {
9
12
  const github = loadGitHubVcsConfig(env);
10
13
  if (!github.appId || !github.privateKey)
11
14
  return null;
12
15
  return new GitHubAppClient({ appId: github.appId, privateKey: github.privateKey, apiVersion: github.apiVersion });
13
16
  }
14
- /** One host per configured provider; empty when no GitHub App is configured (the repo_* tools are then never offered). */
15
- export function buildReviewHosts(env = process.env) {
17
+ /**
18
+ * One host per configured provider: github when a GitHub App is configured,
19
+ * local always (the roots are re-read on every local call, so a link with no
20
+ * roots configured is refused with local_repo_not_allowed at call time).
21
+ */
22
+ export function buildReviewHosts(env, db) {
23
+ const hosts = {};
16
24
  const client = githubClient(env);
17
- return client ? { github: new GitHubReviewHost(client) } : {};
25
+ if (client)
26
+ hosts.github = new GitHubReviewHost(client);
27
+ hosts.local = new LocalReviewHost({ db, roots: () => loadLocalRepoRoots(env).roots });
28
+ return hosts;
18
29
  }
19
30
  /**
20
31
  * One identity-linking flow per provider whose App has OAuth client
@@ -0,0 +1,72 @@
1
+ /**
2
+ * CodeReviewHost over local git repositories (`local:/abs/path`). A "pull
3
+ * request" is a LocalPullRequest row: a branch compared against a base in the
4
+ * repository, keyed by a durable number. Reviews and comments are stored as
5
+ * LocalReview rows; checks, reactions and threads are no-ops.
6
+ *
7
+ * Every read goes through git objects (`cat-file`, `ls-tree`, `diff A B`),
8
+ * never the working tree, in the canonical repository path that
9
+ * resolveLocalRepository returns at call time (the trusted roots can change).
10
+ * git runs with GIT_* stripped, hooks and fsmonitor off, no external diff or
11
+ * textconv drivers, and literal pathspecs. Every ref from the model or a row
12
+ * is checked before it reaches git (a 40-hex sha, or a branch name that
13
+ * `git check-ref-format --branch` accepts and that cannot read as an option),
14
+ * and revisions follow `--end-of-options`.
15
+ */
16
+ import type { PrismaClient } from "#prisma";
17
+ import { type CodeReviewHost, type CommentInput, type CommentRef, type CompleteCheckInput, type EditCommentInput, type FileListView, type FileReadResult, type HostPermission, type HostUser, type PublishReviewInput, type PublishReviewResult, type PullRequestHead, type PullRequestView, type StartCheckInput } from "./types.js";
18
+ type Db = Pick<PrismaClient, "localPullRequest" | "localReview">;
19
+ export interface LocalReviewHostOptions {
20
+ db: Db;
21
+ /** Trusted roots, read at each call (default: LOCAL_REPO_ROOTS). */
22
+ roots?: () => readonly string[];
23
+ }
24
+ export declare class LocalReviewHost implements CodeReviewHost {
25
+ readonly provider: "local";
26
+ private readonly db;
27
+ private readonly roots;
28
+ constructor(options: LocalReviewHostOptions);
29
+ /** The canonical repository and its realpath, checked against the current roots. */
30
+ private repo;
31
+ private pullRequest;
32
+ /** Rejects anything but a plain branch/tag name before it can reach git as a revision or an option. */
33
+ private assertRefName;
34
+ /** The commit a sha or branch name points at. */
35
+ private commitOf;
36
+ /** The repository's checked-out HEAD commit (its "default branch"), and a label for it. */
37
+ private defaultRef;
38
+ private resolveRef;
39
+ private isAncestor;
40
+ /** Files changed between two commits (both 40-hex), with rename detection, in git's order. */
41
+ private changedFiles;
42
+ /** Attaches patches, sharing one character budget across files and flagging truncation like GitHub. */
43
+ private withPatches;
44
+ repositoryPermission(_repository: string, _user: HostUser): Promise<{
45
+ level: HostPermission;
46
+ login: string;
47
+ }>;
48
+ readPullRequest(repository: string, prNumber: number, opts: {
49
+ sinceSha?: string;
50
+ maxPatchChars: number;
51
+ agentMarker: string;
52
+ }): Promise<PullRequestView>;
53
+ pullRequestHead(repository: string, prNumber: number): Promise<PullRequestHead>;
54
+ readFile(repository: string, path: string, ref: string | undefined, window: {
55
+ startLine: number;
56
+ maxLines: number;
57
+ }): Promise<FileReadResult>;
58
+ listFiles(repository: string, ref: string | undefined, pathPrefix?: string): Promise<FileListView>;
59
+ publishReview(repository: string, input: PublishReviewInput): Promise<PublishReviewResult>;
60
+ /** Stored as a COMMENT review on the branch's current head. The host is not told who comments: agentId is "". */
61
+ comment(repository: string, input: CommentInput): Promise<{
62
+ url: string;
63
+ id: string;
64
+ }>;
65
+ editComment(_repository: string, _input: EditCommentInput): Promise<void>;
66
+ acknowledge(_repository: string, _target: CommentRef): Promise<void>;
67
+ startCheck(_repository: string, _input: StartCheckInput): Promise<{
68
+ checkId: string;
69
+ }>;
70
+ completeCheck(_repository: string, _input: CompleteCheckInput): Promise<void>;
71
+ }
72
+ export {};