@brainervirus/workit-core 2.4.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 +40 -0
- package/src/forge/github.ts +84 -1
- package/src/forge/gitlab.ts +102 -1
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-ops.ts +450 -0
- package/src/forge/report.ts +1 -1
- package/src/forge/types.ts +30 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/ops.ts +626 -0
- package/src/git/rev.ts +24 -7
- 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 };
|
package/src/forge/exec.ts
CHANGED
|
@@ -179,3 +179,43 @@ export function apiText(
|
|
|
179
179
|
if (run.status !== 0) return cliFailure(bin, apiHost, run, what, timeoutMs);
|
|
180
180
|
return { ok: true, data: run.stdout };
|
|
181
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
|
+
}
|
package/src/forge/github.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GitHub adapter: `gh api graphql` for PR state, checks and review threads,
|
|
2
2
|
// `gh api` REST for required checks, job logs and reruns (design §2.0).
|
|
3
|
-
import { apiJson, apiText, cliFailure, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
|
|
3
|
+
import { apiJson, apiText, apiWrite, cliFailure, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
|
|
4
4
|
import { logTail, redactText, shortBody } from "./redact";
|
|
5
5
|
import {
|
|
6
6
|
failure,
|
|
@@ -442,5 +442,88 @@ export function createGitHubForge(options: {
|
|
|
442
442
|
);
|
|
443
443
|
return run.ok ? success(undefined) : run;
|
|
444
444
|
},
|
|
445
|
+
|
|
446
|
+
createPr(input) {
|
|
447
|
+
// Cross-repository PRs name the head as owner:branch.
|
|
448
|
+
const headOwner = input.headRepo.split("/")[0];
|
|
449
|
+
const head = input.headRepo === repo ? input.head : `${headOwner}:${input.head}`;
|
|
450
|
+
const created = apiWrite<{
|
|
451
|
+
number?: number;
|
|
452
|
+
html_url?: string;
|
|
453
|
+
state?: string;
|
|
454
|
+
merged?: boolean;
|
|
455
|
+
head?: { ref?: string; sha?: string };
|
|
456
|
+
}>(
|
|
457
|
+
runner,
|
|
458
|
+
"gh",
|
|
459
|
+
apiHost,
|
|
460
|
+
[
|
|
461
|
+
"api",
|
|
462
|
+
"-X",
|
|
463
|
+
"POST",
|
|
464
|
+
`repos/${repo}/pulls`,
|
|
465
|
+
"-f",
|
|
466
|
+
`title=${input.title}`,
|
|
467
|
+
"-f",
|
|
468
|
+
`head=${head}`,
|
|
469
|
+
"-f",
|
|
470
|
+
`base=${input.base}`,
|
|
471
|
+
"-f",
|
|
472
|
+
`body=${input.body}`,
|
|
473
|
+
"-F",
|
|
474
|
+
`draft=${input.draft ? "true" : "false"}`,
|
|
475
|
+
],
|
|
476
|
+
`pull request for ${input.head}`,
|
|
477
|
+
);
|
|
478
|
+
if (!created.ok) return created;
|
|
479
|
+
const value = created.data;
|
|
480
|
+
if (typeof value.number !== "number" || typeof value.html_url !== "string")
|
|
481
|
+
return failure("failed", "gh api pulls returned no pull request number");
|
|
482
|
+
return success({
|
|
483
|
+
number: value.number,
|
|
484
|
+
url: value.html_url,
|
|
485
|
+
state: value.merged ? "merged" : value.state === "closed" ? "closed" : "open",
|
|
486
|
+
headBranch: value.head?.ref ?? input.head,
|
|
487
|
+
headSha: value.head?.sha ?? null,
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
merge(number, mergeOptions) {
|
|
492
|
+
// sha= is GitHub's atomic head guard: 409 when the head moved.
|
|
493
|
+
const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
|
|
494
|
+
runner,
|
|
495
|
+
"gh",
|
|
496
|
+
apiHost,
|
|
497
|
+
[
|
|
498
|
+
"api",
|
|
499
|
+
"-X",
|
|
500
|
+
"PUT",
|
|
501
|
+
`repos/${repo}/pulls/${number}/merge`,
|
|
502
|
+
"-f",
|
|
503
|
+
`sha=${mergeOptions.sha}`,
|
|
504
|
+
"-f",
|
|
505
|
+
`merge_method=${mergeOptions.method}`,
|
|
506
|
+
],
|
|
507
|
+
`merge of pull request #${number}`,
|
|
508
|
+
);
|
|
509
|
+
if (!merged.ok) return merged;
|
|
510
|
+
if (merged.data.merged === false)
|
|
511
|
+
return failure(
|
|
512
|
+
"blocked",
|
|
513
|
+
`merge of pull request #${number} refused: ${redactText(merged.data.message ?? "not merged").slice(0, 200)}`,
|
|
514
|
+
);
|
|
515
|
+
return success({ mergeSha: typeof merged.data.sha === "string" ? merged.data.sha : null });
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
updateBase(number, base) {
|
|
519
|
+
const updated = apiWrite<unknown>(
|
|
520
|
+
runner,
|
|
521
|
+
"gh",
|
|
522
|
+
apiHost,
|
|
523
|
+
["api", "-X", "PATCH", `repos/${repo}/pulls/${number}`, "-f", `base=${base}`],
|
|
524
|
+
`retarget of pull request #${number}`,
|
|
525
|
+
);
|
|
526
|
+
return updated.ok ? success(undefined) : updated;
|
|
527
|
+
},
|
|
445
528
|
};
|
|
446
529
|
}
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GitLab adapter: `glab api` REST only (design §2.0 GitLab endpoints): the MR
|
|
2
2
|
// (`detailed_merge_status`, `has_conflicts`, `head_pipeline`), pipeline jobs,
|
|
3
3
|
// job traces, discussions, and pipeline/job retry.
|
|
4
|
-
import { apiJson, apiText, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
|
|
4
|
+
import { apiJson, apiText, apiWrite, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
|
|
5
5
|
import { logTail, redactText, shortBody } from "./redact";
|
|
6
6
|
import {
|
|
7
7
|
failure,
|
|
@@ -451,5 +451,106 @@ export function createGitLabForge(options: {
|
|
|
451
451
|
);
|
|
452
452
|
return run.ok ? success(undefined) : run;
|
|
453
453
|
},
|
|
454
|
+
|
|
455
|
+
createPr(input) {
|
|
456
|
+
// An MR from a fork is created in the source project and targets ours.
|
|
457
|
+
const fromFork = input.headRepo !== repo;
|
|
458
|
+
let source = project;
|
|
459
|
+
const extra: string[] = [];
|
|
460
|
+
if (fromFork) {
|
|
461
|
+
const target = apiJson<{ id?: number }>(
|
|
462
|
+
runner,
|
|
463
|
+
"glab",
|
|
464
|
+
apiHost,
|
|
465
|
+
api(project),
|
|
466
|
+
`project ${repo}`,
|
|
467
|
+
);
|
|
468
|
+
if (!target.ok) return target;
|
|
469
|
+
if (input.headProjectId === null || typeof target.data.id !== "number")
|
|
470
|
+
return failure(
|
|
471
|
+
"failed",
|
|
472
|
+
`cannot resolve the project ids for an MR from ${input.headRepo}`,
|
|
473
|
+
);
|
|
474
|
+
source = `projects/${input.headProjectId}`;
|
|
475
|
+
extra.push("-F", `target_project_id=${target.data.id}`);
|
|
476
|
+
}
|
|
477
|
+
const title =
|
|
478
|
+
input.draft && !/^draft:/iu.test(input.title) ? `Draft: ${input.title}` : input.title;
|
|
479
|
+
const created = apiWrite<GlMr>(
|
|
480
|
+
runner,
|
|
481
|
+
"glab",
|
|
482
|
+
apiHost,
|
|
483
|
+
[
|
|
484
|
+
"api",
|
|
485
|
+
"-X",
|
|
486
|
+
"POST",
|
|
487
|
+
`${source}/merge_requests`,
|
|
488
|
+
"-f",
|
|
489
|
+
`source_branch=${input.head}`,
|
|
490
|
+
"-f",
|
|
491
|
+
`target_branch=${input.base}`,
|
|
492
|
+
"-f",
|
|
493
|
+
`title=${title}`,
|
|
494
|
+
"-f",
|
|
495
|
+
`description=${input.body}`,
|
|
496
|
+
...extra,
|
|
497
|
+
],
|
|
498
|
+
`merge request for ${input.head}`,
|
|
499
|
+
);
|
|
500
|
+
if (!created.ok) return created;
|
|
501
|
+
const value = created.data;
|
|
502
|
+
if (typeof value.iid !== "number" || typeof value.web_url !== "string")
|
|
503
|
+
return failure("failed", "glab api merge_requests returned no merge request iid");
|
|
504
|
+
return success({
|
|
505
|
+
number: value.iid,
|
|
506
|
+
url: value.web_url,
|
|
507
|
+
state: mrState(value.state),
|
|
508
|
+
headBranch: value.source_branch ?? input.head,
|
|
509
|
+
headSha: value.sha ?? null,
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
merge(iid, mergeOptions) {
|
|
514
|
+
// GitLab's merge method is a project setting; squash is per request.
|
|
515
|
+
if (mergeOptions.method === "rebase")
|
|
516
|
+
return failure(
|
|
517
|
+
"invalid_input",
|
|
518
|
+
"GitLab merges with the project's merge method; pass --method squash or merge",
|
|
519
|
+
);
|
|
520
|
+
const merged = apiWrite<
|
|
521
|
+
GlMr & { merge_commit_sha?: string | null; squash_commit_sha?: string | null }
|
|
522
|
+
>(
|
|
523
|
+
runner,
|
|
524
|
+
"glab",
|
|
525
|
+
apiHost,
|
|
526
|
+
[
|
|
527
|
+
"api",
|
|
528
|
+
"-X",
|
|
529
|
+
"PUT",
|
|
530
|
+
`${project}/merge_requests/${iid}/merge`,
|
|
531
|
+
"-f",
|
|
532
|
+
`sha=${mergeOptions.sha}`,
|
|
533
|
+
"-F",
|
|
534
|
+
`squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
|
|
535
|
+
],
|
|
536
|
+
`merge of merge request !${iid}`,
|
|
537
|
+
);
|
|
538
|
+
if (!merged.ok) return merged;
|
|
539
|
+
const value = merged.data;
|
|
540
|
+
return success({
|
|
541
|
+
mergeSha: value.merge_commit_sha ?? value.squash_commit_sha ?? null,
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
updateBase(iid, base) {
|
|
546
|
+
const updated = apiWrite<unknown>(
|
|
547
|
+
runner,
|
|
548
|
+
"glab",
|
|
549
|
+
apiHost,
|
|
550
|
+
["api", "-X", "PUT", `${project}/merge_requests/${iid}`, "-f", `target_branch=${base}`],
|
|
551
|
+
`retarget of merge request !${iid}`,
|
|
552
|
+
);
|
|
553
|
+
return updated.ok ? success(undefined) : updated;
|
|
554
|
+
},
|
|
454
555
|
};
|
|
455
556
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// PR/MR body issue linking (port of scripts/pr-create.sh --build-body), shared
|
|
2
|
+
// by the managed `hosting.pull_request` action (core/pr-create.ts re-exports
|
|
3
|
+
// these until S16) and `workit pr create` (S11), which passes typed input
|
|
4
|
+
// instead of environment variables.
|
|
5
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
6
|
+
|
|
7
|
+
export function parseGhRepo(remote: string): string | null {
|
|
8
|
+
remote = (remote || "").trim().replace(/\/+$/, "");
|
|
9
|
+
if (!remote) return null;
|
|
10
|
+
if (remote.endsWith(".git")) remote = remote.slice(0, -4);
|
|
11
|
+
if (remote.includes(":")) remote = remote.split(":").pop() ?? ""; // drop git@host part (scp-style URL)
|
|
12
|
+
const parts = remote.split("/").filter(Boolean);
|
|
13
|
+
return parts.length >= 2 ? parts.slice(-2).join("/") : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseGhIssue(value: string): string {
|
|
17
|
+
const m = /issues\/(\d+)/.exec(value);
|
|
18
|
+
return m ? m[1] : String(value).trim().replace(/^#/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// RL-03/CA-25/AR-08: a branch-derived numeric issue id must be a bare number at
|
|
22
|
+
// a segment or dash boundary — and never part of a date segment. Year-first
|
|
23
|
+
// (feature/2024-01-15/x) and day-first (feature/15-01-2024/x) dates are both
|
|
24
|
+
// skipped — a complete date anywhere in a segment (release-2024-01-15,
|
|
25
|
+
// v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
|
|
26
|
+
// numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
|
|
27
|
+
function deriveGhIssueFromBranch(branch: string): string {
|
|
28
|
+
for (const segment of branch.split("/")) {
|
|
29
|
+
if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
|
|
30
|
+
if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
|
|
31
|
+
if (/\d{1,2}-\d{1,2}-\d{4}/.test(segment)) continue; // complete day-first date anywhere
|
|
32
|
+
const m = /(?:^|-)(\d+)(?:-|$)/.exec(segment);
|
|
33
|
+
if (m) return m[1];
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildBody(
|
|
39
|
+
body: string,
|
|
40
|
+
branch: string,
|
|
41
|
+
linkIssues: boolean,
|
|
42
|
+
baseUrl: string,
|
|
43
|
+
ytIssue: string,
|
|
44
|
+
ghLinkOnPr: boolean,
|
|
45
|
+
ghIssue: string,
|
|
46
|
+
ghRelation: string,
|
|
47
|
+
ghRepo: string | null,
|
|
48
|
+
): string {
|
|
49
|
+
let line: string | null = null;
|
|
50
|
+
if (linkIssues) {
|
|
51
|
+
let issue = ytIssue;
|
|
52
|
+
if (!issue && branch) {
|
|
53
|
+
// anchored prefix + \b boundary, 3+ digits so version-like tokens (POSTGRES-16, HTTP-3) never link
|
|
54
|
+
const m = /(?:^|\/|-)([A-Z]{2,}-\d{3,})\b/.exec(branch);
|
|
55
|
+
if (m) issue = m[1];
|
|
56
|
+
}
|
|
57
|
+
if (issue && baseUrl) line = `Related to: ${baseUrl.replace(/\/+$/, "")}/issue/${issue}`;
|
|
58
|
+
} else if (ghLinkOnPr) {
|
|
59
|
+
let issue = parseGhIssue(ghIssue);
|
|
60
|
+
if (!issue && branch) {
|
|
61
|
+
// pure-number issue id (feature/42-title -> 42); digits must be followed by a dash or end-of-string
|
|
62
|
+
// so version tokens (release/1.2.3, backport/8.0.1, lodash-4.17.21, 2024.1) never link
|
|
63
|
+
issue = deriveGhIssueFromBranch(branch);
|
|
64
|
+
}
|
|
65
|
+
if (issue) {
|
|
66
|
+
if (ghRelation === "related") {
|
|
67
|
+
line = `Related to #${issue}`;
|
|
68
|
+
if (ghRepo) line += ` — https://github.com/${ghRepo}/issues/${issue}`;
|
|
69
|
+
} else {
|
|
70
|
+
line = `Closes #${issue}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (line === null) return body;
|
|
75
|
+
return body ? `${body}\n\n${line}` : line;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The body `workit pr create` sends: the agent's text plus the workspace's
|
|
80
|
+
* issue link (YouTrack `link_issues`, or GitHub `issues.link_on_pr` with the
|
|
81
|
+
* issue number derived from the branch). Reads config, never writes it.
|
|
82
|
+
*/
|
|
83
|
+
export function prBodyFor(
|
|
84
|
+
cwd: string,
|
|
85
|
+
input: { body: string; branch: string; repo: string | null },
|
|
86
|
+
): string {
|
|
87
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
88
|
+
if (resolved.ok === false) return input.body;
|
|
89
|
+
return buildBody(
|
|
90
|
+
input.body,
|
|
91
|
+
input.branch,
|
|
92
|
+
resolved.link_issues === true,
|
|
93
|
+
typeof resolved.youtrack_base_url === "string" ? resolved.youtrack_base_url : "",
|
|
94
|
+
"",
|
|
95
|
+
resolved.link_on_pr === true,
|
|
96
|
+
"",
|
|
97
|
+
"closes",
|
|
98
|
+
input.repo,
|
|
99
|
+
);
|
|
100
|
+
}
|