@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/git-commit-intent-node.d.ts +7 -0
- package/dist/git-commit-intent-node.d.ts.map +1 -0
- package/dist/git-commit-intent-node.js +42 -0
- package/dist/git-merge-gateway.d.ts +95 -0
- package/dist/git-merge-gateway.d.ts.map +1 -0
- package/dist/git-merge-gateway.js +536 -0
- package/dist/git-merge-node.d.ts +17 -0
- package/dist/git-merge-node.d.ts.map +1 -0
- package/dist/git-merge-node.js +243 -0
- package/dist/git-mutation-adapter.d.ts +51 -0
- package/dist/git-mutation-adapter.d.ts.map +1 -0
- package/dist/git-mutation-adapter.js +207 -0
- package/dist/git-mutation-evidence.d.ts +23 -0
- package/dist/git-mutation-evidence.d.ts.map +1 -0
- package/dist/git-mutation-evidence.js +283 -0
- package/dist/git-mutation-node.d.ts +22 -0
- package/dist/git-mutation-node.d.ts.map +1 -0
- package/dist/git-mutation-node.js +145 -0
- package/dist/git-mutation-orchestrator.d.ts +92 -0
- package/dist/git-mutation-orchestrator.d.ts.map +1 -0
- package/dist/git-mutation-orchestrator.js +321 -0
- package/dist/git-mutation-preflight.d.ts +35 -0
- package/dist/git-mutation-preflight.d.ts.map +1 -0
- package/dist/git-mutation-preflight.js +226 -0
- package/dist/git-mutation-taxonomy.d.ts +15 -0
- package/dist/git-mutation-taxonomy.d.ts.map +1 -0
- package/dist/git-mutation-taxonomy.js +102 -0
- package/dist/git-pr-gateway.d.ts +101 -0
- package/dist/git-pr-gateway.d.ts.map +1 -0
- package/dist/git-pr-gateway.js +487 -0
- package/dist/git-pr-node.d.ts +17 -0
- package/dist/git-pr-node.d.ts.map +1 -0
- package/dist/git-pr-node.js +173 -0
- package/dist/git-publish-gateway.d.ts +72 -0
- package/dist/git-publish-gateway.d.ts.map +1 -0
- package/dist/git-publish-gateway.js +423 -0
- package/dist/git-publish-node.d.ts +17 -0
- package/dist/git-publish-node.d.ts.map +1 -0
- package/dist/git-publish-node.js +107 -0
- package/dist/git-worktree-adapter.d.ts +78 -0
- package/dist/git-worktree-adapter.d.ts.map +1 -0
- package/dist/git-worktree-adapter.js +300 -0
- package/dist/git-worktree-snapshot-node.d.ts +31 -0
- package/dist/git-worktree-snapshot-node.d.ts.map +1 -0
- package/dist/git-worktree-snapshot-node.js +189 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -0
- 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
|
+
}
|