@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 +1 -1
- package/skills/workit-ship/SKILL.md +2 -2
- package/src/core/vcs-config.ts +90 -0
- package/src/core/workers.ts +9 -0
- package/src/core/workspaces.ts +14 -8
- package/src/core/youtrack.ts +8 -0
- package/src/fanout-brief.ts +8 -0
- package/src/fanout.ts +31 -2
- package/src/forge/github.ts +24 -0
- package/src/forge/gitlab.ts +27 -0
- package/src/forge/issue-link.ts +458 -0
- package/src/forge/pr-body.ts +7 -7
- package/src/forge/resolve.ts +3 -0
- package/src/forge/types.ts +4 -0
- package/src/host-session.ts +3 -1
- package/src/ledger.ts +9 -1
package/package.json
CHANGED
|
@@ -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|->` (
|
|
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
|
package/src/core/vcs-config.ts
CHANGED
|
@@ -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 = {
|
package/src/core/workers.ts
CHANGED
|
@@ -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;
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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({
|
|
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)
|
package/src/core/youtrack.ts
CHANGED
|
@@ -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;
|
package/src/fanout-brief.ts
CHANGED
|
@@ -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;
|
package/src/forge/github.ts
CHANGED
|
@@ -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
|
}
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/forge/pr-body.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/forge/resolve.ts
CHANGED
|
@@ -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
|
|
package/src/forge/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/host-session.ts
CHANGED
|
@@ -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
|
-
|
|
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([
|
|
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;
|