@zhuxixi/pi-agent-board 0.4.2 → 0.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/README.md +16 -1
- package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +614 -0
- package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +32 -0
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
- package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +97 -0
- package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +35 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
- package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
- package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
- package/package.json +4 -2
- package/runner/job-runner.mjs +57 -5
- package/runner/pty-runner.mjs +89 -12
- package/runner/state-runner.mjs +7 -2
- package/runner/title-runner.mjs +1 -1
- package/src/core/atomic.mjs +40 -1
- package/src/core/code-refs-store.mjs +312 -0
- package/src/core/code-refs.mjs +861 -0
- package/src/core/follow-up-queue.mjs +23 -5
- package/src/core/host-crash.mjs +39 -0
- package/src/core/locks.mjs +79 -23
- package/src/core/paths.mjs +24 -1
- package/src/core/pty-attach-reconnect.mjs +43 -0
- package/src/core/repo.mjs +53 -0
- package/src/core/rows.mjs +50 -0
- package/src/core/store.mjs +4 -1
- package/src/core/types.mjs +12 -0
- package/src/runtime/service.mjs +7 -1
- package/src/ui/dashboard.ts +14 -2
- package/src/ui/pty-attach.ts +32 -3
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-refs provider layer: per-platform regex rule bundles for extracting
|
|
3
|
+
* issue/PR references from session evidence.
|
|
4
|
+
*
|
|
5
|
+
* A "provider" is pure data: one bundle of regex rules for a code platform
|
|
6
|
+
* (GitHub, GitLab, or an internal/self-hosted platform defined by the user in
|
|
7
|
+
* `<root>/providers.json`). User providers with the same name as a builtin are
|
|
8
|
+
* append-merged: user rules run first, then builtin rules; scalar fields
|
|
9
|
+
* (hosts/prefixes/urlTemplates) fall back to the builtin values when the user
|
|
10
|
+
* does not set them.
|
|
11
|
+
*
|
|
12
|
+
* All functions here are pure (zero I/O) except `loadProviders`, the single
|
|
13
|
+
* allowed I/O entry point, which reads `<root>/providers.json` through
|
|
14
|
+
* `readJson` and caches the merged result by file mtime.
|
|
15
|
+
*/
|
|
16
|
+
import { statSync } from "node:fs";
|
|
17
|
+
import { readJson } from "./atomic.mjs";
|
|
18
|
+
import { providersPath } from "./paths.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} Provider
|
|
22
|
+
* @property {string} name
|
|
23
|
+
* @property {string[]} hosts lowercase hosts this provider matches; [] = never matched by host
|
|
24
|
+
* @property {string} issuePrefix
|
|
25
|
+
* @property {string} prPrefix
|
|
26
|
+
* @property {{issue?: string, pr?: string}|null} urlTemplates
|
|
27
|
+
* @property {Rule[]} rules
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} Rule
|
|
32
|
+
* @property {RegExp} regex compiled from `pattern`, matched unanchored
|
|
33
|
+
* @property {string} pattern
|
|
34
|
+
* @property {"issue"|"pr"} kind
|
|
35
|
+
* @property {"claim"|"action"|"view"} strength
|
|
36
|
+
* @property {"capture"|"outputUrl"} numberFrom "outputUrl" rules carry no capture group
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const VALID_KINDS = new Set(["issue", "pr"]);
|
|
40
|
+
const VALID_STRENGTHS = new Set(["claim", "action", "view"]);
|
|
41
|
+
const VALID_NUMBER_FROM = new Set(["capture", "outputUrl"]);
|
|
42
|
+
|
|
43
|
+
/** https://host[:port]/owner/repo(.git) */
|
|
44
|
+
const HTTPS_REMOTE_RE = /^https?:\/\/([^/:\s]+)(?::\d+)?\/(.+)$/;
|
|
45
|
+
/** git@host:owner/repo(.git) */
|
|
46
|
+
const SSH_REMOTE_RE = /^git@([^/:\s]+):(.+)$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lowercased host of a raw origin-remote URL, or null when it cannot be parsed.
|
|
50
|
+
* Supports `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`; an
|
|
51
|
+
* optional port is dropped.
|
|
52
|
+
* @param {string|null} url
|
|
53
|
+
* @returns {string|null}
|
|
54
|
+
*/
|
|
55
|
+
export function parseRemoteHost(url) {
|
|
56
|
+
if (typeof url !== "string" || !url) return null;
|
|
57
|
+
const match = HTTPS_REMOTE_RE.exec(url) ?? SSH_REMOTE_RE.exec(url);
|
|
58
|
+
return match ? match[1].toLowerCase() : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `owner/repo` path of a raw origin-remote URL (trailing `.git` stripped), or
|
|
63
|
+
* null when it cannot be parsed. Supports the same https/ssh shapes as
|
|
64
|
+
* `parseRemoteHost`; nested namespaces are kept (e.g. `group/sub/repo`).
|
|
65
|
+
* @param {string|null} url
|
|
66
|
+
* @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
export function parseRemotePath(url) {
|
|
69
|
+
if (typeof url !== "string" || !url) return null;
|
|
70
|
+
const match = HTTPS_REMOTE_RE.exec(url) ?? SSH_REMOTE_RE.exec(url);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
const path = match[2].replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
73
|
+
return path || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compile trusted rule specs into Rule objects. Throws on an invalid pattern;
|
|
78
|
+
* only used for the builtin tables, which must stay valid.
|
|
79
|
+
* @param {Array<{pattern: string, kind: "issue"|"pr", strength: "claim"|"action"|"view", numberFrom?: "capture"|"outputUrl"}>} specs
|
|
80
|
+
* @returns {Rule[]}
|
|
81
|
+
*/
|
|
82
|
+
function compileRules(specs) {
|
|
83
|
+
return specs.map((spec) => ({
|
|
84
|
+
regex: new RegExp(spec.pattern),
|
|
85
|
+
pattern: spec.pattern,
|
|
86
|
+
kind: spec.kind,
|
|
87
|
+
strength: spec.strength,
|
|
88
|
+
numberFrom: spec.numberFrom ?? "capture",
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const GITHUB_RULES = [
|
|
93
|
+
{ pattern: "gh\\s+issue\\s+edit\\s+#?(\\d+)(?=[\\s\\S]*--add-assignee)", kind: "issue", strength: "claim" },
|
|
94
|
+
{ pattern: "gh\\s+issue\\s+(?:comment|edit|close|reopen)\\s+#?(\\d+)", kind: "issue", strength: "action" },
|
|
95
|
+
{ pattern: "gh\\s+issue\\s+create\\b", kind: "issue", strength: "action", numberFrom: "outputUrl" },
|
|
96
|
+
{ pattern: "gh\\s+pr\\s+(?:checkout|merge|comment|review|close)\\s+#?(\\d+)", kind: "pr", strength: "action" },
|
|
97
|
+
{ pattern: "gh\\s+pr\\s+create\\b", kind: "pr", strength: "action", numberFrom: "outputUrl" },
|
|
98
|
+
{ pattern: "github\\.com/[\\w.-]+/[\\w.-]+/pull/(\\d+)", kind: "pr", strength: "action" },
|
|
99
|
+
{ pattern: "gh\\s+issue\\s+view\\s+#?(\\d+)", kind: "issue", strength: "view" },
|
|
100
|
+
{ pattern: "gh\\s+pr\\s+(?:view|diff|checks)\\s+#?(\\d+)", kind: "pr", strength: "view" },
|
|
101
|
+
{ pattern: "github\\.com/[\\w.-]+/[\\w.-]+/issues/(\\d+)", kind: "issue", strength: "view" },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const GITLAB_RULES = [
|
|
105
|
+
{ pattern: "glab\\s+issue\\s+(?:edit|update)\\s+#?(\\d+)(?=[\\s\\S]*--assignee)", kind: "issue", strength: "claim" },
|
|
106
|
+
{ pattern: "glab\\s+issue\\s+(?:note|comment|close|reopen)\\s+#?(\\d+)", kind: "issue", strength: "action" },
|
|
107
|
+
{ pattern: "glab\\s+mr\\s+(?:checkout|merge)\\s+!?(\\d+)", kind: "pr", strength: "action" },
|
|
108
|
+
{ pattern: "glab\\s+mr\\s+create\\b", kind: "pr", strength: "action", numberFrom: "outputUrl" },
|
|
109
|
+
{ pattern: "/-/merge_requests/(\\d+)", kind: "pr", strength: "action" },
|
|
110
|
+
{ pattern: "glab\\s+issue\\s+view\\s+#?(\\d+)", kind: "issue", strength: "view" },
|
|
111
|
+
{ pattern: "glab\\s+mr\\s+view\\s+!?(\\d+)", kind: "pr", strength: "view" },
|
|
112
|
+
{ pattern: "/-/issues/(\\d+)", kind: "issue", strength: "view" },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const GENERIC_RULES = [
|
|
116
|
+
{ pattern: "/issues/(\\d+)", kind: "issue", strength: "view" },
|
|
117
|
+
{ pattern: "/pull/(\\d+)", kind: "pr", strength: "action" },
|
|
118
|
+
{ pattern: "/-/issues/(\\d+)", kind: "issue", strength: "view" },
|
|
119
|
+
{ pattern: "/-/merge_requests/(\\d+)", kind: "pr", strength: "action" },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builtin GitHub + GitLab provider bundles (fresh objects per call).
|
|
124
|
+
* @returns {Provider[]}
|
|
125
|
+
*/
|
|
126
|
+
export function builtinProviders() {
|
|
127
|
+
return [githubProvider(), gitlabProvider()];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Host-agnostic fallback provider used when no provider matches the repo host:
|
|
132
|
+
* URL-only rules for the common issue/PR URL shapes, `#` / `▸#` prefixes, no
|
|
133
|
+
* urlTemplates. Fresh object per call.
|
|
134
|
+
* @returns {Provider}
|
|
135
|
+
*/
|
|
136
|
+
export function genericFallbackProvider() {
|
|
137
|
+
return {
|
|
138
|
+
name: "generic",
|
|
139
|
+
hosts: [],
|
|
140
|
+
issuePrefix: "#",
|
|
141
|
+
prPrefix: "▸#",
|
|
142
|
+
urlTemplates: null,
|
|
143
|
+
rules: compileRules(GENERIC_RULES),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @returns {Provider} */
|
|
148
|
+
function githubProvider() {
|
|
149
|
+
return {
|
|
150
|
+
name: "github",
|
|
151
|
+
hosts: ["github.com"],
|
|
152
|
+
issuePrefix: "#",
|
|
153
|
+
prPrefix: "▸#",
|
|
154
|
+
urlTemplates: {
|
|
155
|
+
issue: "https://{host}/{owner}/{repo}/issues/{number}",
|
|
156
|
+
pr: "https://{host}/{owner}/{repo}/pull/{number}",
|
|
157
|
+
},
|
|
158
|
+
rules: compileRules(GITHUB_RULES),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @returns {Provider} */
|
|
163
|
+
function gitlabProvider() {
|
|
164
|
+
return {
|
|
165
|
+
name: "gitlab",
|
|
166
|
+
hosts: ["gitlab.com"],
|
|
167
|
+
issuePrefix: "#",
|
|
168
|
+
prPrefix: "!",
|
|
169
|
+
urlTemplates: {
|
|
170
|
+
issue: "https://{host}/{owner}/{repo}/-/issues/{number}",
|
|
171
|
+
pr: "https://{host}/{owner}/{repo}/-/merge_requests/{number}",
|
|
172
|
+
},
|
|
173
|
+
rules: compileRules(GITLAB_RULES),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate one raw provider config from `providers.json`.
|
|
179
|
+
*
|
|
180
|
+
* `name` and `rules` are required; an invalid provider yields `provider: null`.
|
|
181
|
+
* Individual invalid rules are skipped with an error message while valid rules
|
|
182
|
+
* are kept. Optional scalar fields (hosts/issuePrefix/prPrefix/urlTemplates)
|
|
183
|
+
* are validated but left unset when absent, so `mergeProviders` can tell "user
|
|
184
|
+
* did not set this" from "user set a value" and fall back to builtin fields.
|
|
185
|
+
* @param {any} raw
|
|
186
|
+
* @returns {{provider: Provider|null, errors: string[]}}
|
|
187
|
+
*/
|
|
188
|
+
export function validateProvider(raw) {
|
|
189
|
+
const errors = [];
|
|
190
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
191
|
+
return { provider: null, errors: ["provider must be an object"] };
|
|
192
|
+
}
|
|
193
|
+
if (typeof raw.name !== "string" || !raw.name.trim()) {
|
|
194
|
+
errors.push("provider name must be a non-empty string");
|
|
195
|
+
return { provider: null, errors };
|
|
196
|
+
}
|
|
197
|
+
const name = raw.name.trim();
|
|
198
|
+
if (!Array.isArray(raw.rules)) {
|
|
199
|
+
errors.push(`provider "${name}": rules must be an array`);
|
|
200
|
+
return { provider: null, errors };
|
|
201
|
+
}
|
|
202
|
+
/** @type {Rule[]} */
|
|
203
|
+
const rules = [];
|
|
204
|
+
raw.rules.forEach((rule, index) => {
|
|
205
|
+
const compiled = validateRule(rule, index, name, errors);
|
|
206
|
+
if (compiled) rules.push(compiled);
|
|
207
|
+
});
|
|
208
|
+
/** @type {Provider} */
|
|
209
|
+
const provider = { name, rules };
|
|
210
|
+
validateScalarFields(raw, name, provider, errors);
|
|
211
|
+
return { provider, errors };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Count capturing groups in a regex source string, ignoring escaped chars,
|
|
216
|
+
* character classes, non-capturing groups, and lookaround groups.
|
|
217
|
+
* @param {string} source
|
|
218
|
+
* @returns {number}
|
|
219
|
+
*/
|
|
220
|
+
function countCaptureGroups(source) {
|
|
221
|
+
let count = 0;
|
|
222
|
+
let inClass = false;
|
|
223
|
+
for (let i = 0; i < source.length; i++) {
|
|
224
|
+
const ch = source[i];
|
|
225
|
+
if (ch === "\\") {
|
|
226
|
+
i++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (inClass) {
|
|
230
|
+
if (ch === "]") inClass = false;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === "[") {
|
|
234
|
+
inClass = true;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ch === "(") {
|
|
238
|
+
if (source[i + 1] === "?") {
|
|
239
|
+
// (?<name>...) is a named capturing group; (?<=, ?<! are lookbehind.
|
|
240
|
+
if (source[i + 2] === "<" && source[i + 3] !== "=" && source[i + 3] !== "!") count++;
|
|
241
|
+
} else {
|
|
242
|
+
count++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return count;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compile and validate one raw rule; returns null (and records an error) when
|
|
251
|
+
* any field is missing or invalid. The regex must compile and, for
|
|
252
|
+
* `numberFrom: "capture"` rules, must contain at least one capture group for
|
|
253
|
+
* the number.
|
|
254
|
+
* @param {any} raw
|
|
255
|
+
* @param {number} index
|
|
256
|
+
* @param {string} providerName
|
|
257
|
+
* @param {string[]} errors
|
|
258
|
+
* @returns {Rule|null}
|
|
259
|
+
*/
|
|
260
|
+
function validateRule(raw, index, providerName, errors) {
|
|
261
|
+
const where = `provider "${providerName}" rule ${index}`;
|
|
262
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
263
|
+
errors.push(`${where}: must be an object`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const { pattern, kind, strength, numberFrom } = raw;
|
|
267
|
+
if (typeof pattern !== "string" || !pattern) {
|
|
268
|
+
errors.push(`${where}: missing pattern`);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
if (!VALID_KINDS.has(kind)) {
|
|
272
|
+
errors.push(`${where}: kind must be "issue" or "pr"`);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
if (!VALID_STRENGTHS.has(strength)) {
|
|
276
|
+
errors.push(`${where}: strength must be "claim", "action", or "view"`);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
if (numberFrom !== undefined && !VALID_NUMBER_FROM.has(numberFrom)) {
|
|
280
|
+
errors.push(`${where}: numberFrom must be "capture" or "outputUrl"`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
let regex;
|
|
284
|
+
try {
|
|
285
|
+
regex = new RegExp(pattern);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
errors.push(`${where}: invalid regex "${pattern}": ${e.message}`);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const resolvedNumberFrom = numberFrom ?? "capture";
|
|
291
|
+
if (resolvedNumberFrom === "capture" && countCaptureGroups(regex.source) === 0) {
|
|
292
|
+
errors.push(`${where}: capture rule must have a capture group`);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
return { regex, pattern, kind, strength, numberFrom: resolvedNumberFrom };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Validate optional scalar fields in place: malformed values are dropped (left
|
|
300
|
+
* unset so mergeProviders falls back to the builtin) with an error recorded.
|
|
301
|
+
* A scalar list is only assigned when at least one entry parsed, so an
|
|
302
|
+
* all-invalid `hosts`/`urlTemplates` is treated as unset rather than as an
|
|
303
|
+
* empty override that would clobber the builtin values.
|
|
304
|
+
* @param {any} raw
|
|
305
|
+
* @param {string} name
|
|
306
|
+
* @param {Provider} provider
|
|
307
|
+
* @param {string[]} errors
|
|
308
|
+
*/
|
|
309
|
+
function validateScalarFields(raw, name, provider, errors) {
|
|
310
|
+
if (raw.hosts !== undefined) {
|
|
311
|
+
if (Array.isArray(raw.hosts)) {
|
|
312
|
+
/** @type {string[]} */
|
|
313
|
+
const hosts = [];
|
|
314
|
+
raw.hosts.forEach((host, i) => {
|
|
315
|
+
if (typeof host === "string" && host) hosts.push(host);
|
|
316
|
+
else errors.push(`provider "${name}": hosts[${i}] must be a non-empty string`);
|
|
317
|
+
});
|
|
318
|
+
if (hosts.length > 0) provider.hosts = hosts;
|
|
319
|
+
} else {
|
|
320
|
+
errors.push(`provider "${name}": hosts must be an array of strings`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (raw.issuePrefix !== undefined) {
|
|
324
|
+
if (typeof raw.issuePrefix === "string") provider.issuePrefix = raw.issuePrefix;
|
|
325
|
+
else errors.push(`provider "${name}": issuePrefix must be a string`);
|
|
326
|
+
}
|
|
327
|
+
if (raw.prPrefix !== undefined) {
|
|
328
|
+
if (typeof raw.prPrefix === "string") provider.prPrefix = raw.prPrefix;
|
|
329
|
+
else errors.push(`provider "${name}": prPrefix must be a string`);
|
|
330
|
+
}
|
|
331
|
+
if (raw.urlTemplates !== undefined) {
|
|
332
|
+
if (raw.urlTemplates && typeof raw.urlTemplates === "object" && !Array.isArray(raw.urlTemplates)) {
|
|
333
|
+
/** @type {{issue?: string, pr?: string}} */
|
|
334
|
+
const templates = {};
|
|
335
|
+
if (raw.urlTemplates.issue !== undefined) {
|
|
336
|
+
if (typeof raw.urlTemplates.issue === "string") templates.issue = raw.urlTemplates.issue;
|
|
337
|
+
else errors.push(`provider "${name}": urlTemplates.issue must be a string`);
|
|
338
|
+
}
|
|
339
|
+
if (raw.urlTemplates.pr !== undefined) {
|
|
340
|
+
if (typeof raw.urlTemplates.pr === "string") templates.pr = raw.urlTemplates.pr;
|
|
341
|
+
else errors.push(`provider "${name}": urlTemplates.pr must be a string`);
|
|
342
|
+
}
|
|
343
|
+
if (templates.issue !== undefined || templates.pr !== undefined) {
|
|
344
|
+
provider.urlTemplates = templates;
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
errors.push(`provider "${name}": urlTemplates must be an object`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Append-merge user providers over builtins (decision D3): a user provider
|
|
354
|
+
* with the same name as a builtin gets its rules prepended to the builtin's
|
|
355
|
+
* rules and its scalar fields override the builtin's when set; unknown names
|
|
356
|
+
* are appended as-is with default scalars (`[]`, `#`, `▸#`, `null`). Builtin
|
|
357
|
+
* objects are never mutated. Validation errors are the caller's concern; the
|
|
358
|
+
* merge itself never throws.
|
|
359
|
+
* @param {Provider[]} builtins
|
|
360
|
+
* @param {Provider[]} user
|
|
361
|
+
* @returns {Provider[]}
|
|
362
|
+
*/
|
|
363
|
+
export function mergeProviders(builtins, user) {
|
|
364
|
+
const byName = new Map(builtins.map((p) => [p.name, p]));
|
|
365
|
+
const merged = builtins.map((builtin) => {
|
|
366
|
+
const override = user.find((p) => p.name === builtin.name);
|
|
367
|
+
if (!override) return builtin;
|
|
368
|
+
return {
|
|
369
|
+
name: builtin.name,
|
|
370
|
+
hosts: override.hosts ?? builtin.hosts,
|
|
371
|
+
issuePrefix: override.issuePrefix ?? builtin.issuePrefix,
|
|
372
|
+
prPrefix: override.prPrefix ?? builtin.prPrefix,
|
|
373
|
+
urlTemplates: override.urlTemplates ?? builtin.urlTemplates,
|
|
374
|
+
rules: [...override.rules, ...builtin.rules],
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
for (const provider of user) {
|
|
378
|
+
if (byName.has(provider.name)) continue;
|
|
379
|
+
merged.push({
|
|
380
|
+
name: provider.name,
|
|
381
|
+
hosts: provider.hosts ?? [],
|
|
382
|
+
issuePrefix: provider.issuePrefix ?? "#",
|
|
383
|
+
prPrefix: provider.prPrefix ?? "▸#",
|
|
384
|
+
urlTemplates: provider.urlTemplates ?? null,
|
|
385
|
+
rules: provider.rules,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return merged;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Cached merged provider lists per store root. The cache key records the
|
|
393
|
+
* `providers.json` mtime (null when the file is absent) so a file change
|
|
394
|
+
* triggers a reload while unchanged files reuse the merged result. Validation
|
|
395
|
+
* errors are cached alongside so they can be surfaced once per mtime.
|
|
396
|
+
* @type {Map<string, {mtimeMs: number|null, providers: Provider[], errors: string[]}>}
|
|
397
|
+
*/
|
|
398
|
+
const providerCache = new Map();
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Resolve the effective provider list for a store root, together with any
|
|
402
|
+
* per-provider validation errors from the user's `<root>/providers.json`.
|
|
403
|
+
* Never throws — a missing, unparseable, or otherwise broken file falls back to
|
|
404
|
+
* builtins alone; invalid provider entries are skipped and their messages
|
|
405
|
+
* returned in `errors`. Cached by file mtime.
|
|
406
|
+
* @param {string} root
|
|
407
|
+
* @returns {{providers: Provider[], errors: string[]}}
|
|
408
|
+
*/
|
|
409
|
+
export function loadProvidersWithErrors(root) {
|
|
410
|
+
const file = providersPath(root);
|
|
411
|
+
let mtimeMs = null;
|
|
412
|
+
try {
|
|
413
|
+
mtimeMs = statSync(file).mtimeMs;
|
|
414
|
+
} catch {
|
|
415
|
+
// no providers.json → builtins only
|
|
416
|
+
}
|
|
417
|
+
const cached = providerCache.get(root);
|
|
418
|
+
if (cached && cached.mtimeMs === mtimeMs) return { providers: cached.providers, errors: cached.errors };
|
|
419
|
+
const loaded = loadProvidersUncached(file);
|
|
420
|
+
providerCache.set(root, { mtimeMs, providers: loaded.providers, errors: loaded.errors });
|
|
421
|
+
return loaded;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Resolve the effective provider list for a store root (validation errors
|
|
426
|
+
* discarded): builtins merged with the user's `<root>/providers.json` when
|
|
427
|
+
* present. Never throws — a missing, unparseable, or otherwise broken file
|
|
428
|
+
* falls back to builtins alone. Thin wrapper over {@link loadProvidersWithErrors}.
|
|
429
|
+
* @param {string} root
|
|
430
|
+
* @returns {Provider[]}
|
|
431
|
+
*/
|
|
432
|
+
export function loadProviders(root) {
|
|
433
|
+
return loadProvidersWithErrors(root).providers;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Read + validate + merge without consulting the cache.
|
|
438
|
+
* @param {string} file
|
|
439
|
+
* @returns {{providers: Provider[], errors: string[]}}
|
|
440
|
+
*/
|
|
441
|
+
function loadProvidersUncached(file) {
|
|
442
|
+
const raw = readJson(file, null);
|
|
443
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.providers)) {
|
|
444
|
+
return { providers: builtinProviders(), errors: [] };
|
|
445
|
+
}
|
|
446
|
+
/** @type {Provider[]} */
|
|
447
|
+
const user = [];
|
|
448
|
+
/** @type {string[]} */
|
|
449
|
+
const errors = [];
|
|
450
|
+
for (const entry of raw.providers) {
|
|
451
|
+
const { provider, errors: entryErrors } = validateProvider(entry);
|
|
452
|
+
if (provider) user.push(provider);
|
|
453
|
+
errors.push(...entryErrors);
|
|
454
|
+
}
|
|
455
|
+
return { providers: mergeProviders(builtinProviders(), user), errors };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Clear the loadProviders cache. Test-only helper.
|
|
460
|
+
* @returns {void}
|
|
461
|
+
*/
|
|
462
|
+
export function clearProvidersCacheForTests() {
|
|
463
|
+
providerCache.clear();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Pick the provider whose hosts contain `host` (exact match, lowercased). A
|
|
468
|
+
* null/empty host or no match falls back to the generic URL-only provider.
|
|
469
|
+
* @param {Provider[]} providers
|
|
470
|
+
* @param {string|null} host
|
|
471
|
+
* @returns {Provider}
|
|
472
|
+
*/
|
|
473
|
+
export function matchProvider(providers, host) {
|
|
474
|
+
if (typeof host === "string" && host) {
|
|
475
|
+
const needle = host.toLowerCase();
|
|
476
|
+
for (const provider of providers) {
|
|
477
|
+
for (const candidate of provider.hosts) {
|
|
478
|
+
if (candidate.toLowerCase() === needle) return provider;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return genericFallbackProvider();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @typedef {Object} Ref
|
|
487
|
+
* @property {"issue"|"pr"} kind
|
|
488
|
+
* @property {number} number
|
|
489
|
+
* @property {"claim"|"action"|"view"|"mention"} strength
|
|
490
|
+
* @property {"high"|"medium"|"low"} confidence
|
|
491
|
+
* @property {string} source
|
|
492
|
+
* @property {string|null} url
|
|
493
|
+
* @property {number} lastIndex
|
|
494
|
+
*/
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @typedef {Object} CodeRefsResult
|
|
498
|
+
* @property {string} provider
|
|
499
|
+
* @property {Ref|null} issue
|
|
500
|
+
* @property {Ref|null} pr
|
|
501
|
+
* @property {Ref[]} allRefs
|
|
502
|
+
*/
|
|
503
|
+
|
|
504
|
+
const STRENGTH_RANK = { claim: 3, action: 2, view: 1, mention: 0 };
|
|
505
|
+
const REF_CONFIDENCE = { claim: "high", action: "high", view: "medium", mention: "low" };
|
|
506
|
+
|
|
507
|
+
/** URL rules carry a `/issues/`, `/pull/`, or `/merge_requests/` path segment. */
|
|
508
|
+
const URL_RULE_RE = /issues\/|pull\/|merge_requests\//;
|
|
509
|
+
/** `closes #N` / `fixes #N` / `issue #N` back-link inside a `pr create` body. */
|
|
510
|
+
const PR_BACKLINK_RE = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue)\s+#(\d{1,7})/i;
|
|
511
|
+
/** `issue-<N>-...` worktree/branch naming convention (engine-builtin). */
|
|
512
|
+
const WORKTREE_RE = /(?:^|[/\\])issue-(\d{1,7})(?:-|$)/;
|
|
513
|
+
/** Bare `#N` mentions. */
|
|
514
|
+
const MENTION_RE = /#(\d{1,7})/g;
|
|
515
|
+
|
|
516
|
+
/** Worktree/branch naming is session metadata, treated as before the transcript. */
|
|
517
|
+
const WORKTREE_INDEX = -1;
|
|
518
|
+
|
|
519
|
+
/** @param {Rule} rule */
|
|
520
|
+
function isUrlRule(rule) {
|
|
521
|
+
return URL_RULE_RE.test(rule.pattern);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
|
|
526
|
+
*/
|
|
527
|
+
function addCandidate(candidates, kind, number, strength, source, lastIndex) {
|
|
528
|
+
if (!Number.isInteger(number) || number <= 0) return;
|
|
529
|
+
candidates.push({ kind, number, strength, source, lastIndex });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Extract issue/PR references from session evidence against one provider. Pure
|
|
534
|
+
* (zero I/O): the caller has already resolved the provider for the repo host
|
|
535
|
+
* and passes `repoUrl` (`owner/repo`) plus `host` for urlTemplate filling.
|
|
536
|
+
*
|
|
537
|
+
* `input.commands` are scanned in array order; `input.assistantTexts` are
|
|
538
|
+
* treated as the ordered sequence continuing after commands (indexes keep
|
|
539
|
+
* counting). Rule strengths rank `claim > action > view > mention`; a tie in
|
|
540
|
+
* strength is broken by the highest `lastIndex`.
|
|
541
|
+
*
|
|
542
|
+
* @param {{commands: Array<{command: string}>, assistantTexts: string[], worktreePath: string|null, branch: string|null, repoUrl: string|null, host: string|null}} input
|
|
543
|
+
* @param {Provider} provider
|
|
544
|
+
* @returns {CodeRefsResult}
|
|
545
|
+
*/
|
|
546
|
+
export function extractCodeRefs(input, provider) {
|
|
547
|
+
input = input ?? {};
|
|
548
|
+
const commands = Array.isArray(input.commands) ? input.commands : [];
|
|
549
|
+
const assistantTexts = Array.isArray(input.assistantTexts) ? input.assistantTexts : [];
|
|
550
|
+
const worktreePath = input.worktreePath ?? null;
|
|
551
|
+
const branch = input.branch ?? null;
|
|
552
|
+
const repoUrl = input.repoUrl ?? null;
|
|
553
|
+
const host = input.host ?? null;
|
|
554
|
+
const rules = provider.rules;
|
|
555
|
+
|
|
556
|
+
/** @type {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} */
|
|
557
|
+
const candidates = [];
|
|
558
|
+
/** @type {Array<{kind: "issue"|"pr", index: number}>} */
|
|
559
|
+
const pendingCreates = [];
|
|
560
|
+
|
|
561
|
+
// Rule 1: commands in order, against every provider rule.
|
|
562
|
+
commands.forEach((cmd, index) => {
|
|
563
|
+
const text = cmd && typeof cmd.command === "string" ? cmd.command : "";
|
|
564
|
+
if (!text) return;
|
|
565
|
+
for (const rule of rules) {
|
|
566
|
+
const m = rule.regex.exec(text);
|
|
567
|
+
if (!m) continue;
|
|
568
|
+
if (rule.numberFrom === "outputUrl") {
|
|
569
|
+
pendingCreates.push({ kind: rule.kind, index });
|
|
570
|
+
// Rule 4: PR back-link within a pr create command.
|
|
571
|
+
if (rule.kind === "pr") applyPrBacklink(text, index, candidates);
|
|
572
|
+
} else {
|
|
573
|
+
addCandidate(candidates, rule.kind, Number(m[1]), rule.strength, "command", index);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Rule 2: assistant texts (indexes continue after commands) with URL rules only.
|
|
579
|
+
const urlRules = rules.filter(isUrlRule);
|
|
580
|
+
assistantTexts.forEach((text, i) => {
|
|
581
|
+
const index = commands.length + i;
|
|
582
|
+
if (typeof text !== "string" || !text) return;
|
|
583
|
+
for (const rule of urlRules) {
|
|
584
|
+
const m = rule.regex.exec(text);
|
|
585
|
+
if (!m) continue;
|
|
586
|
+
addCandidate(candidates, rule.kind, Number(m[1]), rule.strength, "url", index);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Rule 3: resolve pending create markers against subsequent evidence.
|
|
591
|
+
for (let mi = 0; mi < pendingCreates.length; mi++) {
|
|
592
|
+
const marker = pendingCreates[mi];
|
|
593
|
+
const resolved = resolveCreate(marker, commands, assistantTexts, urlRules);
|
|
594
|
+
if (resolved) {
|
|
595
|
+
addCandidate(candidates, marker.kind, resolved.number, "action", "create-url", resolved.index);
|
|
596
|
+
}
|
|
597
|
+
// Rule 4b: the issue back-link of a created PR may live in a later
|
|
598
|
+
// assistant message ("This PR closes #40") or in --body-file content
|
|
599
|
+
// that never appears in the command string — scan subsequent evidence
|
|
600
|
+
// for the back-link pattern as well (not just the command itself).
|
|
601
|
+
// The scan stops at the NEXT pr-create marker so an earlier create
|
|
602
|
+
// never absorbs a later PR's back-link.
|
|
603
|
+
if (marker.kind === "pr") {
|
|
604
|
+
const nextPr = pendingCreates.slice(mi + 1).find((m) => m.kind === "pr");
|
|
605
|
+
const backlink = resolveBacklinkAfter(marker, commands, assistantTexts, nextPr?.index ?? Infinity);
|
|
606
|
+
if (backlink) addCandidate(candidates, "issue", backlink.number, "claim", "pr-backlink", backlink.index);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Rule 5: worktree/branch naming (engine-builtin, not configurable).
|
|
611
|
+
for (const value of [worktreePath, branch]) {
|
|
612
|
+
if (typeof value !== "string" || !value) continue;
|
|
613
|
+
const m = WORKTREE_RE.exec(value);
|
|
614
|
+
if (m) addCandidate(candidates, "issue", Number(m[1]), "claim", "worktree", WORKTREE_INDEX);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Rule 7 (first half): drop view signals seen fewer than twice. This runs
|
|
618
|
+
// before the mention check so a single discarded view does not suppress the
|
|
619
|
+
// bare-`#N` fallback.
|
|
620
|
+
discardWeakViews(candidates);
|
|
621
|
+
|
|
622
|
+
// Rule 6: bare `#N` mention fallback, only when no stronger issue signal.
|
|
623
|
+
if (!hasIssueCandidateAtLeastView(candidates)) {
|
|
624
|
+
const mention = mentionFallback(assistantTexts, commands.length);
|
|
625
|
+
if (mention) addCandidate(candidates, "issue", mention.number, "mention", "mention", mention.lastIndex);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Rule 7 (second half): aggregate winners + allRefs.
|
|
629
|
+
return aggregate(candidates, provider, repoUrl, host);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* @param {string} text
|
|
634
|
+
* @param {number} index
|
|
635
|
+
* @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
|
|
636
|
+
*/
|
|
637
|
+
function applyPrBacklink(text, index, candidates) {
|
|
638
|
+
const m = PR_BACKLINK_RE.exec(text);
|
|
639
|
+
if (m) addCandidate(candidates, "issue", Number(m[1]), "claim", "pr-body", index);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Find the first PR→issue back-link ("Closes #N" etc.) in evidence AFTER a
|
|
644
|
+
* `pr create` marker — covers assistant messages and later commands alike.
|
|
645
|
+
* The scan stops before `stopBefore` (typically the next pr-create marker).
|
|
646
|
+
* @param {{kind: "issue"|"pr", index: number}} marker
|
|
647
|
+
* @param {Array<{command: string}>} commands
|
|
648
|
+
* @param {string[]} assistantTexts
|
|
649
|
+
* @param {number} [stopBefore]
|
|
650
|
+
*/
|
|
651
|
+
function resolveBacklinkAfter(marker, commands, assistantTexts, stopBefore = Infinity) {
|
|
652
|
+
const total = Math.min(commands.length + assistantTexts.length, stopBefore);
|
|
653
|
+
for (let index = marker.index + 1; index < total; index++) {
|
|
654
|
+
const text = evidenceTextAt(index, commands, assistantTexts);
|
|
655
|
+
if (!text) continue;
|
|
656
|
+
const m = PR_BACKLINK_RE.exec(text);
|
|
657
|
+
if (m) return { number: Number(m[1]), index };
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* @param {{kind: "issue"|"pr", index: number}} marker
|
|
664
|
+
* @param {Array<{command: string}>} commands
|
|
665
|
+
* @param {string[]} assistantTexts
|
|
666
|
+
* @param {Rule[]} urlRules
|
|
667
|
+
*/
|
|
668
|
+
function resolveCreate(marker, commands, assistantTexts, urlRules) {
|
|
669
|
+
const total = commands.length + assistantTexts.length;
|
|
670
|
+
for (let index = marker.index + 1; index < total; index++) {
|
|
671
|
+
const text = evidenceTextAt(index, commands, assistantTexts);
|
|
672
|
+
if (!text) continue;
|
|
673
|
+
for (const rule of urlRules) {
|
|
674
|
+
if (rule.kind !== marker.kind) continue;
|
|
675
|
+
const m = rule.regex.exec(text);
|
|
676
|
+
if (m) return { number: Number(m[1]), index };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* @param {number} index
|
|
684
|
+
* @param {Array<{command: string}>} commands
|
|
685
|
+
* @param {string[]} assistantTexts
|
|
686
|
+
*/
|
|
687
|
+
function evidenceTextAt(index, commands, assistantTexts) {
|
|
688
|
+
if (index < commands.length) {
|
|
689
|
+
const cmd = commands[index];
|
|
690
|
+
return cmd && typeof cmd.command === "string" ? cmd.command : "";
|
|
691
|
+
}
|
|
692
|
+
const text = assistantTexts[index - commands.length];
|
|
693
|
+
return typeof text === "string" ? text : "";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Remove view candidates whose number was viewed fewer than twice (D1: view
|
|
698
|
+
* signals only count at frequency >= 2). Mutates `candidates` in place.
|
|
699
|
+
* @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
|
|
700
|
+
*/
|
|
701
|
+
function discardWeakViews(candidates) {
|
|
702
|
+
const counts = new Map();
|
|
703
|
+
for (const c of candidates) {
|
|
704
|
+
if (c.strength !== "view") continue;
|
|
705
|
+
const key = `${c.kind}:${c.number}`;
|
|
706
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
707
|
+
}
|
|
708
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
709
|
+
const c = candidates[i];
|
|
710
|
+
if (c.strength === "view" && (counts.get(`${c.kind}:${c.number}`) ?? 0) < 2) {
|
|
711
|
+
candidates.splice(i, 1);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
|
|
718
|
+
*/
|
|
719
|
+
function hasIssueCandidateAtLeastView(candidates) {
|
|
720
|
+
return candidates.some((c) => c.kind === "issue" && STRENGTH_RANK[c.strength] >= STRENGTH_RANK.view);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Count bare `#N` mentions over the last 20 assistant texts. Returns the
|
|
725
|
+
* winner (and its last occurrence index) only when it appears at least 3 times
|
|
726
|
+
* and at least twice as often as the runner-up; otherwise null.
|
|
727
|
+
* @param {string[]} assistantTexts
|
|
728
|
+
* @param {number} baseIndex
|
|
729
|
+
*/
|
|
730
|
+
function mentionFallback(assistantTexts, baseIndex) {
|
|
731
|
+
const start = Math.max(0, assistantTexts.length - 20);
|
|
732
|
+
const counts = new Map();
|
|
733
|
+
const lastIndex = new Map();
|
|
734
|
+
for (let i = start; i < assistantTexts.length; i++) {
|
|
735
|
+
const text = assistantTexts[i];
|
|
736
|
+
if (typeof text !== "string") continue;
|
|
737
|
+
for (const m of text.matchAll(MENTION_RE)) {
|
|
738
|
+
const n = Number(m[1]);
|
|
739
|
+
counts.set(n, (counts.get(n) ?? 0) + 1);
|
|
740
|
+
lastIndex.set(n, baseIndex + i);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (counts.size === 0) return null;
|
|
744
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
745
|
+
const winner = ranked[0];
|
|
746
|
+
const runnerUp = ranked[1]?.[1] ?? 0;
|
|
747
|
+
if (winner[1] < 3 || winner[1] < 2 * runnerUp) return null;
|
|
748
|
+
return { number: winner[0], lastIndex: lastIndex.get(winner[0]) ?? baseIndex };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* @param {Array<{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} candidates
|
|
753
|
+
* @param {Provider} provider
|
|
754
|
+
* @param {string|null} repoUrl
|
|
755
|
+
* @param {string|null} host
|
|
756
|
+
*/
|
|
757
|
+
function aggregate(candidates, provider, repoUrl, host) {
|
|
758
|
+
/** @type {Map<string, {kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} */
|
|
759
|
+
const byKey = new Map();
|
|
760
|
+
for (const c of candidates) {
|
|
761
|
+
const key = `${c.kind}:${c.number}`;
|
|
762
|
+
const prev = byKey.get(key);
|
|
763
|
+
if (!prev) {
|
|
764
|
+
byKey.set(key, c);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const rank = STRENGTH_RANK[c.strength];
|
|
768
|
+
const prevRank = STRENGTH_RANK[prev.strength];
|
|
769
|
+
// Stronger wins; equal strength → most recent; equal recency → the more
|
|
770
|
+
// specific later-derived signal (e.g. create-url beats url).
|
|
771
|
+
if (rank > prevRank || (rank === prevRank && c.lastIndex >= prev.lastIndex)) {
|
|
772
|
+
byKey.set(key, c);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const issue = pickWinner(byKey, "issue");
|
|
777
|
+
const pr = pickWinner(byKey, "pr");
|
|
778
|
+
const allRefs = [...byKey.values()]
|
|
779
|
+
.sort((a, b) => STRENGTH_RANK[b.strength] - STRENGTH_RANK[a.strength] || b.lastIndex - a.lastIndex)
|
|
780
|
+
.slice(0, 10)
|
|
781
|
+
.map((c) => toRef(c, provider, repoUrl, host));
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
provider: provider.name,
|
|
785
|
+
issue: issue ? toRef(issue, provider, repoUrl, host) : null,
|
|
786
|
+
pr: pr ? toRef(pr, provider, repoUrl, host) : null,
|
|
787
|
+
allRefs,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* @param {Map<string, {kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}>} byKey
|
|
793
|
+
* @param {"issue"|"pr"} kind
|
|
794
|
+
*/
|
|
795
|
+
function pickWinner(byKey, kind) {
|
|
796
|
+
let best = null;
|
|
797
|
+
for (const c of byKey.values()) {
|
|
798
|
+
if (c.kind !== kind) continue;
|
|
799
|
+
if (!best) {
|
|
800
|
+
best = c;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const rank = STRENGTH_RANK[c.strength];
|
|
804
|
+
const bestRank = STRENGTH_RANK[best.strength];
|
|
805
|
+
if (rank > bestRank || (rank === bestRank && c.lastIndex > best.lastIndex)) best = c;
|
|
806
|
+
}
|
|
807
|
+
return best;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* @param {{kind: "issue"|"pr", number: number, strength: string, source: string, lastIndex: number}} c
|
|
812
|
+
* @param {Provider} provider
|
|
813
|
+
* @param {string|null} repoUrl
|
|
814
|
+
* @param {string|null} host
|
|
815
|
+
* @returns {Ref}
|
|
816
|
+
*/
|
|
817
|
+
function toRef(c, provider, repoUrl, host) {
|
|
818
|
+
return {
|
|
819
|
+
kind: c.kind,
|
|
820
|
+
number: c.number,
|
|
821
|
+
strength: c.strength,
|
|
822
|
+
confidence: REF_CONFIDENCE[c.strength],
|
|
823
|
+
source: c.source,
|
|
824
|
+
url: c.strength === "mention" ? null : fillUrl(provider, c.kind, c.number, repoUrl, host),
|
|
825
|
+
lastIndex: c.lastIndex,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Fill a provider urlTemplate (`{host}`, `{owner}`, `{repo}`, `{number}`).
|
|
831
|
+
* Returns null when there is no template for the kind or no repoUrl.
|
|
832
|
+
* @param {Provider} provider
|
|
833
|
+
* @param {"issue"|"pr"} kind
|
|
834
|
+
* @param {number} number
|
|
835
|
+
* @param {string|null} repoUrl
|
|
836
|
+
* @param {string|null} host
|
|
837
|
+
*/
|
|
838
|
+
function fillUrl(provider, kind, number, repoUrl, host) {
|
|
839
|
+
const template = provider.urlTemplates?.[kind];
|
|
840
|
+
if (!template || typeof repoUrl !== "string" || !repoUrl) return null;
|
|
841
|
+
// Templates embed {host}; without a host the URL would be malformed, so bail.
|
|
842
|
+
if (template.includes("{host}") && !host) return null;
|
|
843
|
+
const { owner, repo } = splitRepoUrl(repoUrl);
|
|
844
|
+
return template
|
|
845
|
+
.replaceAll("{host}", typeof host === "string" ? host : "")
|
|
846
|
+
.replaceAll("{owner}", owner)
|
|
847
|
+
.replaceAll("{repo}", repo)
|
|
848
|
+
.replaceAll("{number}", String(number));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Split an `owner/repo` path into its owner and repo parts (repo name minus
|
|
853
|
+
* any trailing `.git`).
|
|
854
|
+
* @param {string} repoUrl
|
|
855
|
+
*/
|
|
856
|
+
function splitRepoUrl(repoUrl) {
|
|
857
|
+
const path = repoUrl.replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
858
|
+
const slash = path.lastIndexOf("/");
|
|
859
|
+
if (slash === -1) return { owner: "", repo: path };
|
|
860
|
+
return { owner: path.slice(0, slash), repo: path.slice(slash + 1) };
|
|
861
|
+
}
|