@brainervirus/workit-core 2.3.0 → 2.5.0
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/package.json +1 -1
- package/src/autonomy.ts +135 -0
- package/src/core/external-action-effects.ts +4 -9
- package/src/core/pr-create.ts +2 -71
- package/src/forge/exec.ts +221 -0
- package/src/forge/github.ts +529 -0
- package/src/forge/gitlab.ts +556 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-ops.ts +450 -0
- package/src/forge/redact.ts +191 -0
- package/src/forge/report.ts +618 -0
- package/src/forge/reruns.ts +127 -0
- package/src/forge/resolve.ts +240 -0
- package/src/forge/types.ts +166 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/ops.ts +626 -0
- package/src/git/rev.ts +120 -11
- package/src/ledger.ts +17 -1
package/package.json
CHANGED
package/src/autonomy.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Autonomy grants (D4, D15; design §0 #12, §4.2): the per-workspace ceiling on
|
|
2
|
+
// what an agent may deliver without asking. S11 introduces the reader and the
|
|
3
|
+
// one seam every delivery verb consults, `requireGrant(kind)`; S16 adds
|
|
4
|
+
// `workit grant`, the D4 defaults and the legacy `autoApprove` mapping.
|
|
5
|
+
//
|
|
6
|
+
// Today (S11):
|
|
7
|
+
// - Grants are read only from the matched workspace entry in the user's
|
|
8
|
+
// `~/.config/workit/workspaces.json` (`"autonomy": {"push": true, "pr": true,
|
|
9
|
+
// "merge": false | true | "verified", "release": false}`), never from a repo
|
|
10
|
+
// file, and nothing here writes them (D15: never raised headless).
|
|
11
|
+
// - An explicit value is honored: `false` denies with `grant_required`, `true`
|
|
12
|
+
// allows, and `merge: "verified"` allows only with an accepted independent
|
|
13
|
+
// verdict (S13). `merge: true` is the only way to merge without one.
|
|
14
|
+
// - An absent grant is allowed on the host's own authority (`source:
|
|
15
|
+
// "host_authority"`): the host permission prompt is the ceiling, and a merge
|
|
16
|
+
// still needs an accepted verdict. S16 replaces that default with the D4
|
|
17
|
+
// ceiling `{push: true, pr: true, merge: false, release: false}`.
|
|
18
|
+
// - Grants require an account (design §2.0 Identity): a workspace with any
|
|
19
|
+
// grant but no `vcs.account` cannot push, open or merge through a forge,
|
|
20
|
+
// so a personal grant never rides a work credential.
|
|
21
|
+
import { resolveRuntimeWorkspaceVcs } from "./core/workspaces";
|
|
22
|
+
|
|
23
|
+
export type GrantKind = "push" | "pr" | "merge" | "release";
|
|
24
|
+
const GRANT_KINDS: readonly GrantKind[] = ["push", "pr", "merge", "release"];
|
|
25
|
+
|
|
26
|
+
/** One grant as configured: `"verified"` means allowed with an accepted verdict. */
|
|
27
|
+
export type GrantValue = boolean | "verified";
|
|
28
|
+
|
|
29
|
+
export type Autonomy = {
|
|
30
|
+
workspace: string | null;
|
|
31
|
+
/** Explicitly configured grants; a kind that is absent falls back to host authority. */
|
|
32
|
+
grants: Partial<Record<GrantKind, GrantValue>>;
|
|
33
|
+
source: "autonomy" | "host_authority";
|
|
34
|
+
accountConfigured: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type GrantDecision =
|
|
38
|
+
| {
|
|
39
|
+
allowed: true;
|
|
40
|
+
kind: GrantKind;
|
|
41
|
+
/** Where the permission came from. */
|
|
42
|
+
source: "autonomy" | "host_authority";
|
|
43
|
+
/** A merge must also have an accepted independent verdict (S13). */
|
|
44
|
+
requireVerdict: boolean;
|
|
45
|
+
workspace: string | null;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
allowed: false;
|
|
49
|
+
kind: GrantKind;
|
|
50
|
+
reason: "grant_required" | "account_required" | "config_invalid";
|
|
51
|
+
error: string;
|
|
52
|
+
unblock: string;
|
|
53
|
+
workspace: string | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const isGrantValue = (value: unknown): value is GrantValue =>
|
|
57
|
+
value === true || value === false || value === "verified";
|
|
58
|
+
|
|
59
|
+
/** The grants for the workspace `cwd` belongs to (read-only). */
|
|
60
|
+
export function resolveAutonomy(cwd: string): Autonomy {
|
|
61
|
+
const workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
62
|
+
const raw = (workspace as { autonomy?: unknown } | null)?.autonomy;
|
|
63
|
+
const grants: Autonomy["grants"] = {};
|
|
64
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
65
|
+
for (const kind of GRANT_KINDS) {
|
|
66
|
+
const value = (raw as Record<string, unknown>)[kind];
|
|
67
|
+
if (isGrantValue(value)) grants[kind] = value;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
workspace: workspace?.name ?? null,
|
|
71
|
+
grants,
|
|
72
|
+
source: Object.keys(grants).length > 0 ? "autonomy" : "host_authority",
|
|
73
|
+
accountConfigured: Boolean(workspace?.vcs?.account),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const grantHint = (workspace: string | null, kind: GrantKind): string =>
|
|
78
|
+
`ask the user to set "autonomy": {"${kind}": ${kind === "merge" ? '"verified"' : "true"}} for workspace "${workspace ?? "?"}" in ~/.config/workit/workspaces.json (S16: workit grant set ${workspace ?? "<workspace>"} ${kind}=${kind === "merge" ? "verified" : "true"})`;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The seam every delivery verb consults before an external effect. `forge`
|
|
82
|
+
* says the effect goes through a GitHub/GitLab account (a push to a local
|
|
83
|
+
* path does not).
|
|
84
|
+
*/
|
|
85
|
+
export function requireGrant(
|
|
86
|
+
cwd: string,
|
|
87
|
+
kind: GrantKind,
|
|
88
|
+
options: { forge?: boolean } = {},
|
|
89
|
+
): GrantDecision {
|
|
90
|
+
let autonomy: Autonomy;
|
|
91
|
+
try {
|
|
92
|
+
autonomy = resolveAutonomy(cwd);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
allowed: false,
|
|
96
|
+
kind,
|
|
97
|
+
reason: "config_invalid",
|
|
98
|
+
error: `grant_unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
|
99
|
+
unblock: "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
100
|
+
workspace: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const { workspace } = autonomy;
|
|
104
|
+
const value = autonomy.grants[kind];
|
|
105
|
+
if (value === false)
|
|
106
|
+
return {
|
|
107
|
+
allowed: false,
|
|
108
|
+
kind,
|
|
109
|
+
reason: "grant_required",
|
|
110
|
+
error: `grant_required: ${kind} is not granted for workspace "${workspace ?? "?"}"`,
|
|
111
|
+
unblock: grantHint(workspace, kind),
|
|
112
|
+
workspace,
|
|
113
|
+
};
|
|
114
|
+
if (
|
|
115
|
+
autonomy.source === "autonomy" &&
|
|
116
|
+
!autonomy.accountConfigured &&
|
|
117
|
+
options.forge !== false &&
|
|
118
|
+
kind !== "release"
|
|
119
|
+
)
|
|
120
|
+
return {
|
|
121
|
+
allowed: false,
|
|
122
|
+
kind,
|
|
123
|
+
reason: "account_required",
|
|
124
|
+
error: `account_required: workspace "${workspace ?? "?"}" has autonomy grants but no vcs.account, so the credential cannot be checked`,
|
|
125
|
+
unblock: `set vcs.account for workspace "${workspace ?? "?"}" in ~/.config/workit/workspaces.json`,
|
|
126
|
+
workspace,
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
allowed: true,
|
|
130
|
+
kind,
|
|
131
|
+
source: value === undefined ? "host_authority" : "autonomy",
|
|
132
|
+
requireVerdict: kind === "merge" && value !== true,
|
|
133
|
+
workspace,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import { normalizeChainSteps } from "./authority";
|
|
58
58
|
import { resolveInside, run as coreRun } from "../core";
|
|
59
59
|
import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
60
|
+
import { deleteRemoteBranch } from "../git/ops";
|
|
60
61
|
import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
|
|
61
62
|
import { sameDirectoryIdentity, TaskStore } from "./task-store";
|
|
62
63
|
|
|
@@ -2128,15 +2129,9 @@ export const executeConcreteExternalAction = async (
|
|
|
2128
2129
|
outcome: "not_started",
|
|
2129
2130
|
});
|
|
2130
2131
|
const branch = request.payload.branch;
|
|
2131
|
-
const result =
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
"--delete",
|
|
2135
|
-
target,
|
|
2136
|
-
branch,
|
|
2137
|
-
]);
|
|
2138
|
-
if (result.exitCode === 0) return success(null, null, { branch, deletedTip: approvedTip });
|
|
2139
|
-
return /stale info|cannot lock ref|fetch first|non-fast-forward/i.test(result.stderr)
|
|
2132
|
+
const result = deleteRemoteBranch(root, target, branch, approvedTip);
|
|
2133
|
+
if (result.ok) return success(null, null, { branch, deletedTip: approvedTip });
|
|
2134
|
+
return result.lease
|
|
2140
2135
|
? failure("capability_unavailable", "remote branch tip changed before deletion", {
|
|
2141
2136
|
outcome: "not_started",
|
|
2142
2137
|
})
|
package/src/core/pr-create.ts
CHANGED
|
@@ -5,21 +5,13 @@ import { pathToFileURL } from "node:url";
|
|
|
5
5
|
import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
|
|
6
6
|
export { hostingApiHostMatches } from "./vcs-config";
|
|
7
7
|
import { resolveBranchPolicyFor } from "./branch";
|
|
8
|
+
import { buildBody, parseGhIssue, parseGhRepo } from "../forge/pr-body";
|
|
8
9
|
|
|
9
10
|
// Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
|
|
10
11
|
// Hosted creation is enabled (decision ae03c569): every create path pre-binds
|
|
11
12
|
// the approved source SHA through WORKIT_EXPECTED_* and the shared executor
|
|
12
13
|
// post-verifies the provider PR head before reporting success. The residual
|
|
13
14
|
// non-atomic source-SHA race is accepted.
|
|
14
|
-
function parseGhRepo(remote: string): string | null {
|
|
15
|
-
remote = (remote || "").trim().replace(/\/+$/, "");
|
|
16
|
-
if (!remote) return null;
|
|
17
|
-
if (remote.endsWith(".git")) remote = remote.slice(0, -4);
|
|
18
|
-
if (remote.includes(":")) remote = remote.split(":").pop() ?? ""; // drop git@host part (scp-style URL)
|
|
19
|
-
const parts = remote.split("/").filter(Boolean);
|
|
20
|
-
return parts.length >= 2 ? parts.slice(-2).join("/") : null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
15
|
const remoteRepoPath = (
|
|
24
16
|
remote: string,
|
|
25
17
|
): { host: string; path: string; protocol: string } | null => {
|
|
@@ -63,68 +55,6 @@ const cliRepository = (
|
|
|
63
55
|
return host === "gitlab.com" ? parsed.path : `https://${host}/${parsed.path}`;
|
|
64
56
|
};
|
|
65
57
|
|
|
66
|
-
function parseGhIssue(value: string): string {
|
|
67
|
-
const m = /issues\/(\d+)/.exec(value);
|
|
68
|
-
return m ? m[1] : String(value).trim().replace(/^#/, "");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// RL-03/CA-25/AR-08: a branch-derived numeric issue id must be a bare number at
|
|
72
|
-
// a segment or dash boundary — and never part of a date segment. Year-first
|
|
73
|
-
// (feature/2024-01-15/x) and day-first (feature/15-01-2024/x) dates are both
|
|
74
|
-
// skipped — a complete date anywhere in a segment (release-2024-01-15,
|
|
75
|
-
// v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
|
|
76
|
-
// numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
|
|
77
|
-
function deriveGhIssueFromBranch(branch: string): string {
|
|
78
|
-
for (const segment of branch.split("/")) {
|
|
79
|
-
if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
|
|
80
|
-
if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
|
|
81
|
-
if (/\d{1,2}-\d{1,2}-\d{4}/.test(segment)) continue; // complete day-first date anywhere
|
|
82
|
-
const m = /(?:^|-)(\d+)(?:-|$)/.exec(segment);
|
|
83
|
-
if (m) return m[1];
|
|
84
|
-
}
|
|
85
|
-
return "";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function buildBody(
|
|
89
|
-
body: string,
|
|
90
|
-
branch: string,
|
|
91
|
-
linkIssues: boolean,
|
|
92
|
-
baseUrl: string,
|
|
93
|
-
ytIssue: string,
|
|
94
|
-
ghLinkOnPr: boolean,
|
|
95
|
-
ghIssue: string,
|
|
96
|
-
ghRelation: string,
|
|
97
|
-
ghRepo: string | null,
|
|
98
|
-
): string {
|
|
99
|
-
let line: string | null = null;
|
|
100
|
-
if (linkIssues) {
|
|
101
|
-
let issue = ytIssue;
|
|
102
|
-
if (!issue && branch) {
|
|
103
|
-
// anchored prefix + \b boundary, 3+ digits so version-like tokens (POSTGRES-16, HTTP-3) never link
|
|
104
|
-
const m = /(?:^|\/|-)([A-Z]{2,}-\d{3,})\b/.exec(branch);
|
|
105
|
-
if (m) issue = m[1];
|
|
106
|
-
}
|
|
107
|
-
if (issue && baseUrl) line = `Related to: ${baseUrl.replace(/\/+$/, "")}/issue/${issue}`;
|
|
108
|
-
} else if (ghLinkOnPr) {
|
|
109
|
-
let issue = parseGhIssue(ghIssue);
|
|
110
|
-
if (!issue && branch) {
|
|
111
|
-
// pure-number issue id (feature/42-title -> 42); digits must be followed by a dash or end-of-string
|
|
112
|
-
// so version tokens (release/1.2.3, backport/8.0.1, lodash-4.17.21, 2024.1) never link
|
|
113
|
-
issue = deriveGhIssueFromBranch(branch);
|
|
114
|
-
}
|
|
115
|
-
if (issue) {
|
|
116
|
-
if (ghRelation === "related") {
|
|
117
|
-
line = `Related to #${issue}`;
|
|
118
|
-
if (ghRepo) line += ` — https://github.com/${ghRepo}/issues/${issue}`;
|
|
119
|
-
} else {
|
|
120
|
-
line = `Closes #${issue}`;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (line === null) return body;
|
|
125
|
-
return body ? `${body}\n\n${line}` : line;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
58
|
const truthy = (v: string | undefined): boolean =>
|
|
129
59
|
["1", "true", "yes"].includes(String(v ?? "").toLowerCase());
|
|
130
60
|
|
|
@@ -714,4 +644,5 @@ export function mergePr(
|
|
|
714
644
|
};
|
|
715
645
|
}
|
|
716
646
|
|
|
647
|
+
// Moved to forge/pr-body.ts (S11); re-exported for the managed action path until S16.
|
|
717
648
|
export { buildBody, parseGhRepo, parseGhIssue };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Bounded gh/glab invocation (design §0 #8: `gh api` / `glab api` only, never
|
|
2
|
+
// porcelain whose flags vary by CLI version). Every call has a timeout, runs
|
|
3
|
+
// with prompts and update checks disabled, and targets the API host derived
|
|
4
|
+
// from the push remote (GH_HOST / GITLAB_HOST). Failures map to the envelope
|
|
5
|
+
// codes; stderr is redacted before it reaches a message.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import type { ForgeKind } from "../git/rev";
|
|
10
|
+
import { redactText } from "./redact";
|
|
11
|
+
import { failure, type ForgeResult } from "./types";
|
|
12
|
+
|
|
13
|
+
export type CliBin = "gh" | "glab";
|
|
14
|
+
|
|
15
|
+
export type CliRun = {
|
|
16
|
+
status: number | null;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
timedOut: boolean;
|
|
20
|
+
/** The binary is not on PATH. */
|
|
21
|
+
missing: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Runs one gh/glab command. Injected in tests to replay recorded API fixtures. */
|
|
25
|
+
export type ForgeRunner = (
|
|
26
|
+
bin: CliBin,
|
|
27
|
+
args: readonly string[],
|
|
28
|
+
options: {
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
/** Credential for this call (GH_TOKEN / GITLAB_TOKEN); never logged. */
|
|
31
|
+
token?: string;
|
|
32
|
+
},
|
|
33
|
+
) => CliRun;
|
|
34
|
+
|
|
35
|
+
/** Default per-call bounds (ms). */
|
|
36
|
+
export const FORGE_TIMEOUTS = { api: 20_000, log: 30_000 } as const;
|
|
37
|
+
|
|
38
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
/** Locate `bin` on `env.PATH` without running a shell. */
|
|
41
|
+
export function findExecutable(bin: string, env: NodeJS.ProcessEnv): string | null {
|
|
42
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
43
|
+
// Only real executables: spawning a .cmd/.bat shim would need a shell.
|
|
44
|
+
const exts = process.platform === "win32" ? [".exe"] : [""];
|
|
45
|
+
for (const dir of dirs)
|
|
46
|
+
for (const ext of exts) {
|
|
47
|
+
const candidate = path.join(dir, `${bin}${ext}`);
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.statSync(candidate);
|
|
50
|
+
if (stat.isFile()) return candidate;
|
|
51
|
+
} catch {
|
|
52
|
+
// not here
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The real gh/glab, bound to one API host. */
|
|
59
|
+
export function systemRunner(
|
|
60
|
+
kind: ForgeKind,
|
|
61
|
+
apiHost: string,
|
|
62
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
63
|
+
): ForgeRunner {
|
|
64
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
65
|
+
...env,
|
|
66
|
+
...(kind === "github" ? { GH_HOST: apiHost } : { GITLAB_HOST: apiHost }),
|
|
67
|
+
GH_PROMPT_DISABLED: "1",
|
|
68
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
69
|
+
GLAB_CHECK_UPDATE: "false",
|
|
70
|
+
NO_PROMPT: "1",
|
|
71
|
+
NO_COLOR: "1",
|
|
72
|
+
GH_PAGER: "",
|
|
73
|
+
GLAB_PAGER: "",
|
|
74
|
+
PAGER: "",
|
|
75
|
+
};
|
|
76
|
+
return (bin, args, options) => {
|
|
77
|
+
const exe = findExecutable(bin, env);
|
|
78
|
+
if (!exe) return { status: null, stdout: "", stderr: "", timedOut: false, missing: true };
|
|
79
|
+
const token = options.token
|
|
80
|
+
? kind === "github"
|
|
81
|
+
? { GH_TOKEN: options.token, GH_ENTERPRISE_TOKEN: options.token }
|
|
82
|
+
: { GITLAB_TOKEN: options.token }
|
|
83
|
+
: {};
|
|
84
|
+
const result = spawnSync(exe, [...args], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
env: { ...childEnv, ...token },
|
|
87
|
+
maxBuffer: MAX_BUFFER,
|
|
88
|
+
timeout: options.timeoutMs,
|
|
89
|
+
killSignal: "SIGKILL",
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
+
});
|
|
93
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
94
|
+
return {
|
|
95
|
+
status: result.status,
|
|
96
|
+
stdout: result.stdout ?? "",
|
|
97
|
+
stderr: result.stderr ?? "",
|
|
98
|
+
timedOut: error?.code === "ETIMEDOUT",
|
|
99
|
+
missing: error?.code === "ENOENT",
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const installHint = (bin: CliBin): string =>
|
|
105
|
+
bin === "gh"
|
|
106
|
+
? "install the GitHub CLI (https://cli.github.com) and run: gh auth login"
|
|
107
|
+
: "install glab (https://gitlab.com/gitlab-org/cli) and run: glab auth login";
|
|
108
|
+
|
|
109
|
+
export const loginHint = (bin: CliBin, apiHost: string): string =>
|
|
110
|
+
`${bin} auth login --hostname ${apiHost}`;
|
|
111
|
+
|
|
112
|
+
/** Map a failed CLI run to an envelope error. */
|
|
113
|
+
export function cliFailure<T>(
|
|
114
|
+
bin: CliBin,
|
|
115
|
+
apiHost: string,
|
|
116
|
+
run: CliRun,
|
|
117
|
+
what: string,
|
|
118
|
+
timeoutMs: number,
|
|
119
|
+
): ForgeResult<T> {
|
|
120
|
+
if (run.missing) return failure("unavailable", `${bin} is not installed`, installHint(bin));
|
|
121
|
+
if (run.timedOut)
|
|
122
|
+
return failure("unavailable", `${bin} api ${what} timed out after ${timeoutMs} ms`);
|
|
123
|
+
const stderr = redactText(run.stderr).trim();
|
|
124
|
+
if (
|
|
125
|
+
/auth login|not logged in|no token|authentication required|bad credentials|\b401\b|unauthorized/iu.test(
|
|
126
|
+
stderr,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return failure(
|
|
130
|
+
"unavailable",
|
|
131
|
+
`${bin} is not authenticated for ${apiHost}`,
|
|
132
|
+
loginHint(bin, apiHost),
|
|
133
|
+
);
|
|
134
|
+
if (/\b404\b|not found/iu.test(stderr)) return failure("not_found", `${what} was not found`);
|
|
135
|
+
const first = stderr.split(/\r?\n/u).find((value) => value.trim()) ?? `exit ${run.status}`;
|
|
136
|
+
return failure("failed", `${bin} api ${what} failed: ${first.slice(0, 200)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Run and parse a JSON response. */
|
|
140
|
+
export function apiJson<T>(
|
|
141
|
+
runner: ForgeRunner,
|
|
142
|
+
bin: CliBin,
|
|
143
|
+
apiHost: string,
|
|
144
|
+
args: readonly string[],
|
|
145
|
+
what: string,
|
|
146
|
+
timeoutMs: number = FORGE_TIMEOUTS.api,
|
|
147
|
+
options: { graphql?: boolean } = {},
|
|
148
|
+
): ForgeResult<T> {
|
|
149
|
+
const run = runner(bin, args, { timeoutMs });
|
|
150
|
+
if (run.status !== 0) {
|
|
151
|
+
// `gh api graphql` exits 1 on GraphQL errors but still prints the
|
|
152
|
+
// response; the caller reads its `errors` array.
|
|
153
|
+
if (options.graphql && !run.missing && !run.timedOut)
|
|
154
|
+
try {
|
|
155
|
+
const body = JSON.parse(run.stdout) as { errors?: unknown };
|
|
156
|
+
if (Array.isArray(body.errors)) return { ok: true, data: body as T };
|
|
157
|
+
} catch {
|
|
158
|
+
// fall through
|
|
159
|
+
}
|
|
160
|
+
return cliFailure(bin, apiHost, run, what, timeoutMs);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return { ok: true, data: JSON.parse(run.stdout) as T };
|
|
164
|
+
} catch {
|
|
165
|
+
return failure("failed", `${bin} api ${what} returned invalid JSON`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Run and return raw text (job logs). */
|
|
170
|
+
export function apiText(
|
|
171
|
+
runner: ForgeRunner,
|
|
172
|
+
bin: CliBin,
|
|
173
|
+
apiHost: string,
|
|
174
|
+
args: readonly string[],
|
|
175
|
+
what: string,
|
|
176
|
+
timeoutMs: number = FORGE_TIMEOUTS.log,
|
|
177
|
+
): ForgeResult<string> {
|
|
178
|
+
const run = runner(bin, args, { timeoutMs });
|
|
179
|
+
if (run.status !== 0) return cliFailure(bin, apiHost, run, what, timeoutMs);
|
|
180
|
+
return { ok: true, data: run.stdout };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A forge write (create, merge, retarget). Like apiJson, plus: an HTTP 409 or
|
|
185
|
+
* a head-moved/SHA-mismatch message is `blocked` (`head_moved`), and 405/406/
|
|
186
|
+
* 422 refusals are `blocked` with the forge's own reason, so a refused
|
|
187
|
+
* mutation never reads as a transient failure.
|
|
188
|
+
*/
|
|
189
|
+
export function apiWrite<T>(
|
|
190
|
+
runner: ForgeRunner,
|
|
191
|
+
bin: CliBin,
|
|
192
|
+
apiHost: string,
|
|
193
|
+
args: readonly string[],
|
|
194
|
+
what: string,
|
|
195
|
+
timeoutMs: number = FORGE_TIMEOUTS.api,
|
|
196
|
+
): ForgeResult<T> {
|
|
197
|
+
const run = runner(bin, args, { timeoutMs });
|
|
198
|
+
if (run.status !== 0) {
|
|
199
|
+
const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
|
|
200
|
+
const reason = (text.split(/\r?\n/u).find((value) => value.trim()) ?? "").slice(0, 200);
|
|
201
|
+
if (
|
|
202
|
+
!run.missing &&
|
|
203
|
+
!run.timedOut &&
|
|
204
|
+
/\b409\b|head branch was modified|sha does not match|sha.*mismatch/iu.test(text)
|
|
205
|
+
)
|
|
206
|
+
return failure(
|
|
207
|
+
"blocked",
|
|
208
|
+
`head_moved: ${what} refused because the head changed (${reason})`,
|
|
209
|
+
"workit pr status # re-check the new head, then verify it again",
|
|
210
|
+
);
|
|
211
|
+
if (!run.missing && !run.timedOut && /\b(405|406|422)\b/u.test(text))
|
|
212
|
+
return failure("blocked", `${what} refused by the forge: ${reason}`);
|
|
213
|
+
return cliFailure(bin, apiHost, run, what, timeoutMs);
|
|
214
|
+
}
|
|
215
|
+
if (!run.stdout.trim()) return { ok: true, data: {} as T };
|
|
216
|
+
try {
|
|
217
|
+
return { ok: true, data: JSON.parse(run.stdout) as T };
|
|
218
|
+
} catch {
|
|
219
|
+
return failure("failed", `${bin} api ${what} returned invalid JSON`);
|
|
220
|
+
}
|
|
221
|
+
}
|