@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -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 = run(root, [
2132
- "push",
2133
- `--force-with-lease=refs/heads/${branch}:${approvedTip}`,
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
  })
@@ -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
+ }
@@ -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
  }
@@ -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
+ }