@oscharko-dev/keiko-tools 0.2.8 → 0.2.9

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 (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/git-commit-intent-node.d.ts +7 -0
  3. package/dist/git-commit-intent-node.d.ts.map +1 -0
  4. package/dist/git-commit-intent-node.js +42 -0
  5. package/dist/git-merge-gateway.d.ts +95 -0
  6. package/dist/git-merge-gateway.d.ts.map +1 -0
  7. package/dist/git-merge-gateway.js +536 -0
  8. package/dist/git-merge-node.d.ts +17 -0
  9. package/dist/git-merge-node.d.ts.map +1 -0
  10. package/dist/git-merge-node.js +243 -0
  11. package/dist/git-mutation-adapter.d.ts +51 -0
  12. package/dist/git-mutation-adapter.d.ts.map +1 -0
  13. package/dist/git-mutation-adapter.js +207 -0
  14. package/dist/git-mutation-evidence.d.ts +23 -0
  15. package/dist/git-mutation-evidence.d.ts.map +1 -0
  16. package/dist/git-mutation-evidence.js +283 -0
  17. package/dist/git-mutation-node.d.ts +22 -0
  18. package/dist/git-mutation-node.d.ts.map +1 -0
  19. package/dist/git-mutation-node.js +145 -0
  20. package/dist/git-mutation-orchestrator.d.ts +92 -0
  21. package/dist/git-mutation-orchestrator.d.ts.map +1 -0
  22. package/dist/git-mutation-orchestrator.js +321 -0
  23. package/dist/git-mutation-preflight.d.ts +35 -0
  24. package/dist/git-mutation-preflight.d.ts.map +1 -0
  25. package/dist/git-mutation-preflight.js +226 -0
  26. package/dist/git-mutation-taxonomy.d.ts +15 -0
  27. package/dist/git-mutation-taxonomy.d.ts.map +1 -0
  28. package/dist/git-mutation-taxonomy.js +102 -0
  29. package/dist/git-pr-gateway.d.ts +101 -0
  30. package/dist/git-pr-gateway.d.ts.map +1 -0
  31. package/dist/git-pr-gateway.js +487 -0
  32. package/dist/git-pr-node.d.ts +17 -0
  33. package/dist/git-pr-node.d.ts.map +1 -0
  34. package/dist/git-pr-node.js +173 -0
  35. package/dist/git-publish-gateway.d.ts +72 -0
  36. package/dist/git-publish-gateway.d.ts.map +1 -0
  37. package/dist/git-publish-gateway.js +423 -0
  38. package/dist/git-publish-node.d.ts +17 -0
  39. package/dist/git-publish-node.d.ts.map +1 -0
  40. package/dist/git-publish-node.js +107 -0
  41. package/dist/git-worktree-adapter.d.ts +78 -0
  42. package/dist/git-worktree-adapter.d.ts.map +1 -0
  43. package/dist/git-worktree-adapter.js +300 -0
  44. package/dist/git-worktree-snapshot-node.d.ts +31 -0
  45. package/dist/git-worktree-snapshot-node.d.ts.map +1 -0
  46. package/dist/git-worktree-snapshot-node.js +189 -0
  47. package/dist/index.d.ts +9 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +37 -0
  50. package/package.json +9 -5
@@ -0,0 +1,487 @@
1
+ // The governed GitHub pull request gateway (Issue #477, Epic #470, ADR-0064) — AC1–AC5.
2
+ //
3
+ // This is the PR-orchestration authority the #472 kernel deferred to the remote slices: a PARALLEL
4
+ // execution authority to the #476 publish gateway, never an extension of it. A pull request shells
5
+ // `gh api` REST calls, not `git push`; the two are structurally independent (different binary, output
6
+ // shape, and failure taxonomy), so this gateway has its OWN narrow adapter port, its OWN dedicated
7
+ // `gh api` allowlist (create / update / get / the draft GraphQL mutations — NO merge, NO delete), and
8
+ // its OWN GitHub-error classifier. It NEVER touches the publish gateway or the local mutation adapter.
9
+ //
10
+ // Like the kernel and the publish gateway, it is deterministic given its injected dependencies
11
+ // (snapshot, clock, id generator, PR adapter). It performs no IO of its own: the actual `gh api` call
12
+ // lives behind the injected GitPullRequestAdapter (implemented by git-pr-node.ts on the Node subpath),
13
+ // so the orchestrator is unit-testable with a fake adapter and never opens a parallel child_process
14
+ // path. It reuses the kernel's pure machinery unchanged: evaluateGitPreflight (pr-create / pr-update
15
+ // map to preflightNoLocalPrecondition), the GitMutationLifecycleResult shape (so the #474 evidence
16
+ // builder consumes a PR lifecycle with no change), and the failure taxonomy.
17
+ //
18
+ // The actual PR title/body strings flow command → adapter → GitHub; the content-free contract inputs
19
+ // carry only their byte lengths, so evidence never persists raw content.
20
+ import { evaluateGitPolicy, GIT_DELIVERY_SCHEMA_VERSION, gitDeliveryBranchNameMatchesAny, gitDeliveryRiskClassWithinCeiling, gitPrRejectionToDisposition, gitPrRejectionToErrorCode, } from "@oscharko-dev/keiko-contracts";
21
+ import { evaluateGitPreflight } from "./git-mutation-preflight.js";
22
+ import { gitMutationCategoryForExecutionResult } from "./git-mutation-taxonomy.js";
23
+ const UTF8 = new TextEncoder();
24
+ // ─── Dedicated gh-api allowlist ─────────────────────────────────────────────────────────────────────
25
+ // A closed allowlist permitting ONLY the `api` subcommand of `gh`. Structurally separate from the git
26
+ // mutation rules and the git publish rules. The specific REST endpoints/methods (no merge, no delete)
27
+ // are enforced by the pure argv builders below; the allowlist denies any flag that could read an
28
+ // arbitrary file (`--input`) or paginate beyond the targeted resource.
29
+ export const GIT_PULL_REQUEST_ALLOWED_SUBCOMMANDS = Object.freeze(["api"]);
30
+ export const GIT_PULL_REQUEST_COMMAND_RULES = Object.freeze([
31
+ {
32
+ executable: "gh",
33
+ allowedSubcommands: GIT_PULL_REQUEST_ALLOWED_SUBCOMMANDS,
34
+ // gh value flags, declared so the subcommand resolver stays correct even if a flag ever preceded
35
+ // the `api` token (the builders always emit `api` first).
36
+ valueFlags: Object.freeze([
37
+ "--method",
38
+ "-X",
39
+ "--hostname",
40
+ "--jq",
41
+ "-q",
42
+ "-f",
43
+ "--raw-field",
44
+ "-F",
45
+ "--field",
46
+ "-H",
47
+ "--header",
48
+ ]),
49
+ // Deny flags that read arbitrary files / stream bodies from disk.
50
+ denyFlags: Object.freeze(["--input", "--paginate"]),
51
+ },
52
+ ]);
53
+ // ─── Pure argv builders (safe endpoints only; no merge/delete) ───────────────────────────────────────
54
+ export class GitPrArgvError extends Error {
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "GitPrArgvError";
58
+ }
59
+ }
60
+ // NUL + the C0 control range + DEL, enumerated via a string-built RegExp (no literal control chars in
61
+ // source). A ref / repo slug never legitimately contains one.
62
+ // eslint-disable-next-line no-control-regex -- intentionally matches control chars to REJECT them
63
+ const REF_CONTROL_CHAR = new RegExp("[\u0000-\u001f\u007f]");
64
+ // Title is a single line: reject the whole control range.
65
+ // eslint-disable-next-line no-control-regex -- intentionally matches control chars to REJECT them
66
+ const TITLE_CONTROL_CHAR = new RegExp("[\u0000-\u001f\u007f]");
67
+ // Body permits TAB (09), LF (0a), CR (0d); every other control char + NUL + DEL is rejected.
68
+ // eslint-disable-next-line no-control-regex -- intentionally matches control chars to REJECT them
69
+ const BODY_CONTROL_CHAR = new RegExp("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]");
70
+ const OWNER_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
71
+ const PR_NUMBER_RE = /^[1-9][0-9]{0,9}$/;
72
+ function assertRef(value, label) {
73
+ if (value.length === 0) {
74
+ throw new GitPrArgvError(`${label} must not be empty`);
75
+ }
76
+ if (REF_CONTROL_CHAR.test(value)) {
77
+ throw new GitPrArgvError(`${label} must not contain control characters`);
78
+ }
79
+ if (/\s/.test(value)) {
80
+ throw new GitPrArgvError(`${label} must not contain whitespace`);
81
+ }
82
+ if (value.startsWith("-")) {
83
+ throw new GitPrArgvError(`${label} must not start with "-" (flag-injection guard)`);
84
+ }
85
+ if (value.includes(":")) {
86
+ throw new GitPrArgvError(`${label} must not contain ":"`);
87
+ }
88
+ return value;
89
+ }
90
+ function assertOwnerAndRepo(value) {
91
+ if (!OWNER_REPO_RE.test(value)) {
92
+ throw new GitPrArgvError('ownerAndRepo must match "owner/repo"');
93
+ }
94
+ return value;
95
+ }
96
+ function assertPrNumber(value) {
97
+ if (!PR_NUMBER_RE.test(value)) {
98
+ throw new GitPrArgvError("prExternalId must be a positive PR number");
99
+ }
100
+ return value;
101
+ }
102
+ function assertTitle(value) {
103
+ if (value.length === 0) {
104
+ throw new GitPrArgvError("title must not be empty");
105
+ }
106
+ if (TITLE_CONTROL_CHAR.test(value)) {
107
+ throw new GitPrArgvError("title must not contain control characters");
108
+ }
109
+ return value;
110
+ }
111
+ function assertBody(value) {
112
+ if (BODY_CONTROL_CHAR.test(value)) {
113
+ throw new GitPrArgvError("body must not contain disallowed control characters");
114
+ }
115
+ return value;
116
+ }
117
+ // `gh api --method POST /repos/{owner}/{repo}/pulls -f title=… -f body=… -f head=… -f base=… -F draft=…`.
118
+ // `-f` (raw-field) sends a literal string (no `@file` interpretation); `-F` typed-field carries the
119
+ // boolean draft flag. The body may contain newlines and `=`; gh splits each `field=value` on the FIRST
120
+ // `=` only, and the argv is passed as a vector (no shell), so the value is opaque.
121
+ export function buildPrCreateArgv(req) {
122
+ const repo = assertOwnerAndRepo(req.ownerAndRepo);
123
+ const head = assertRef(req.headBranchName, "headBranchName");
124
+ const base = assertRef(req.baseBranchName, "baseBranchName");
125
+ const title = assertTitle(req.title);
126
+ const body = assertBody(req.body);
127
+ return [
128
+ "api",
129
+ "--method",
130
+ "POST",
131
+ `/repos/${repo}/pulls`,
132
+ "-f",
133
+ `title=${title}`,
134
+ "-f",
135
+ `body=${body}`,
136
+ "-f",
137
+ `head=${head}`,
138
+ "-f",
139
+ `base=${base}`,
140
+ "-F",
141
+ `draft=${req.isDraft ? "true" : "false"}`,
142
+ ];
143
+ }
144
+ // `gh api --method PATCH /repos/{owner}/{repo}/pulls/{number} -f title=… -f body=… -f base=…`. The REST
145
+ // update endpoint adjusts metadata only; draft↔ready is a separate GraphQL transition (below).
146
+ export function buildPrUpdateArgv(req) {
147
+ const repo = assertOwnerAndRepo(req.ownerAndRepo);
148
+ const number = assertPrNumber(req.prExternalId);
149
+ const base = assertRef(req.baseBranchName, "baseBranchName");
150
+ const title = assertTitle(req.title);
151
+ const body = assertBody(req.body);
152
+ return [
153
+ "api",
154
+ "--method",
155
+ "PATCH",
156
+ `/repos/${repo}/pulls/${number}`,
157
+ "-f",
158
+ `title=${title}`,
159
+ "-f",
160
+ `body=${body}`,
161
+ "-f",
162
+ `base=${base}`,
163
+ ];
164
+ }
165
+ const GITHUB_NODE_ID_RE = /^[A-Za-z0-9_=-]+$/;
166
+ function assertNodeId(value) {
167
+ if (value.length === 0 || !GITHUB_NODE_ID_RE.test(value)) {
168
+ throw new GitPrArgvError("pull request node id is malformed");
169
+ }
170
+ return value;
171
+ }
172
+ const MARK_READY_MUTATION = "mutation($pullRequestId:ID!){markPullRequestReadyForReview(input:{pullRequestId:$pullRequestId}){pullRequest{isDraft}}}";
173
+ const CONVERT_DRAFT_MUTATION = "mutation($pullRequestId:ID!){convertPullRequestToDraft(input:{pullRequestId:$pullRequestId}){pullRequest{isDraft}}}";
174
+ // `gh api graphql -f query=<mutation> -f pullRequestId=<nodeId>`. The REST PATCH cannot toggle draft
175
+ // state (GitHub exposes it only through GraphQL); these mutations perform the draft↔ready transition.
176
+ export function buildPrMarkReadyGraphqlArgv(nodeId) {
177
+ const id = assertNodeId(nodeId);
178
+ return ["api", "graphql", "-f", `query=${MARK_READY_MUTATION}`, "-f", `pullRequestId=${id}`];
179
+ }
180
+ export function buildPrConvertDraftGraphqlArgv(nodeId) {
181
+ const id = assertNodeId(nodeId);
182
+ return ["api", "graphql", "-f", `query=${CONVERT_DRAFT_MUTATION}`, "-f", `pullRequestId=${id}`];
183
+ }
184
+ // True iff the argv begins with the single allowed `api` subcommand. Lets tests prove the
185
+ // no-generic-fallback property structurally for the PR authority.
186
+ export function gitPrArgvIsGoverned(argv) {
187
+ return argv.length > 0 && GIT_PULL_REQUEST_ALLOWED_SUBCOMMANDS.includes(argv[0] ?? "");
188
+ }
189
+ // ─── Provider-rejection classifier (GitHub-specific; neutral taxonomy lives in keiko-contracts) ─────
190
+ // Ordered phrase table over the (lower-cased, secret-redacted) gh output. GitHub surfaces failures as
191
+ // an HTTP status line plus a JSON body envelope ({message, errors:[{field,code}]}); the specific
192
+ // status/message tokens discriminate. Rate-limit (HTTP 403 with a rate-limit message) is matched
193
+ // BEFORE the generic permission denial, and already-exists (HTTP 422 with a specific message) before
194
+ // the generic validation failure.
195
+ // ⚠️ ORDERING SEMANTIC (load-bearing): rate-limited MUST precede permission-denied (a GitHub rate limit
196
+ // is surfaced as HTTP 403), and already-exists MUST precede validation-error (both are HTTP 422). The
197
+ // classifier returns on the FIRST matching row, so reordering rows silently flips the classification. Do
198
+ // not reorder or insert a row without re-checking this invariant (covered by the ambiguous-token test).
199
+ const REJECTION_PHRASES = [
200
+ ["already-exists", ["a pull request already exists", "already exists for"]],
201
+ ["rate-limited", ["rate limit exceeded", "secondary rate limit", "exceeded a secondary rate"]],
202
+ [
203
+ "permission-denied",
204
+ [
205
+ "http 401",
206
+ "http 403",
207
+ "bad credentials",
208
+ "must have admin",
209
+ "resource not accessible",
210
+ "forbidden",
211
+ ],
212
+ ],
213
+ ["not-found", ["http 404", "not found"]],
214
+ [
215
+ "head-unpublished",
216
+ ["head sha can't be blank", "field: head", '"field":"head"', "no ref found"],
217
+ ],
218
+ ["base-missing", ["field: base", '"field":"base"', "base does not exist"]],
219
+ [
220
+ "validation-error",
221
+ ["http 422", "validation failed", "unprocessable", "no commits between", "invalid request"],
222
+ ],
223
+ [
224
+ "provider-unavailable",
225
+ [
226
+ "http 502",
227
+ "http 503",
228
+ "http 504",
229
+ "bad gateway",
230
+ "service unavailable",
231
+ "could not resolve host",
232
+ "connection refused",
233
+ "timed out",
234
+ "timeout",
235
+ ],
236
+ ],
237
+ ];
238
+ export function classifyGitPullRequestRejection(output) {
239
+ const haystack = output.toLowerCase();
240
+ for (const [reason, phrases] of REJECTION_PHRASES) {
241
+ if (phrases.some((phrase) => haystack.includes(phrase))) {
242
+ return reason;
243
+ }
244
+ }
245
+ return "unknown";
246
+ }
247
+ // Action hint only where the #473 vocabulary fits cleanly; the precise rejectionReason is the primary
248
+ // user-facing signal, so most reasons intentionally carry NO hint rather than a misleading one.
249
+ const REJECTION_ACTION_HINT = {
250
+ "already-exists": undefined,
251
+ "base-missing": undefined,
252
+ "head-unpublished": "configure-upstream",
253
+ "validation-error": undefined,
254
+ "permission-denied": undefined,
255
+ "not-found": undefined,
256
+ "rate-limited": "wait-for-provider",
257
+ "provider-unavailable": "wait-for-provider",
258
+ unknown: undefined,
259
+ };
260
+ export function gitPullRequestRejectionFor(reason) {
261
+ const actionHint = REJECTION_ACTION_HINT[reason];
262
+ return {
263
+ reason,
264
+ disposition: gitPrRejectionToDisposition(reason),
265
+ ...(actionHint !== undefined ? { actionHint } : {}),
266
+ };
267
+ }
268
+ function constraintBlock(constraint, target, capabilities, actionKind) {
269
+ if (constraint.kind === "branch-pattern") {
270
+ const ok = target !== undefined && gitDeliveryBranchNameMatchesAny(target, constraint.patterns);
271
+ return ok ? undefined : "policy-pack-blocked";
272
+ }
273
+ if (constraint.kind === "provider-capability") {
274
+ return capabilities.includes(constraint.capability) ? undefined : "provider-capability-absent";
275
+ }
276
+ return gitDeliveryRiskClassWithinCeiling(actionKind, constraint.maxRiskClass)
277
+ ? undefined
278
+ : "risk-class-ceiling";
279
+ }
280
+ // The EFFECTIVE policy outcome for a specific PR base target: a `constrained` decision is resolved
281
+ // against the base branch (a protected/unlisted base reads as blocked). Approval state is not
282
+ // considered here (the preview has no approval), so an approval-gated decision reads as approval-gated.
283
+ export function evaluateGitPullRequestEffectivePolicy(decision, baseTarget, capabilities, actionKind) {
284
+ if (decision.outcome === "allowed") {
285
+ return { outcome: "allowed" };
286
+ }
287
+ if (decision.outcome === "blocked") {
288
+ return { outcome: "blocked", blockReason: decision.reason };
289
+ }
290
+ if (decision.outcome === "approval-gated") {
291
+ return { outcome: "approval-gated" };
292
+ }
293
+ for (const constraint of decision.constraints) {
294
+ const reason = constraintBlock(constraint, baseTarget, capabilities, actionKind);
295
+ if (reason !== undefined) {
296
+ return { outcome: "blocked", blockReason: reason };
297
+ }
298
+ }
299
+ return { outcome: "allowed" };
300
+ }
301
+ function prResolvedInputs(command) {
302
+ if (command.kind === "pr-create") {
303
+ return {
304
+ kind: "pr-create",
305
+ headBranchName: command.headBranchName,
306
+ baseBranchName: command.baseBranchName,
307
+ titleByteLength: UTF8.encode(command.title).length,
308
+ bodyByteLength: UTF8.encode(command.body).length,
309
+ isDraft: command.isDraft,
310
+ };
311
+ }
312
+ return {
313
+ kind: "pr-update",
314
+ prExternalId: command.prExternalId,
315
+ headBranchName: command.headBranchName,
316
+ baseBranchName: command.baseBranchName,
317
+ titleByteLength: UTF8.encode(command.title).length,
318
+ bodyByteLength: UTF8.encode(command.body).length,
319
+ convertToDraft: command.convertToDraft,
320
+ convertFromDraft: command.convertFromDraft,
321
+ };
322
+ }
323
+ function buildPrPreview(command) {
324
+ return {
325
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
326
+ affectedBranchName: command.headBranchName,
327
+ wouldCreateRemoteBranch: false,
328
+ wouldTriggerChecks: true,
329
+ };
330
+ }
331
+ function assembleEnvelope(actionId, inputs, policyDecision, approval, preview, executionResult) {
332
+ return {
333
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
334
+ actionId,
335
+ kind: inputs.kind,
336
+ resolvedInputs: inputs,
337
+ policyDecision,
338
+ approvalRequirement: approval,
339
+ preview,
340
+ ...(executionResult !== undefined ? { executionResult } : {}),
341
+ };
342
+ }
343
+ function approvalState(approval, now) {
344
+ if (!approval.required) {
345
+ return "absent";
346
+ }
347
+ if (approval.expiresAtMs !== undefined && approval.expiresAtMs <= now) {
348
+ return "expired";
349
+ }
350
+ return "valid";
351
+ }
352
+ function resolvePrGate(decision, approval, target, capabilities, actionKind, now) {
353
+ if (decision.outcome === "allowed") {
354
+ return { proceed: true };
355
+ }
356
+ if (decision.outcome === "blocked") {
357
+ return { proceed: false, status: "policy-block", reason: decision.reason };
358
+ }
359
+ if (decision.outcome === "approval-gated") {
360
+ const state = approvalState(approval, now);
361
+ if (state === "valid")
362
+ return { proceed: true };
363
+ if (state === "expired") {
364
+ return { proceed: false, status: "policy-block", reason: "approval-expired" };
365
+ }
366
+ return { proceed: false, status: "approval-required", approvers: decision.requiredApprovers };
367
+ }
368
+ for (const constraint of decision.constraints) {
369
+ const reason = constraintBlock(constraint, target, capabilities, actionKind);
370
+ if (reason !== undefined) {
371
+ return { proceed: false, status: "policy-block", reason };
372
+ }
373
+ }
374
+ return { proceed: true };
375
+ }
376
+ function prOutcomeFor(result) {
377
+ if (result.outcome === "succeeded") {
378
+ return { status: "succeeded", executionResult: result };
379
+ }
380
+ const category = gitMutationCategoryForExecutionResult(result) ?? "execution-failure";
381
+ if (category === "recovery-required") {
382
+ return { status: "recovery-required", category, executionResult: result };
383
+ }
384
+ if (category === "provider-failure") {
385
+ return { status: "failed", category, executionResult: result };
386
+ }
387
+ return { status: "failed", category: "execution-failure", executionResult: result };
388
+ }
389
+ function preparePr(request, deps) {
390
+ const inputs = prResolvedInputs(request.command);
391
+ const capabilities = deps.activeProviderCapabilities ?? [];
392
+ const context = {
393
+ actionKind: request.command.kind,
394
+ targetBranchName: request.command.baseBranchName,
395
+ activeProviderCapabilities: capabilities,
396
+ };
397
+ return {
398
+ inputs,
399
+ preflight: evaluateGitPreflight(inputs, deps.snapshot),
400
+ preview: buildPrPreview(request.command),
401
+ policyDecision: evaluateGitPolicy(deps.orgPolicyPack, deps.repoPolicyPack, context),
402
+ actionId: deps.newActionId(),
403
+ };
404
+ }
405
+ function lifecycleFor(prep, approval, outcome, phaseReached, executionResult) {
406
+ return {
407
+ envelope: assembleEnvelope(prep.actionId, prep.inputs, prep.policyDecision, approval, prep.preview, executionResult),
408
+ outcome,
409
+ phaseReached,
410
+ preflight: prep.preflight,
411
+ };
412
+ }
413
+ async function runPrAdapter(command, adapter) {
414
+ try {
415
+ if (command.kind === "pr-create") {
416
+ return await adapter.createPullRequest({
417
+ ownerAndRepo: command.ownerAndRepo,
418
+ headBranchName: command.headBranchName,
419
+ baseBranchName: command.baseBranchName,
420
+ title: command.title,
421
+ body: command.body,
422
+ isDraft: command.isDraft,
423
+ });
424
+ }
425
+ return await adapter.updatePullRequest({
426
+ ownerAndRepo: command.ownerAndRepo,
427
+ prExternalId: command.prExternalId,
428
+ baseBranchName: command.baseBranchName,
429
+ title: command.title,
430
+ body: command.body,
431
+ convertToDraft: command.convertToDraft,
432
+ convertFromDraft: command.convertFromDraft,
433
+ });
434
+ }
435
+ catch {
436
+ return {
437
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
438
+ outcome: "failed",
439
+ durationMs: 0,
440
+ errorCode: "internal-error",
441
+ };
442
+ }
443
+ }
444
+ /**
445
+ * Runs ONE governed pull request operation end-to-end: derive PR inputs → preflight (no local
446
+ * precondition) → preview → policy → (only when policy permits and any approval is satisfied) execute
447
+ * through the narrow PR adapter. Returns a kernel-shaped lifecycle result (so the #474 evidence builder
448
+ * records it unchanged) plus the live provider-rejection descriptor when the provider rejected the
449
+ * operation, and the provider-assigned PR number on a successful create.
450
+ */
451
+ export async function runGitPullRequest(request, deps) {
452
+ const prep = preparePr(request, deps);
453
+ if (!prep.preflight.ok) {
454
+ const outcome = {
455
+ status: "blocked",
456
+ category: "preflight-block",
457
+ findings: prep.preflight.blocking,
458
+ };
459
+ return { lifecycle: lifecycleFor(prep, request.approval, outcome, "preflight", undefined) };
460
+ }
461
+ const gate = resolvePrGate(prep.policyDecision, request.approval, request.command.baseBranchName, deps.activeProviderCapabilities ?? [], request.command.kind, deps.now());
462
+ if (!gate.proceed) {
463
+ const outcome = gate.status === "approval-required"
464
+ ? { status: "approval-required", requiredApprovers: gate.approvers }
465
+ : { status: "blocked", category: "policy-block", blockReason: gate.reason };
466
+ return { lifecycle: lifecycleFor(prep, request.approval, outcome, "policy", undefined) };
467
+ }
468
+ const result = await runPrAdapter(request.command, deps.adapter);
469
+ const outcome = prOutcomeFor(result);
470
+ const lifecycle = lifecycleFor(prep, request.approval, outcome, "result", result);
471
+ const createdPrExternalId = result.createdPrExternalId;
472
+ if (result.outcome !== "succeeded" && result.outcome !== "aborted") {
473
+ const reason = result.rejectionReason ?? "unknown";
474
+ return {
475
+ lifecycle,
476
+ rejection: gitPullRequestRejectionFor(reason),
477
+ ...(createdPrExternalId !== undefined ? { createdPrExternalId } : {}),
478
+ };
479
+ }
480
+ return {
481
+ lifecycle,
482
+ ...(createdPrExternalId !== undefined ? { createdPrExternalId } : {}),
483
+ };
484
+ }
485
+ // Re-export the contract bridges so the server/UI consume the error-code mapping from this gateway,
486
+ // keeping the publish/PR gateway surfaces symmetric.
487
+ export { gitPrRejectionToErrorCode };
@@ -0,0 +1,17 @@
1
+ import type { WorkspaceInfo } from "@oscharko-dev/keiko-workspace";
2
+ import { type GitPullRequestAdapter } from "./git-pr-gateway.js";
3
+ import { type ExecutableResolver, type HomeProvider, type SpawnFn } from "./exec.js";
4
+ import { type SandboxPolicy } from "./types.js";
5
+ export interface NodeGitPullRequestAdapterDeps {
6
+ readonly workspace: WorkspaceInfo;
7
+ readonly processEnv?: NodeJS.ProcessEnv | undefined;
8
+ readonly now?: (() => number) | undefined;
9
+ readonly spawn?: SpawnFn | undefined;
10
+ readonly policy?: SandboxPolicy | undefined;
11
+ readonly resolveExecutable?: ExecutableResolver | undefined;
12
+ readonly home?: HomeProvider | undefined;
13
+ readonly signal?: AbortSignal | undefined;
14
+ readonly timeoutMs?: number | undefined;
15
+ }
16
+ export declare function createNodeGitPullRequestAdapter(deps: NodeGitPullRequestAdapterDeps): GitPullRequestAdapter;
17
+ //# sourceMappingURL=git-pr-node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-pr-node.d.ts","sourceRoot":"","sources":["../src/git-pr-node.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAKnE,OAAO,EAWL,KAAK,qBAAqB,EAC3B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,YAAY,EAEjB,KAAK,OAAO,EACb,MAAM,WAAW,CAAC;AACnB,OAAO,EAA8C,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5F,MAAM,WAAW,6BAA6B;IAE5C,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,UAAU,GAAG,SAAS,CAAC;IACpD,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAGrC,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IAC5C,QAAQ,CAAC,iBAAiB,CAAC,EAAE,kBAAkB,GAAG,SAAS,CAAC;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACzC;AA+KD,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,6BAA6B,GAClC,qBAAqB,CAQvB"}
@@ -0,0 +1,173 @@
1
+ // Node implementation of the narrow GitHub pull request adapter (Issue #477, Epic #470) — AC1/AC4/AC5.
2
+ //
3
+ // This is the ONLY place a governed pull request operation actually executes. It builds the governed
4
+ // `gh api` argv from the pure builders (git-pr-gateway.ts) and runs it through the SAME keiko-tools
5
+ // no-shell spawn boundary (`runCommand`, exec.ts) with a DEDICATED PR allowlist
6
+ // (`GIT_PULL_REQUEST_COMMAND_RULES`) that permits only `gh api` and denies file-input flags. There is
7
+ // no method that accepts an arbitrary command string and no parallel child_process path: the
8
+ // deny-by-default allowlist, env isolation, redaction, and cancellation of the shared boundary apply to
9
+ // the PR operation exactly as to every other tool.
10
+ //
11
+ // `gh` reads its own GitHub token from its keyring or from GH_TOKEN/GITHUB_TOKEN in the inherited
12
+ // process environment; Keiko never reads or handles the token value. A non-OK HTTP status / non-zero
13
+ // exit is classified into a typed GitPullRequestRejectionReason by matching GitHub's own error tokens in
14
+ // the (already secret-redacted) output. Raw stdout/stderr never leave this module — only the typed
15
+ // reason, the content-free contract error code, and the opaque provider-assigned PR number cross out.
16
+ //
17
+ // Lives on the `./internal/git-mutation` subpath (re-exported by git-mutation-node.ts) because it
18
+ // carries the Node execution effect; the pure port, builders, and rules it implements are on the barrel.
19
+ import { GIT_DELIVERY_SCHEMA_VERSION, } from "@oscharko-dev/keiko-contracts";
20
+ import { buildPrConvertDraftGraphqlArgv, buildPrCreateArgv, buildPrMarkReadyGraphqlArgv, buildPrUpdateArgv, classifyGitPullRequestRejection, GIT_PULL_REQUEST_COMMAND_RULES, gitPrRejectionToErrorCode, } from "./git-pr-gateway.js";
21
+ import { CommandCancelledError, CommandTimeoutError } from "./errors.js";
22
+ import { nodeSpawnFn, runCommand, } from "./exec.js";
23
+ import { DEFAULT_SANDBOX_POLICY } from "./types.js";
24
+ function executionResult(outcome, durationMs, extra) {
25
+ return {
26
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
27
+ outcome,
28
+ durationMs: Math.max(0, Math.trunc(durationMs)),
29
+ ...extra,
30
+ };
31
+ }
32
+ function buildRunContext(deps) {
33
+ return {
34
+ runDeps: {
35
+ workspace: deps.workspace,
36
+ policy: deps.policy ?? DEFAULT_SANDBOX_POLICY,
37
+ commandRules: GIT_PULL_REQUEST_COMMAND_RULES,
38
+ spawn: deps.spawn ?? nodeSpawnFn,
39
+ processEnv: deps.processEnv ?? process.env,
40
+ now: deps.now ?? Date.now,
41
+ ...(deps.resolveExecutable !== undefined
42
+ ? { resolveExecutable: deps.resolveExecutable }
43
+ : {}),
44
+ ...(deps.home !== undefined ? { home: deps.home } : {}),
45
+ },
46
+ signal: deps.signal ?? new AbortController().signal,
47
+ timeoutMs: deps.timeoutMs,
48
+ };
49
+ }
50
+ function rejectionFromExit(result) {
51
+ if (result.timedOut) {
52
+ return executionResult("failed", result.durationMs, {
53
+ errorCode: "timeout",
54
+ rejectionReason: "provider-unavailable",
55
+ });
56
+ }
57
+ const reason = classifyGitPullRequestRejection(`${result.stdout}\n${result.stderr}`);
58
+ return executionResult("failed", result.durationMs, {
59
+ errorCode: gitPrRejectionToErrorCode(reason),
60
+ rejectionReason: reason,
61
+ });
62
+ }
63
+ function failureFromThrow(error, durationMs) {
64
+ if (error instanceof CommandTimeoutError) {
65
+ return executionResult("failed", durationMs, {
66
+ errorCode: "timeout",
67
+ rejectionReason: "provider-unavailable",
68
+ });
69
+ }
70
+ if (error instanceof CommandCancelledError) {
71
+ return executionResult("aborted", durationMs);
72
+ }
73
+ return executionResult("failed", durationMs, { errorCode: "internal-error" });
74
+ }
75
+ async function runGh(ctx, argv) {
76
+ try {
77
+ return await runCommand({ command: "gh", args: argv, cwd: undefined, timeoutMs: ctx.timeoutMs, signal: ctx.signal }, ctx.runDeps);
78
+ }
79
+ catch (error) {
80
+ return error instanceof Error ? error : new Error("gh invocation failed");
81
+ }
82
+ }
83
+ // Extracts the leading run of digits from `gh api … --jq .number` output (a single value, already
84
+ // redacted). Returns undefined when the output is empty or non-numeric.
85
+ function parsePrNumber(stdout) {
86
+ const match = /^\s*(\d{1,10})\s*$/.exec(stdout);
87
+ return match?.[1];
88
+ }
89
+ function parseNodeId(stdout) {
90
+ const trimmed = stdout.trim();
91
+ return /^[A-Za-z0-9_=-]+$/.test(trimmed) ? trimmed : undefined;
92
+ }
93
+ async function createPullRequest(ctx, req) {
94
+ let argv;
95
+ try {
96
+ // Append `--jq .number` so the success stdout is just the provider-assigned PR number (a clean,
97
+ // parse-robust value rather than the full PR JSON, which redaction or the output cap could disturb).
98
+ argv = [...buildPrCreateArgv(req), "--jq", ".number"];
99
+ }
100
+ catch {
101
+ return executionResult("failed", 0, { errorCode: "internal-error" });
102
+ }
103
+ const result = await runGh(ctx, argv);
104
+ if (result instanceof Error) {
105
+ return failureFromThrow(result, 0);
106
+ }
107
+ if (result.exitCode !== 0) {
108
+ return rejectionFromExit(result);
109
+ }
110
+ const createdPrExternalId = parsePrNumber(result.stdout);
111
+ return executionResult("succeeded", result.durationMs, {
112
+ ...(createdPrExternalId !== undefined ? { createdPrExternalId } : {}),
113
+ });
114
+ }
115
+ // Performs the draft↔ready transition the REST update endpoint cannot: looks up the PR's GraphQL node
116
+ // id, then runs the appropriate mutation. Returns undefined on success, or the failed exec result.
117
+ async function runDraftTransition(ctx, req, totalDuration) {
118
+ if (!req.convertToDraft && !req.convertFromDraft) {
119
+ return undefined;
120
+ }
121
+ const idResult = await runGh(ctx, [
122
+ "api",
123
+ `/repos/${req.ownerAndRepo}/pulls/${req.prExternalId}`,
124
+ "--jq",
125
+ ".node_id",
126
+ ]);
127
+ if (idResult instanceof Error) {
128
+ return failureFromThrow(idResult, totalDuration);
129
+ }
130
+ if (idResult.exitCode !== 0) {
131
+ return rejectionFromExit(idResult);
132
+ }
133
+ const nodeId = parseNodeId(idResult.stdout);
134
+ if (nodeId === undefined) {
135
+ return executionResult("failed", totalDuration, { errorCode: "internal-error" });
136
+ }
137
+ const mutation = req.convertToDraft
138
+ ? buildPrConvertDraftGraphqlArgv(nodeId)
139
+ : buildPrMarkReadyGraphqlArgv(nodeId);
140
+ const mutationResult = await runGh(ctx, mutation);
141
+ if (mutationResult instanceof Error) {
142
+ return failureFromThrow(mutationResult, totalDuration);
143
+ }
144
+ return mutationResult.exitCode === 0 ? undefined : rejectionFromExit(mutationResult);
145
+ }
146
+ async function updatePullRequest(ctx, req) {
147
+ let argv;
148
+ try {
149
+ argv = buildPrUpdateArgv(req);
150
+ }
151
+ catch {
152
+ return executionResult("failed", 0, { errorCode: "internal-error" });
153
+ }
154
+ const patch = await runGh(ctx, argv);
155
+ if (patch instanceof Error) {
156
+ return failureFromThrow(patch, 0);
157
+ }
158
+ if (patch.exitCode !== 0) {
159
+ return rejectionFromExit(patch);
160
+ }
161
+ const transition = await runDraftTransition(ctx, req, patch.durationMs);
162
+ if (transition !== undefined) {
163
+ return transition;
164
+ }
165
+ return executionResult("succeeded", patch.durationMs, { createdPrExternalId: req.prExternalId });
166
+ }
167
+ export function createNodeGitPullRequestAdapter(deps) {
168
+ const ctx = buildRunContext(deps);
169
+ return {
170
+ createPullRequest: (req) => createPullRequest(ctx, req),
171
+ updatePullRequest: (req) => updatePullRequest(ctx, req),
172
+ };
173
+ }