@brainervirus/workit-core 8.14.1 → 8.15.1

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": "8.14.1",
3
+ "version": "8.15.1",
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": [
@@ -33,8 +33,8 @@ that repeats because the base keeps moving, or after 3 failed fix attempts on
33
33
  the same check.
34
34
 
35
35
  1. **Open.** `workit git push`, then `workit pr create --title "<title>"
36
- --body-file <f|->` (idempotent; body: `references/pr-body.md`; edits:
37
- `workit pr edit`). A stack: `workit stack plan <bottom> ... <top>`, one
36
+ --body-file <f|->` plus `--issue <#n|ID>` per issue (`--refs`: no close; idempotent;
37
+ body: `references/pr-body.md`; edits: `workit pr edit`). A stack: `workit stack plan <bottom> ... <top>`, one
38
38
  `workit pr create --base <parent> --fill` per branch, then
39
39
  `workit stack sync`. Finish the whole stack before babysitting any PR.
40
40
  2. **Read state** with `workit pr status --json`. `REVIEW` alone is a pending
@@ -143,6 +143,93 @@ export const readVcsConfig = (): VcsConfigResult => {
143
143
  return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
144
144
  };
145
145
 
146
+ export type IssueTrackerProvider = "github" | "gitlab" | "youtrack";
147
+
148
+ /** A workspace's issue tracker, with legacy keys migrated (issueTrackerOf). */
149
+ export type IssueTracker = {
150
+ provider: IssueTrackerProvider;
151
+ /** Derive the issue from the branch name when no issue is named. */
152
+ link: boolean;
153
+ relation: "closes" | "refs";
154
+ /** Post the PR URL on each linked issue after the PR is opened. */
155
+ backlink: boolean;
156
+ /** YouTrack's base URL from the workspace (`youtrack.baseUrl`), else null. */
157
+ baseUrl: string | null;
158
+ /** The GitHub/GitLab repository that holds the issues (`issues.repo`); null: the PR's own. */
159
+ repo: string | null;
160
+ /** That repository's host (`issues.host`); null: the PR's host, or the tracker's public one. */
161
+ host: string | null;
162
+ /** Which keys set it: the `issues` block or a legacy alias. */
163
+ source: "issues" | "issues.link_on_pr" | "youtrack.link_issues" | "youtrack.baseUrl";
164
+ /** Values read leniently (D17): what was ignored and what was used instead. */
165
+ warnings: string[];
166
+ };
167
+
168
+ const TRACKERS: ReadonlySet<string> = new Set(["github", "gitlab", "youtrack"]);
169
+
170
+ const asRecord = (value: unknown): Record<string, unknown> =>
171
+ value && typeof value === "object" && !Array.isArray(value)
172
+ ? (value as Record<string, unknown>)
173
+ : {};
174
+
175
+ const flag = (value: unknown): boolean | null => (typeof value === "boolean" ? value : null);
176
+
177
+ const text = (value: unknown): string | null =>
178
+ typeof value === "string" && value.trim() ? value.trim() : null;
179
+
180
+ /**
181
+ * The tracker from a workspace's `issues` and `youtrack` blocks. Legacy
182
+ * configs keep their behavior: `youtrack.link_issues: true` reads as
183
+ * `{ provider: youtrack, link: true }` (and wins over a legacy GitHub
184
+ * `link_on_pr`, as it always did), `issues.link_on_pr` as `link`, and a bare
185
+ * `youtrack.baseUrl` as a YouTrack tracker that links only named issues. An
186
+ * unknown `relation`, `repo` or `host` is a warning, never an error (D17).
187
+ */
188
+ export function issueTrackerOf(issues: unknown, youtrack: unknown): IssueTracker | null {
189
+ const iss = asRecord(issues);
190
+ const yt = asRecord(youtrack);
191
+ const warnings: string[] = [];
192
+ const named =
193
+ typeof iss.provider === "string" && TRACKERS.has(iss.provider.toLowerCase())
194
+ ? (iss.provider.toLowerCase() as IssueTrackerProvider)
195
+ : null;
196
+ if (iss.relation !== undefined && iss.relation !== "closes" && iss.relation !== "refs")
197
+ warnings.push(
198
+ `issues.relation ${JSON.stringify(iss.relation)} is not closes or refs; read as closes`,
199
+ );
200
+ for (const key of ["repo", "host"] as const)
201
+ if (iss[key] !== undefined && text(iss[key]) === null)
202
+ warnings.push(`issues.${key} is not a non-empty string; ignored`);
203
+ const common = {
204
+ relation: iss.relation === "refs" ? ("refs" as const) : ("closes" as const),
205
+ backlink: iss.backlink === true,
206
+ baseUrl: text(yt.baseUrl),
207
+ repo: text(iss.repo)?.replace(/^\/+|\/+$/gu, "") ?? null,
208
+ host: text(iss.host)?.toLowerCase() ?? null,
209
+ warnings,
210
+ };
211
+ const legacyGithub = named === "github" && flag(iss.link) === null;
212
+ if (flag(yt.link_issues) === true && (named === null || legacyGithub))
213
+ return { provider: "youtrack", link: true, ...common, source: "youtrack.link_issues" };
214
+ if (named) {
215
+ const link =
216
+ flag(iss.link) ??
217
+ flag(iss.link_on_pr) ??
218
+ (named === "youtrack" ? flag(yt.link_issues) : null) ??
219
+ false;
220
+ return {
221
+ provider: named,
222
+ link,
223
+ ...common,
224
+ source:
225
+ flag(iss.link) === null && flag(iss.link_on_pr) !== null ? "issues.link_on_pr" : "issues",
226
+ };
227
+ }
228
+ if (common.baseUrl)
229
+ return { provider: "youtrack", link: false, ...common, source: "youtrack.baseUrl" };
230
+ return null;
231
+ }
232
+
146
233
  /** The release track a vcsConfig call resolved (null: the workspace has no tracks). */
147
234
  export type ResolvedReleaseTrack = {
148
235
  name: string | null;
@@ -220,6 +307,7 @@ export function vcsConfig(
220
307
  // CA-05/CA-02 (workspaces.ts workspaceDefaultTarget): shared with doctor
221
308
  // and grant show so their display never disagrees with the commands.
222
309
  const workspaceDefault = workspaceDefaultTarget(root, cfg.defaultTargetBranch);
310
+ const issueTracker = issueTrackerOf(wsIssues, wsYt);
223
311
  const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
224
312
  const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
225
313
  // github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
@@ -267,6 +355,7 @@ export function vcsConfig(
267
355
  youtrack_base_url: youtrackBaseUrl,
268
356
  issues_provider: issuesProvider,
269
357
  link_on_pr: linkOnPr,
358
+ issue_tracker: issueTracker,
270
359
  };
271
360
  }
272
361
 
@@ -297,6 +386,7 @@ export function vcsConfig(
297
386
  youtrack_base_url: youtrackBaseUrl,
298
387
  issues_provider: issuesProvider,
299
388
  link_on_pr: linkOnPr,
389
+ issue_tracker: issueTracker,
300
390
  };
301
391
  if (provider === "gitlab") {
302
392
  out.gitlab = {
@@ -51,3 +51,12 @@ export const workerBlocksTransition = (
51
51
  transition: "pause" | "resume" | "revise" | "close",
52
52
  state: WorkerState,
53
53
  ): boolean => (transition === "pause" ? false : isUncertainWorker(state));
54
+
55
+ /**
56
+ * A worker may be continued in its own session only while it holds live
57
+ * state: a running process, or uncommitted changes in its worktree. A worker
58
+ * that reported and is clean is continued by a fresh worker on the same
59
+ * slice. An unreadable worktree counts as no live state.
60
+ */
61
+ export const holdsLiveState = (state: WorkerState, dirty: boolean | null): boolean =>
62
+ state === "running" || dirty === true;
@@ -175,8 +175,21 @@ const workspaceConfigSchema = z
175
175
  .object({ baseUrl: nonBlank.optional(), link_issues: z.boolean().optional() })
176
176
  .passthrough()
177
177
  .optional(),
178
+ /**
179
+ * The issue tracker PRs link to (any forge with any tracker). `link`
180
+ * derives the issue from the branch; `youtrack.link_issues` and
181
+ * `link_on_pr` are read as aliases (vcs-config.ts issueTrackerOf).
182
+ * `relation`, `repo` and `host` are read leniently there (D17): an
183
+ * unknown value warns and falls back, never invalidating the file.
184
+ */
178
185
  issues: z
179
- .object({ provider: z.literal("github").optional(), link_on_pr: z.boolean().optional() })
186
+ .object({
187
+ provider: z.enum(["github", "gitlab", "youtrack"]).optional(),
188
+ link: z.boolean().optional(),
189
+ relation: z.unknown().optional(),
190
+ backlink: z.boolean().optional(),
191
+ link_on_pr: z.boolean().optional(),
192
+ })
180
193
  .passthrough()
181
194
  .optional(),
182
195
  branchPolicy: workspaceBranchPolicySchema.optional(),
@@ -187,13 +200,6 @@ const workspaceConfigSchema = z
187
200
  })
188
201
  .passthrough()
189
202
  .superRefine((workspace, context) => {
190
- if (workspace.issues && workspace.vcs?.provider !== "github") {
191
- context.addIssue({
192
- code: "custom",
193
- path: ["issues"],
194
- message: `GitHub issue linking requires the github provider, got ${workspace.vcs?.provider ?? "unset"}`,
195
- });
196
- }
197
203
  if (
198
204
  workspace.defaultProfile !== undefined &&
199
205
  !Object.hasOwn(workspace.profiles ?? {}, workspace.defaultProfile)
@@ -224,6 +224,14 @@ export function youTrackWorkDateMs(dateRaw: string):
224
224
  }
225
225
  }
226
226
 
227
+ /** youtrack.json's baseUrl without trailing slashes, read-only (no token needed); null when unset. */
228
+ export const youTrackBaseUrl = (): string | null => {
229
+ const read = readYouTrackContextConfig();
230
+ if ("error" in read) return null;
231
+ const base = typeof read.data.baseUrl === "string" ? read.data.baseUrl.replace(/\/+$/, "") : "";
232
+ return base || null;
233
+ };
234
+
227
235
  export const youTrackToken = (): { token: string; base: string } | { error: string } => {
228
236
  const loaded = readYouTrackConfig(true);
229
237
  if ("error" in loaded) return loaded;
@@ -39,6 +39,8 @@ export type BriefOutcome = {
39
39
  branch: string;
40
40
  base: string;
41
41
  tier: string;
42
+ /** Issues the slice's PR links (the brief's ISSUE line). */
43
+ issues: string[];
42
44
  /** The model `fanout.tierModels` names for the tier on the lead's host, else null. */
43
45
  model: string | null;
44
46
  /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
@@ -195,6 +197,11 @@ export function renderBrief(
195
197
  ...(slice.owns.length ? [` owns: ${slice.owns.join(", ")}`] : []),
196
198
  ` branch: ${branch} base: ${slice.base}`,
197
199
  `CONTEXT: ${slice.context ?? "none given: read the files in SCOPE and the neighbours they import"}`,
200
+ ...(slice.issues.length
201
+ ? [
202
+ `ISSUE: ${slice.issues.join(", ")} (\`workit pr create\` on ${branch} links ${slice.issues.length === 1 ? "it" : "them"} by itself; no --issue needed)`,
203
+ ]
204
+ : []),
198
205
  ...(mode === "resume" && head
199
206
  ? [
200
207
  ` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${slice.base}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`,
@@ -229,6 +236,7 @@ export function renderBrief(
229
236
  branch,
230
237
  base: slice.base,
231
238
  tier: slice.tier,
239
+ issues: slice.issues,
232
240
  model: model?.model ?? null,
233
241
  scratch,
234
242
  session,
package/src/fanout.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  //
21
21
  // Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
22
22
  // scope, owns, branch, base, dependencies), not how it is briefed (tier,
23
- // timebox, context) or where its worktree goes. A re-plan that changes a
23
+ // timebox, context, issues) or where its worktree goes. A re-plan that changes a
24
24
  // slice starts a new run for that slice alone: its `hashSince` moves to now,
25
25
  // and only ledger rows recorded under its hash (worktree rows carry
26
26
  // `sliceHash`) or since then link a gone branch's merged PR to it or count as
@@ -69,6 +69,8 @@ export type Slice = {
69
69
  forbidden: string[];
70
70
  context: string | null;
71
71
  timebox: string | null;
72
+ /** Issues the slice's PR links (`issue` in the plan: `#42`, `IRPT-74` or a URL). */
73
+ issues: string[];
72
74
  /** sliceHash of this definition: which run of the slice a ledger row belongs to. */
73
75
  hash: string;
74
76
  /** When a slice with this hash was first planned: older rows are not its run. */
@@ -405,6 +407,8 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
405
407
  problems.push(`${where}: tier must be one of ${TIERS.join(", ")}`);
406
408
  const dependsOn = textList(item.dependsOn ?? []);
407
409
  if (dependsOn === null) problems.push(`${where}: dependsOn must be a list of slice ids`);
410
+ const issues = textList(item.issue ?? []);
411
+ if (issues === null) problems.push(`${where}: issue must be an issue id or a list of them`);
408
412
  const optional = (field: string): string | null =>
409
413
  typeof item[field] === "string" && filled(item[field]) ? item[field].trim() : null;
410
414
  slices.push({
@@ -425,6 +429,7 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
425
429
  forbidden,
426
430
  context: optional("context"),
427
431
  timebox: optional("timebox"),
432
+ issues: [...new Set((issues ?? []).map((entry) => entry.trim()).filter(filled))],
428
433
  hash: "",
429
434
  hashSince: "",
430
435
  });
@@ -777,7 +782,7 @@ export const planLeads = (
777
782
 
778
783
  /**
779
784
  * sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
780
- * timebox, context) and the worktree path are left out, so fixing a typo in
785
+ * timebox, context, issues) and the worktree path are left out, so fixing a typo in
781
786
  * them never re-dispatches landed work.
782
787
  */
783
788
  export function sliceHash(slice: Slice): string {
@@ -788,6 +793,7 @@ export function sliceHash(slice: Slice): string {
788
793
  tier: _tier,
789
794
  timebox: _timebox,
790
795
  context: _context,
796
+ issues: _issues,
791
797
  ...definition
792
798
  } = slice;
793
799
  return createHash("sha256").update(JSON.stringify(definition)).digest("hex").slice(0, 16);
@@ -842,6 +848,7 @@ function parseStored(raw: unknown): FanoutFile | null {
842
848
  forbidden: list(item.forbidden),
843
849
  context: typeof item.context === "string" ? item.context : null,
844
850
  timebox: typeof item.timebox === "string" ? item.timebox : null,
851
+ issues: list(item.issues),
845
852
  hash: text(item.hash),
846
853
  // A plan written before slice hashes: its run began at createdAt.
847
854
  hashSince: text(item.hashSince) || text(raw.createdAt),
@@ -942,6 +949,28 @@ export function selectFanout(
942
949
  );
943
950
  }
944
951
 
952
+ /**
953
+ * The issues the fanout slice on `branch` links (its `issue` field), so a
954
+ * worker's `workit pr create` links them unasked. A race attempt branch
955
+ * `<branch>-try<n>` counts as its slice; no plan or no slice is none.
956
+ */
957
+ export function sliceIssuesFor(cwd: string, branch: string | null): string[] {
958
+ if (!branch) return [];
959
+ const all = listFanouts(cwd);
960
+ if (!all.ok) return [];
961
+ const issues = all.data.flatMap((plan) =>
962
+ plan.slices
963
+ .filter(
964
+ (slice) =>
965
+ slice.branch === branch ||
966
+ (branch.startsWith(`${slice.branch}-try`) &&
967
+ /^\d+$/u.test(branch.slice(slice.branch.length + 4))),
968
+ )
969
+ .flatMap((slice) => slice.issues),
970
+ );
971
+ return [...new Set(issues)];
972
+ }
973
+
945
974
  function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
946
975
  const dir = fanoutsDir(cwd);
947
976
  if (!dir.ok) return dir;
@@ -719,5 +719,29 @@ export function createGitHubForge(options: {
719
719
  url: safeUrl(created.data.html_url ?? null),
720
720
  });
721
721
  },
722
+
723
+ issueComments(n) {
724
+ const listed = rest<Array<{ body?: unknown }>>(
725
+ `repos/${repo}/issues/${n}/comments?per_page=100`,
726
+ `comments on issue #${n}`,
727
+ );
728
+ if (!listed.ok) return listed;
729
+ if (!Array.isArray(listed.data))
730
+ return failure("failed", `gh api issue #${n} comments returned no list`);
731
+ return success(
732
+ listed.data.flatMap((comment) => (typeof comment.body === "string" ? [comment.body] : [])),
733
+ );
734
+ },
735
+
736
+ commentIssue(n, body) {
737
+ const posted = apiWrite<unknown>(
738
+ runner,
739
+ "gh",
740
+ apiHost,
741
+ ["api", "-X", "POST", `repos/${repo}/issues/${n}/comments`, "-f", `body=${body}`],
742
+ `comment on issue #${n}`,
743
+ );
744
+ return posted.ok ? success(undefined) : posted;
745
+ },
722
746
  };
723
747
  }
@@ -776,5 +776,32 @@ export function createGitLabForge(options: {
776
776
  url: safeUrl(created.data["_links"]?.self ?? null),
777
777
  });
778
778
  },
779
+
780
+ issueComments(iid) {
781
+ const listed = apiJson<Array<{ body?: unknown }>>(
782
+ runner,
783
+ "glab",
784
+ apiHost,
785
+ api(`${project}/issues/${iid}/notes?per_page=100`),
786
+ `notes on issue #${iid}`,
787
+ );
788
+ if (!listed.ok) return listed;
789
+ if (!Array.isArray(listed.data))
790
+ return failure("failed", `glab api issue #${iid} notes returned no list`);
791
+ return success(
792
+ listed.data.flatMap((note) => (typeof note.body === "string" ? [note.body] : [])),
793
+ );
794
+ },
795
+
796
+ commentIssue(iid, body) {
797
+ const posted = apiWrite<unknown>(
798
+ runner,
799
+ "glab",
800
+ apiHost,
801
+ ["api", "-X", "POST", `${project}/issues/${iid}/notes`, "-f", `body=${body}`],
802
+ `note on issue #${iid}`,
803
+ );
804
+ return posted.ok ? success(undefined) : posted;
805
+ },
779
806
  };
780
807
  }
@@ -0,0 +1,458 @@
1
+ // Issue links for `workit pr create` (#324): which issues a PR links, the body
2
+ // line each one gets, the `pr.linked` ledger rows, the optional tracker
3
+ // backlink, and the links `pr status` lists.
4
+ //
5
+ // Issues come from `--issue` (repeatable), else the fanout slice the branch
6
+ // belongs to (its `issue` field), else the branch name when the workspace
7
+ // tracker has `link: true`. `IRPT-74` and `.../issue/IRPT-74` are YouTrack;
8
+ // an issue URL names its own forge; `#42`, `42` and `owner/repo#42` are on
9
+ // the workspace tracker (`issues.provider` github or gitlab), else the PR's
10
+ // forge. An issue on the PR's own forge closes in its syntax (`Closes #42`,
11
+ // `Refs #42`, `Closes owner/repo#42`); one on another forge cannot close
12
+ // across forges and gets `Related to: <issue URL>`.
13
+ import { vcsConfig, type IssueTracker } from "../core/vcs-config";
14
+ import { youTrackBaseUrl } from "../core/youtrack";
15
+ import { defaultOperations, ISSUE_RE, unwrap } from "../core/youtrack-tools";
16
+ import { withMarker, writeOnce, youTrackMarker, type OnceOutcome } from "../core/youtrack-writes";
17
+ import { sliceIssuesFor } from "../fanout";
18
+ import { appendObserved, type LedgerActor, type ReadRow } from "../ledger";
19
+ import type { ForgeRunner } from "./exec";
20
+ import { createGitHubForge } from "./github";
21
+ import { createGitLabForge } from "./gitlab";
22
+ import { deriveGhIssueFromBranch, deriveYouTrackIssueFromBranch } from "./pr-body";
23
+ import type { ResolvedForge } from "./resolve";
24
+ import type { Forge } from "./types";
25
+
26
+ export type ForgeKind = "github" | "gitlab";
27
+ /** What the body line says: a closing keyword, a reference, or a cross-tracker "Related to". */
28
+ export type IssueRelation = "closes" | "refs" | "related";
29
+
30
+ /** The PR's forge: where a same-forge issue reference closes. */
31
+ export type PrForge = { kind: ForgeKind; repo: string; host: string };
32
+
33
+ export type IssueLink = {
34
+ tracker: ForgeKind | "youtrack";
35
+ /** The issue number on a forge, the issue id on YouTrack. */
36
+ id: string;
37
+ /** The issue's repository when it is not the PR's own, else null. */
38
+ repo: string | null;
39
+ /** The issue's forge host when it is not the PR's, else null. */
40
+ host: string | null;
41
+ /** The issue's web URL when it is on another host or tracker, else null. */
42
+ url: string | null;
43
+ relation: IssueRelation;
44
+ source: "flag" | "fanout" | "branch";
45
+ /** The line the PR body carries for it. */
46
+ line: string;
47
+ };
48
+
49
+ export type IssueLinkPlan = {
50
+ links: IssueLink[];
51
+ /** The workspace tracker's `backlink`. */
52
+ backlink: boolean;
53
+ tracker: IssueTracker | null;
54
+ /** Config read leniently, or a link that could not be derived. */
55
+ warnings: string[];
56
+ };
57
+
58
+ /** An issue as named: its host and repo only when the name carries them. */
59
+ type NamedRef = {
60
+ tracker: ForgeKind | "youtrack";
61
+ id: string;
62
+ repo: string | null;
63
+ host: string | null;
64
+ };
65
+
66
+ const DEFAULT_HOST: Record<ForgeKind, string> = { github: "github.com", gitlab: "gitlab.com" };
67
+
68
+ const same = (left: string | null, right: string | null): boolean =>
69
+ (left ?? "").toLowerCase() === (right ?? "").toLowerCase();
70
+
71
+ /**
72
+ * One `--issue` value: `#42`, `42`, `owner/repo#42`, `IRPT-74` or an issue
73
+ * URL. A bare or short form is an issue on `tracker`.
74
+ */
75
+ export function parseIssueRef(
76
+ raw: string,
77
+ tracker: ForgeKind,
78
+ ): { ok: true; ref: NamedRef } | { ok: false; error: string } {
79
+ const value = raw.trim();
80
+ if (ISSUE_RE.test(value))
81
+ return { ok: true, ref: { tracker: "youtrack", id: value, repo: null, host: null } };
82
+ const short = /^(?:([\w.-]+(?:\/[\w.-]+)+))?#?(\d+)$/u.exec(value);
83
+ if (short && (short[1] === undefined || value.includes("#")))
84
+ return { ok: true, ref: { tracker, id: short[2], repo: short[1] ?? null, host: null } };
85
+ let url: URL | null = null;
86
+ try {
87
+ url = /^https?:\/\//iu.test(value) ? new URL(value) : null;
88
+ } catch {
89
+ url = null;
90
+ }
91
+ if (url) {
92
+ const pathname = url.pathname.replace(/\/+$/u, "");
93
+ const youtrack = /\/issues?\/([A-Z][A-Z0-9]*-\d+)(?:\/|$)/u.exec(pathname);
94
+ if (youtrack && ISSUE_RE.test(youtrack[1]))
95
+ return { ok: true, ref: { tracker: "youtrack", id: youtrack[1], repo: null, host: null } };
96
+ const gitlab = /^\/(.+?)\/-\/(?:issues|work_items)\/(\d+)$/u.exec(pathname);
97
+ const github = /^\/([^/]+\/[^/]+)\/issues\/(\d+)$/u.exec(pathname);
98
+ const found: [ForgeKind, RegExpExecArray] | null = gitlab
99
+ ? ["gitlab", gitlab]
100
+ : github
101
+ ? ["github", github]
102
+ : null;
103
+ if (found) {
104
+ const [kind, match] = found;
105
+ return {
106
+ ok: true,
107
+ ref: { tracker: kind, id: match[2], repo: match[1], host: url.host.toLowerCase() },
108
+ };
109
+ }
110
+ }
111
+ return {
112
+ ok: false,
113
+ error: `cannot read issue "${raw}" (use #42, owner/repo#42, IRPT-74 or an issue URL)`,
114
+ };
115
+ }
116
+
117
+ const issueUrl = (kind: ForgeKind, host: string, repo: string, id: string): string =>
118
+ `https://${host}/${repo}/${kind === "gitlab" ? "-/" : ""}issues/${id}`;
119
+
120
+ /**
121
+ * Where a forge-tracker issue lives, its body line and its relation. The
122
+ * workspace's `issues.repo`/`issues.host` place a bare number when the
123
+ * tracker is not the PR's own repository.
124
+ */
125
+ function placeRef(
126
+ ref: NamedRef,
127
+ forge: PrForge,
128
+ tracker: IssueTracker | null,
129
+ relation: "closes" | "refs",
130
+ youtrackBase: string | null,
131
+ ): Omit<IssueLink, "source"> | { error: string } {
132
+ if (ref.tracker === "youtrack") {
133
+ const url = youtrackBase ? `${youtrackBase.replace(/\/+$/u, "")}/issue/${ref.id}` : null;
134
+ return {
135
+ ...ref,
136
+ url,
137
+ relation: "related",
138
+ line: `Related to: ${url ?? ref.id}`,
139
+ };
140
+ }
141
+ const configured = tracker?.provider === ref.tracker;
142
+ const home = ref.tracker === forge.kind ? forge.host : DEFAULT_HOST[ref.tracker];
143
+ const host = ref.host ?? (configured ? tracker?.host : null) ?? home;
144
+ const repo =
145
+ ref.repo ??
146
+ (configured ? tracker?.repo : null) ??
147
+ (ref.tracker === forge.kind && same(host, forge.host) ? forge.repo : null);
148
+ if (!repo)
149
+ return {
150
+ error: `#${ref.id} is a ${ref.tracker} issue, but the PR is on ${forge.kind}: set issues.repo to the ${ref.tracker} project that holds it, or pass the issue URL`,
151
+ };
152
+ if (ref.tracker === forge.kind && same(host, forge.host)) {
153
+ const own = same(repo, forge.repo);
154
+ return {
155
+ tracker: ref.tracker,
156
+ id: ref.id,
157
+ repo: own ? null : repo,
158
+ host: null,
159
+ url: null,
160
+ relation,
161
+ line: `${relation === "refs" ? "Refs" : "Closes"} ${own ? "" : repo}#${ref.id}`,
162
+ };
163
+ }
164
+ const url = issueUrl(ref.tracker, host, repo, ref.id);
165
+ return {
166
+ tracker: ref.tracker,
167
+ id: ref.id,
168
+ repo,
169
+ host,
170
+ url,
171
+ relation: "related",
172
+ line: `Related to: ${url}`,
173
+ };
174
+ }
175
+
176
+ /** The workspace tracker, or null when none is configured or the config cannot resolve. */
177
+ function trackerFor(cwd: string): IssueTracker | null {
178
+ const resolved = vcsConfig("resolve", cwd);
179
+ if (resolved.ok === false) return null;
180
+ return (resolved.issue_tracker as IssueTracker | null | undefined) ?? null;
181
+ }
182
+
183
+ /**
184
+ * The issues a PR from `branch` links and their body lines. Named issues
185
+ * (the flag, else the fanout slice) always link; the branch name is read
186
+ * only when the tracker has `link: true`, and a YouTrack id from it only
187
+ * when a base URL is known (unchanged from the branch-only behavior).
188
+ */
189
+ export function planIssueLinks(
190
+ cwd: string,
191
+ input: {
192
+ issues: readonly string[];
193
+ refs: boolean;
194
+ branch: string | null;
195
+ forge: PrForge;
196
+ },
197
+ tracker: IssueTracker | null = trackerFor(cwd),
198
+ ): { ok: true; data: IssueLinkPlan } | { ok: false; error: string } {
199
+ const relation = input.refs ? "refs" : (tracker?.relation ?? "closes");
200
+ const youtrackBase = tracker?.baseUrl ?? youTrackBaseUrl();
201
+ const forgeTracker: ForgeKind =
202
+ tracker?.provider === "github" || tracker?.provider === "gitlab"
203
+ ? tracker.provider
204
+ : input.forge.kind;
205
+ const warnings = [...(tracker?.warnings ?? [])];
206
+ const named = input.issues.length ? input.issues : sliceIssuesFor(cwd, input.branch);
207
+ const source: IssueLink["source"] = input.issues.length ? "flag" : "fanout";
208
+ const refs: Array<{ ref: NamedRef; source: IssueLink["source"] }> = [];
209
+ for (const raw of named) {
210
+ const parsed = parseIssueRef(raw, forgeTracker);
211
+ if (!parsed.ok)
212
+ return {
213
+ ok: false,
214
+ error: source === "fanout" ? `fanout slice issue: ${parsed.error}` : parsed.error,
215
+ };
216
+ refs.push({ ref: parsed.ref, source });
217
+ }
218
+ let derived = false;
219
+ if (refs.length === 0 && tracker?.link && input.branch) {
220
+ const id =
221
+ tracker.provider === "youtrack"
222
+ ? youtrackBase
223
+ ? deriveYouTrackIssueFromBranch(input.branch)
224
+ : ""
225
+ : deriveGhIssueFromBranch(input.branch);
226
+ if (id) {
227
+ refs.push({
228
+ ref: { tracker: tracker.provider, id, repo: null, host: null },
229
+ source: "branch",
230
+ });
231
+ derived = true;
232
+ }
233
+ }
234
+ const seen = new Set<string>();
235
+ const links: IssueLink[] = [];
236
+ for (const { ref, source: from } of refs) {
237
+ const placed = placeRef(ref, input.forge, tracker, relation, youtrackBase);
238
+ if ("error" in placed) {
239
+ if (derived) {
240
+ warnings.push(`the branch issue is not linked: ${placed.error}`);
241
+ continue;
242
+ }
243
+ return { ok: false, error: placed.error };
244
+ }
245
+ const key = `${placed.tracker}:${placed.host ?? ""}:${placed.repo?.toLowerCase() ?? ""}#${placed.id}`;
246
+ if (seen.has(key)) continue;
247
+ seen.add(key);
248
+ links.push({ ...placed, source: from });
249
+ }
250
+ return { ok: true, data: { links, backlink: tracker?.backlink === true, tracker, warnings } };
251
+ }
252
+
253
+ /** The body with each link's line appended once (a line already present is kept as is). */
254
+ export function withIssueLines(
255
+ body: string,
256
+ links: ReadonlyArray<Pick<IssueLink, "line">>,
257
+ ): string {
258
+ const present = new Set(body.split(/\r?\n/u).map((line) => line.trim()));
259
+ const lines = links.map((link) => link.line).filter((line) => !present.has(line));
260
+ if (lines.length === 0) return body;
261
+ return body ? `${body}\n\n${lines.join("\n")}` : lines.join("\n");
262
+ }
263
+
264
+ /** One `pr.linked` row per link, for a PR this call opened. */
265
+ export function recordIssueLinks(
266
+ cwd: string,
267
+ pr: { number: number; url: string; branch: string; base: string; head: string },
268
+ links: readonly IssueLink[],
269
+ context: { actor: LedgerActor; forge: ForgeKind; repo: string },
270
+ ): Array<{ issue: string; id: string } | { issue: string; error: string }> {
271
+ return links.map((link) => {
272
+ const row = appendObserved(cwd, {
273
+ type: "pr.linked",
274
+ actor: context.actor,
275
+ branch: pr.branch,
276
+ head: pr.head,
277
+ pr: pr.number,
278
+ base: pr.base,
279
+ url: pr.url,
280
+ forge: context.forge,
281
+ repo: context.repo,
282
+ tracker: link.tracker,
283
+ issue: link.id,
284
+ issueRepo: link.repo,
285
+ issueHost: link.host,
286
+ issueUrl: link.url,
287
+ relation: link.relation,
288
+ source: link.source,
289
+ });
290
+ return row.ok
291
+ ? { issue: label(link), id: String(row.value.id) }
292
+ : { issue: label(link), error: row.error };
293
+ });
294
+ }
295
+
296
+ /** `#42`, `owner/repo#42`, `IRPT-74`, or the URL of an issue on another forge. */
297
+ export const label = (link: Pick<IssueLink, "tracker" | "id" | "repo" | "url">): string =>
298
+ link.tracker === "youtrack" ? link.id : (link.url ?? `${link.repo ?? ""}#${link.id}`);
299
+
300
+ export type LinkedIssue = {
301
+ tracker: string;
302
+ issue: string;
303
+ repo: string | null;
304
+ url: string | null;
305
+ relation: string;
306
+ source: string;
307
+ };
308
+
309
+ /** The issues `pr.linked` rows record for PR `pr` of `repo` (the CLI's own rows only). */
310
+ export function linkedIssues(
311
+ rows: readonly ReadRow[],
312
+ target: { pr: number; repo: string },
313
+ ): LinkedIssue[] {
314
+ const out = new Map<string, LinkedIssue>();
315
+ for (const row of rows) {
316
+ if (row.type !== "pr.linked" || row.observer !== "workit_cli" || row.pr !== target.pr) continue;
317
+ if (typeof row.repo !== "string" || row.repo.toLowerCase() !== target.repo.toLowerCase())
318
+ continue;
319
+ if (typeof row.issue !== "string" || typeof row.tracker !== "string") continue;
320
+ const repo = typeof row.issueRepo === "string" ? row.issueRepo : null;
321
+ const url = typeof row.issueUrl === "string" ? row.issueUrl : null;
322
+ out.set(`${row.tracker}:${url ?? repo ?? ""}#${row.issue}`, {
323
+ tracker: row.tracker,
324
+ issue: row.issue,
325
+ repo,
326
+ url,
327
+ relation:
328
+ typeof row.relation === "string"
329
+ ? row.relation
330
+ : row.tracker === "youtrack"
331
+ ? "related"
332
+ : "closes",
333
+ source: typeof row.source === "string" ? row.source : "flag",
334
+ });
335
+ }
336
+ return [...out.values()];
337
+ }
338
+
339
+ export type BacklinkOutcome = {
340
+ issue: string;
341
+ status: OnceOutcome["status"] | "skipped";
342
+ note?: string;
343
+ };
344
+
345
+ const defaultYouTrackComment = (
346
+ root: string,
347
+ issueId: string,
348
+ text: string,
349
+ marker: string,
350
+ ): Promise<OnceOutcome> =>
351
+ writeOnce(issueId, marker, "comments", async () => {
352
+ try {
353
+ return {
354
+ ok: true,
355
+ data: unwrap(await defaultOperations.postComment(issueId, withMarker(text, marker), root)),
356
+ };
357
+ } catch (error) {
358
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
359
+ }
360
+ });
361
+
362
+ /**
363
+ * The forge an issue lives on: the PR's own, or another repository on the
364
+ * same forge host under the same verified credential. Null for another host:
365
+ * no account is verified there.
366
+ */
367
+ function issueForge(
368
+ resolved: ResolvedForge,
369
+ link: IssueLink,
370
+ runner: ForgeRunner | undefined,
371
+ ): Forge | null {
372
+ if (link.tracker === "youtrack") return null;
373
+ if (link.repo === null && link.host === null) return resolved.forge;
374
+ const apiHost = resolved.forge.apiHost;
375
+ if (link.tracker !== resolved.forge.kind || !same(link.host ?? apiHost, apiHost)) return null;
376
+ const options = {
377
+ apiHost,
378
+ repo: link.repo ?? resolved.forge.repo,
379
+ runner: runner ?? resolved.runner,
380
+ };
381
+ return link.tracker === "github" ? createGitHubForge(options) : createGitLabForge(options);
382
+ }
383
+
384
+ /**
385
+ * The tracker backlink (`issues.backlink: true`): a comment with the PR URL
386
+ * on each linked issue, posted at most once: a marker naming the PR is read
387
+ * back first. Forge issues on the PR's host go through the workspace's
388
+ * verified credential; one on another forge host is skipped (no account is
389
+ * verified there). YouTrack goes through its API behind WORKFLOW_YT_WRITE=1,
390
+ * like `workit youtrack note`. A failure is reported per issue and never
391
+ * undoes the PR.
392
+ */
393
+ export async function postBacklinks(
394
+ cwd: string,
395
+ resolved: ResolvedForge,
396
+ pr: { number: number; url: string },
397
+ links: readonly IssueLink[],
398
+ deps: { runner?: ForgeRunner; env?: NodeJS.ProcessEnv } = {},
399
+ ): Promise<BacklinkOutcome[]> {
400
+ const env = deps.env ?? process.env;
401
+ const noun = resolved.forge.kind === "github" ? `PR #${pr.number}` : `MR !${pr.number}`;
402
+ const text = `Linked from ${noun}: ${pr.url}`;
403
+ const out: BacklinkOutcome[] = [];
404
+ for (const link of links) {
405
+ const issue = label(link);
406
+ if (link.tracker === "youtrack") {
407
+ if (env.WORKFLOW_YT_WRITE !== "1") {
408
+ out.push({
409
+ issue,
410
+ status: "failed",
411
+ note: "YouTrack writes need WORKFLOW_YT_WRITE=1 (as for workit youtrack note)",
412
+ });
413
+ continue;
414
+ }
415
+ const marker = youTrackMarker({ operation: "pr.backlink", issueId: link.id, pr: pr.url });
416
+ if (!marker) {
417
+ out.push({
418
+ issue,
419
+ status: "failed",
420
+ note: "YouTrack credentials are unavailable (youtrack.json / youtrack.token)",
421
+ });
422
+ continue;
423
+ }
424
+ const outcome = await defaultYouTrackComment(cwd, link.id, text, marker);
425
+ out.push({
426
+ issue,
427
+ status: outcome.status,
428
+ ...("error" in outcome ? { note: outcome.error } : {}),
429
+ });
430
+ continue;
431
+ }
432
+ const forge = issueForge(resolved, link, deps.runner);
433
+ if (!forge) {
434
+ out.push({
435
+ issue,
436
+ status: "skipped",
437
+ note: `an issue on another forge host is not backlinked: no verified account for ${link.host ?? resolved.forge.apiHost}`,
438
+ });
439
+ continue;
440
+ }
441
+ const marker = `<!-- workit-backlink:${pr.url} -->`;
442
+ const number = Number(link.id);
443
+ const existing = forge.issueComments(number);
444
+ if (!existing.ok) {
445
+ out.push({ issue, status: "failed", note: existing.error });
446
+ continue;
447
+ }
448
+ if (existing.data.some((body) => body.includes(marker))) {
449
+ out.push({ issue, status: "already_done" });
450
+ continue;
451
+ }
452
+ const posted = forge.commentIssue(number, withMarker(text, marker));
453
+ out.push(
454
+ posted.ok ? { issue, status: "done" } : { issue, status: "failed", note: posted.error },
455
+ );
456
+ }
457
+ return out;
458
+ }
@@ -24,7 +24,7 @@ export function parseGhIssue(value: string): string {
24
24
  // skipped — a complete date anywhere in a segment (release-2024-01-15,
25
25
  // v2-2024-01-15-fix) — so no date digit ever closes an issue. Deliberate
26
26
  // numeric issue branches (feature/42-title, feature/2024-fix) keep linking.
27
- function deriveGhIssueFromBranch(branch: string): string {
27
+ export function deriveGhIssueFromBranch(branch: string): string {
28
28
  for (const segment of branch.split("/")) {
29
29
  if (/^\d{4}-\d/.test(segment)) continue; // year-first date-like segment (incl. year-month)
30
30
  if (/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) continue; // complete year-first date anywhere
@@ -35,6 +35,11 @@ function deriveGhIssueFromBranch(branch: string): string {
35
35
  return "";
36
36
  }
37
37
 
38
+ /** A YouTrack id in the branch (feature/IRPT-740-x); anchored, 3+ digits so POSTGRES-16 or HTTP-3 never link. */
39
+ export function deriveYouTrackIssueFromBranch(branch: string): string {
40
+ return /(?:^|\/|-)([A-Z]{2,}-\d{3,})\b/.exec(branch)?.[1] ?? "";
41
+ }
42
+
38
43
  export function buildBody(
39
44
  body: string,
40
45
  branch: string,
@@ -48,12 +53,7 @@ export function buildBody(
48
53
  ): string {
49
54
  let line: string | null = null;
50
55
  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
- }
56
+ const issue = ytIssue || deriveYouTrackIssueFromBranch(branch);
57
57
  if (issue && baseUrl) line = `Related to: ${baseUrl.replace(/\/+$/, "")}/issue/${issue}`;
58
58
  } else if (ghLinkOnPr) {
59
59
  let issue = parseGhIssue(ghIssue);
@@ -53,6 +53,8 @@ export type ResolvedForge = {
53
53
  baseRemote: string;
54
54
  /** Absolute deadline (ms, `now()` clock) every API call is clamped to; null = none. */
55
55
  limits: { deadline: number | null };
56
+ /** The workspace credential's runner, clamped to `limits` (another repo on the same host). */
57
+ runner: ForgeRunner;
56
58
  };
57
59
 
58
60
  export type ResolveOptions = {
@@ -352,6 +354,7 @@ export function resolveForge(
352
354
  fork: baseRepo !== headRepo,
353
355
  baseRemote,
354
356
  limits,
357
+ runner,
355
358
  });
356
359
  }
357
360
 
@@ -217,4 +217,8 @@ export interface Forge {
217
217
  findRelease(tag: string): ForgeResult<ForgeRelease | null>;
218
218
  /** Publish a release on an existing tag (never creates the tag). */
219
219
  createRelease(input: CreateReleaseInput): ForgeResult<ForgeRelease>;
220
+ /** Bodies of the first page of comments on issue `n` of the base repository. */
221
+ issueComments(n: number): ForgeResult<string[]>;
222
+ /** Comment on issue `n` of the base repository (the PR backlink). */
223
+ commentIssue(n: number, body: string): ForgeResult<void>;
220
224
  }
@@ -196,7 +196,9 @@ const tokenHash = (token: string): string => createHash("sha256").update(token).
196
196
  * Null when nothing was issued: already issued, or the write failed (fails open).
197
197
  */
198
198
  export function recordIssuedSession(cwd: string, lead: string, issued: string): string | null {
199
- const token = randomBytes(18).toString("base64url");
199
+ // Hex, never base64url: a token that starts with "-" (1 in 64) breaks the
200
+ // `--token <token>` the hook prints, because the CLI parses it as a flag.
201
+ const token = randomBytes(18).toString("hex");
200
202
  try {
201
203
  migrateRepoIssued(cwd);
202
204
  const own = hash(issued);
package/src/ledger.ts CHANGED
@@ -89,7 +89,12 @@ export type VerdictSurface = (typeof VERDICT_SURFACES)[number];
89
89
  export type Observer = "agent_asserted" | "workit_cli";
90
90
 
91
91
  /** Row types written by the CLI's own PR verbs (S10/S11); the only PR→branch source. */
92
- export const PR_ROW_TYPES: ReadonlySet<string> = new Set(["pr.created", "pr.status", "pr.merged"]);
92
+ export const PR_ROW_TYPES: ReadonlySet<string> = new Set([
93
+ "pr.created",
94
+ "pr.status",
95
+ "pr.merged",
96
+ "pr.linked",
97
+ ]);
93
98
 
94
99
  /** Who wrote a row. Unverified: the environment says so (D18). */
95
100
  export type LedgerActor = { host: string; session: string | null; agentId: string | null };
@@ -2085,6 +2090,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
2085
2090
  case "pr.merged":
2086
2091
  summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
2087
2092
  break;
2093
+ case "pr.linked":
2094
+ summary = `#${row.pr ?? "?"} links ${text("tracker")} ${text("issueRepo") ? `${text("issueRepo")}` : ""}${text("tracker") === "youtrack" ? "" : "#"}${text("issue")} (${text("relation") || "closes"}, from ${text("source") || "?"})`;
2095
+ break;
2088
2096
  case "merge.unverified":
2089
2097
  summary = `#${row.pr ?? "?"} unverified merge requested at ${(row.head ?? "?").slice(0, 12)} (reason: ${text("reason")})`;
2090
2098
  break;