@reneturcios/greenlight 0.0.0-stage → 0.3.3

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/dist/github.js ADDED
@@ -0,0 +1,787 @@
1
+ /**
2
+ * GitHub API client. Native fetch only, no extra dependencies.
3
+ * Optional GITHUB_TOKEN env var raises the rate limit from 60 to 5000 req/hr.
4
+ * Responses are cached under ~/.cache/greenlight (6h TTL for issues/repos,
5
+ * 1h for search) so repeated checks do not burn quota.
6
+ */
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
10
+ import { classifyLinkStrength } from "./linkstrength.js";
11
+ import { detectWorkflowGates } from "./gates.js";
12
+ import { isBotAuthor } from "./claims.js";
13
+ const API = "https://api.github.com";
14
+ const RAW = "https://raw.githubusercontent.com";
15
+ const CACHE_DIR = join(homedir(), ".cache", "greenlight");
16
+ function ensureCache() {
17
+ try {
18
+ mkdirSync(CACHE_DIR, { recursive: true });
19
+ }
20
+ catch { /* ignore */ }
21
+ }
22
+ const TTL_MS = {
23
+ issue: 6 * 60 * 60 * 1000,
24
+ search: 60 * 60 * 1000,
25
+ contributing: 24 * 60 * 60 * 1000,
26
+ };
27
+ function cacheKey(kind, parts) {
28
+ return `${kind}-${parts.join("-").replace(/[^a-zA-Z0-9_.-]/g, "_")}.json`;
29
+ }
30
+ function cacheGet(kind, parts) {
31
+ ensureCache();
32
+ const file = join(CACHE_DIR, cacheKey(kind, parts));
33
+ if (!existsSync(file))
34
+ return undefined;
35
+ try {
36
+ const entry = JSON.parse(readFileSync(file, "utf8"));
37
+ if (Date.now() - entry.savedAt > TTL_MS[kind])
38
+ return undefined;
39
+ return entry.data;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ function cacheSet(kind, parts, data) {
46
+ ensureCache();
47
+ const file = join(CACHE_DIR, cacheKey(kind, parts));
48
+ try {
49
+ writeFileSync(file, JSON.stringify({ savedAt: Date.now(), data }), "utf8");
50
+ }
51
+ catch { /* cache is best-effort */ }
52
+ }
53
+ export class RateLimitError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "RateLimitError";
57
+ }
58
+ }
59
+ /** Thrown by apiGet/apiPost for HTTP 404, so callers can treat "missing" as data. */
60
+ export class ApiNotFoundError extends Error {
61
+ constructor(path) {
62
+ super(`GitHub API 404 for ${path}`);
63
+ this.name = "ApiNotFoundError";
64
+ }
65
+ }
66
+ export function githubToken() {
67
+ return process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
68
+ }
69
+ export async function apiGet(path) {
70
+ const headers = {
71
+ "Accept": "application/vnd.github+json",
72
+ "User-Agent": "greenlight-cli",
73
+ "X-GitHub-Api-Version": "2022-11-28",
74
+ };
75
+ const token = githubToken();
76
+ if (token)
77
+ headers["Authorization"] = `Bearer ${token}`;
78
+ const res = await fetch(`${API}${path}`, { headers });
79
+ if (res.status === 403 || res.status === 429) {
80
+ const body = await res.text().catch(() => "");
81
+ const reset = res.headers.get("x-ratelimit-reset");
82
+ const wait = reset ? ` Rate limit resets at ${new Date(Number(reset) * 1000).toISOString()}.` : "";
83
+ const hint = token
84
+ ? "Authenticated limit also exhausted."
85
+ : "Set GITHUB_TOKEN (a classic PAT, no scopes needed for public repos) to raise the limit from 60 to 5000 req/hr.";
86
+ throw new RateLimitError(`GitHub rate limit hit (${res.status}).${wait} ${hint} ${body.slice(0, 120)}`);
87
+ }
88
+ if (res.status === 404) {
89
+ throw new ApiNotFoundError(path);
90
+ }
91
+ if (!res.ok) {
92
+ throw new Error(`GitHub API ${res.status} for ${path}: ${(await res.text()).slice(0, 200)}`);
93
+ }
94
+ return res.json();
95
+ }
96
+ /** POST helper for write operations (automation commands). Read paths use apiGet. */
97
+ export async function apiPost(path, body) {
98
+ const headers = {
99
+ "Accept": "application/vnd.github+json",
100
+ "Content-Type": "application/json",
101
+ "User-Agent": "greenlight-cli",
102
+ "X-GitHub-Api-Version": "2022-11-28",
103
+ };
104
+ const token = githubToken();
105
+ if (token)
106
+ headers["Authorization"] = `Bearer ${token}`;
107
+ const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
108
+ if (res.status === 403 || res.status === 429) {
109
+ const reset = res.headers.get("x-ratelimit-reset");
110
+ const wait = reset ? ` Rate limit resets at ${new Date(Number(reset) * 1000).toISOString()}.` : "";
111
+ throw new RateLimitError(`GitHub rate limit hit (${res.status}).${wait}`);
112
+ }
113
+ if (res.status === 404) {
114
+ throw new ApiNotFoundError(path);
115
+ }
116
+ if (!res.ok) {
117
+ throw new Error(`GitHub API ${res.status} for ${path}: ${(await res.text()).slice(0, 200)}`);
118
+ }
119
+ return res.json();
120
+ }
121
+ async function paginate(path, perPage = 100, maxPages = 5) {
122
+ const out = [];
123
+ for (let page = 1; page <= maxPages; page++) {
124
+ const sep = path.includes("?") ? "&" : "?";
125
+ const data = (await apiGet(`${path}${sep}per_page=${perPage}&page=${page}`));
126
+ out.push(...data);
127
+ if (data.length < perPage)
128
+ break;
129
+ }
130
+ return out;
131
+ }
132
+ export function parseIssueUrl(url) {
133
+ const m = /github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/.exec(url);
134
+ if (!m)
135
+ throw new Error(`Not a GitHub issue URL: ${url}`);
136
+ return { owner: m[1], repo: m[2], number: Number(m[3]) };
137
+ }
138
+ /** Fetch issue comments for claim detection. */
139
+ export async function fetchComments(owner, repo, number) {
140
+ const cached = cacheGet("issue", [owner, repo, String(number), "comments"]);
141
+ if (cached)
142
+ return cached;
143
+ const data = await paginate(`/repos/${owner}/${repo}/issues/${number}/comments`);
144
+ const comments = data.map((c) => ({
145
+ author: c.user.login,
146
+ body: String(c.body ?? ""),
147
+ createdAt: String(c.created_at),
148
+ }));
149
+ cacheSet("issue", [owner, repo, String(number), "comments"], comments);
150
+ return comments;
151
+ }
152
+ /**
153
+ * Fetch raw issue timeline events (cached). Shared by the linked-PR walk
154
+ * and duplicate-of detection so the timeline is fetched once per issue.
155
+ */
156
+ async function fetchTimelineEvents(owner, repo, number) {
157
+ const cached = cacheGet("issue", [owner, repo, String(number), "timeline"]);
158
+ if (cached)
159
+ return cached;
160
+ let events = [];
161
+ try {
162
+ events = await paginate(`/repos/${owner}/${repo}/issues/${number}/timeline`);
163
+ }
164
+ catch { /* timeline is best-effort */ }
165
+ cacheSet("issue", [owner, repo, String(number), "timeline"], events);
166
+ return events;
167
+ }
168
+ /**
169
+ * Find PRs that reference an issue. Timeline cross-references and a text
170
+ * search for the issue number are discovery only: every candidate PR is
171
+ * then fetched individually and scored with classifyLinkStrength, so a PR
172
+ * that merely lists a sibling issue (e.g. pdfcn #91 closing #70 while
173
+ * mentioning #61) is stored as a "mention", not a "closing" PR. See issue #1.
174
+ */
175
+ export async function fetchLinkedPRs(owner, repo, number) {
176
+ const cached = cacheGet("issue", [owner, repo, String(number), "prs"]);
177
+ if (cached)
178
+ return cached;
179
+ const found = new Map();
180
+ {
181
+ const timeline = await fetchTimelineEvents(owner, repo, number);
182
+ for (const event of timeline) {
183
+ if (event.event !== "cross-referenced")
184
+ continue;
185
+ const src = event.source;
186
+ if (!src || src.type !== "issue")
187
+ continue;
188
+ const pull = src.pull_request;
189
+ if (!pull)
190
+ continue;
191
+ const prUrl = String(src.html_url ?? "");
192
+ const nm = /\/pull\/(\d+)/.exec(prUrl);
193
+ if (!nm)
194
+ continue;
195
+ found.set(Number(nm[1]), {
196
+ number: Number(nm[1]),
197
+ state: "open", // refined below via search state
198
+ title: String(src.title ?? ""),
199
+ url: prUrl,
200
+ });
201
+ }
202
+ }
203
+ // Search PRs in the repo that mention the issue number, to get states.
204
+ try {
205
+ const q = encodeURIComponent(`repo:${owner}/${repo} type:pr #${number}`);
206
+ const data = (await apiGet(`/search/issues?q=${q}&per_page=20`));
207
+ for (const item of data.items ?? []) {
208
+ const state = item.pull_request?.merged_at ? "merged" : item.state === "open" ? "open" : "closed";
209
+ found.set(item.number, {
210
+ number: item.number,
211
+ state,
212
+ title: item.title,
213
+ url: item.html_url,
214
+ });
215
+ }
216
+ }
217
+ catch { /* search is best-effort */ }
218
+ // Score each candidate with its real body and branch: discovery is not evidence.
219
+ const prs = [];
220
+ for (const cand of found.values()) {
221
+ let linkStrength = "mention";
222
+ let title = cand.title;
223
+ try {
224
+ const detail = (await apiGet(`/repos/${owner}/${repo}/pulls/${cand.number}`));
225
+ title = String(detail.title ?? cand.title);
226
+ const branch = String(detail.head?.ref ?? "");
227
+ linkStrength = classifyLinkStrength({
228
+ title,
229
+ body: String(detail.body ?? ""),
230
+ branch,
231
+ issueNumber: number,
232
+ });
233
+ }
234
+ catch {
235
+ linkStrength = "mention"; // could not read the PR; treat as a weak reference
236
+ }
237
+ prs.push({ number: cand.number, state: cand.state, title, url: cand.url, linkStrength });
238
+ }
239
+ cacheSet("issue", [owner, repo, String(number), "prs"], prs);
240
+ return prs;
241
+ }
242
+ /**
243
+ * Policy files fetched for gate extraction. Binding contribution rules
244
+ * increasingly live in house-rules files that CONTRIBUTING only links to
245
+ * (reticle keeps its claim-first, DCO, .changes and `pnpm verify` rules in
246
+ * AGENTS.md), so CONTRIBUTING variants are fetched together with AGENTS.md
247
+ * and CLAUDE.md at the root and under .github/. fastmcp moved its rules
248
+ * again in 2026, into docs/development/contributing.mdx behind a pointer
249
+ * CONTRIBUTING.md, which is why the development-guide paths are here too.
250
+ */
251
+ export const HOUSE_RULE_FILES = [
252
+ "CONTRIBUTING.md",
253
+ "contributing.md",
254
+ ".github/CONTRIBUTING.md",
255
+ "docs/development/contributing.mdx",
256
+ "docs/development/contributing.md",
257
+ "AGENTS.md",
258
+ "CLAUDE.md",
259
+ ".github/AGENTS.md",
260
+ ".github/CLAUDE.md",
261
+ ];
262
+ /** Fetch all existing house-rules files for a repo (best-effort, cached). */
263
+ export async function fetchHouseRules(owner, repo, deps = {}) {
264
+ if (!deps.fetchRaw) {
265
+ const cached = cacheGet("house-rules", [owner, repo]);
266
+ if (cached)
267
+ return cached;
268
+ }
269
+ const headers = { "User-Agent": "greenlight-cli" };
270
+ const token = githubToken();
271
+ if (token)
272
+ headers["Authorization"] = `Bearer ${token}`;
273
+ const fetchRaw = deps.fetchRaw ??
274
+ (async (file) => {
275
+ try {
276
+ const res = await fetch(`${RAW}/${owner}/${repo}/HEAD/${file}`, { headers });
277
+ if (res.ok)
278
+ return await res.text();
279
+ }
280
+ catch { /* try next */ }
281
+ return undefined;
282
+ });
283
+ const out = [];
284
+ for (const file of HOUSE_RULE_FILES) {
285
+ const text = await fetchRaw(file);
286
+ if (text)
287
+ out.push({ file, text });
288
+ }
289
+ if (!deps.fetchRaw)
290
+ cacheSet("house-rules", [owner, repo], out);
291
+ return out;
292
+ }
293
+ /** Fetch CONTRIBUTING.md raw text; returns undefined when the repo has none. */
294
+ export async function fetchContributing(owner, repo) {
295
+ const rules = await fetchHouseRules(owner, repo);
296
+ return rules.find((r) => r.file.toLowerCase().includes("contributing"))?.text;
297
+ }
298
+ /**
299
+ * Extract PR references from comment bodies: bare #NNN mentions and full
300
+ * github.com/<owner>/<repo>/pull/NNN URLs. Returns deduplicated numbers in
301
+ * order of first appearance.
302
+ */
303
+ export function extractPRReferences(comments, owner, repo) {
304
+ const esc = (s) => s.replace(/[^a-zA-Z0-9_.-]/g, "");
305
+ const urlRe = new RegExp(`github\\.com/${esc(owner)}/${esc(repo)}/pull/(\\d+)`, "gi");
306
+ const bareRe = /#(\d{1,6})\b/g;
307
+ const seen = new Set();
308
+ const out = [];
309
+ for (const comment of comments) {
310
+ for (const re of [urlRe, bareRe]) {
311
+ re.lastIndex = 0;
312
+ let m;
313
+ while ((m = re.exec(comment.body)) !== null) {
314
+ const n = Number(m[1]);
315
+ if (n > 0 && !seen.has(n)) {
316
+ seen.add(n);
317
+ out.push(n);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+ /**
325
+ * PR references per citing comment author: number -> first comment author
326
+ * that cited it. Used to tag MentionedPRs with fromAuthor.
327
+ */
328
+ export function extractPRReferenceAuthors(comments, owner, repo) {
329
+ const esc = (s) => s.replace(/[^a-zA-Z0-9_.-]/g, "");
330
+ const urlRe = new RegExp(`github\\.com/${esc(owner)}/${esc(repo)}/pull/(\\d+)`, "gi");
331
+ const bareRe = /#(\d{1,6})\b/g;
332
+ const out = new Map();
333
+ for (const comment of comments) {
334
+ for (const re of [urlRe, bareRe]) {
335
+ re.lastIndex = 0;
336
+ let m;
337
+ while ((m = re.exec(comment.body)) !== null) {
338
+ const n = Number(m[1]);
339
+ if (n > 0 && !out.has(n))
340
+ out.set(n, comment.author);
341
+ }
342
+ }
343
+ }
344
+ return out;
345
+ }
346
+ /** True when text references issue number n via #n or a full issue/PR URL. */
347
+ function mentionsIssueNumber(text, n) {
348
+ const bare = text.match(/#(\d{1,6})\b/g) ?? [];
349
+ if (bare.some((r) => Number(r.slice(1)) === n))
350
+ return true;
351
+ return text.includes(`/issues/${n}`) || text.includes(`/pull/${n}`);
352
+ }
353
+ async function defaultFetchPull(owner, repo, n) {
354
+ try {
355
+ const d = (await apiGet(`/repos/${owner}/${repo}/pulls/${n}`));
356
+ return {
357
+ number: Number(d.number ?? n),
358
+ state: d.state === "closed" ? "closed" : "open",
359
+ merged: d.merged_at != null,
360
+ title: String(d.title ?? ""),
361
+ body: String(d.body ?? ""),
362
+ url: String(d.html_url ?? ""),
363
+ };
364
+ }
365
+ catch (e) {
366
+ // A 404 here means the reference was an issue number, not a PR: skip it.
367
+ if (e instanceof ApiNotFoundError)
368
+ return null;
369
+ throw e;
370
+ }
371
+ }
372
+ /**
373
+ * Cross-check PR references found in comments against the PR API.
374
+ *
375
+ * Motivation: reticlehq/reticle#691 got a clean GO verdict while its comments
376
+ * contained a partial claim ("Taking the remaining fallback ask") and a
377
+ * heads-up that PR #861 covers that work. Neither signal existed in the
378
+ * timeline or the issue-number search, so both were missed.
379
+ *
380
+ * All comments are scanned (PR references are cheap to extract), but each
381
+ * result records whether the citing comment came from the issue author.
382
+ * The open-PR downgrade (rule 11) only trusts non-author heads-ups; the
383
+ * partially-addressed rule (rule 12) also reads the author's, because the
384
+ * maintainer narrating what already shipped is usually the issue author.
385
+ * References already found via the timeline/search, and the issue's own
386
+ * number, are skipped. Bounded at 5 PR fetches per call; the walk is
387
+ * best-effort and cached like the rest of the issue data.
388
+ */
389
+ export async function fetchCommentMentionedPRs(owner, repo, issueNumber, comments, knownPRNumbers, deps = {}) {
390
+ // Injected fetch deps (tests) bypass the cache; live calls use it.
391
+ const useCache = !deps.fetchPull;
392
+ if (useCache) {
393
+ const cached = cacheGet("issue", [owner, repo, String(issueNumber), "mentioned-prs"]);
394
+ if (cached)
395
+ return cached;
396
+ }
397
+ const refs = extractPRReferences(comments, owner, repo)
398
+ .filter((n) => n !== issueNumber && !knownPRNumbers.includes(n))
399
+ .slice(0, 5);
400
+ const fetchPull = deps.fetchPull ?? defaultFetchPull;
401
+ const refAuthors = extractPRReferenceAuthors(comments, owner, repo);
402
+ const out = [];
403
+ for (const n of refs) {
404
+ let detail;
405
+ try {
406
+ detail = await fetchPull(owner, repo, n);
407
+ }
408
+ catch {
409
+ break; // best-effort: a failing provider ends the walk, keeps prior results
410
+ }
411
+ if (!detail)
412
+ continue; // it was an issue number, not a PR
413
+ out.push({
414
+ number: detail.number,
415
+ url: detail.url,
416
+ state: detail.merged ? "merged" : detail.state,
417
+ mentionsIssue: mentionsIssueNumber(`${detail.title}\n${detail.body}`, issueNumber),
418
+ ...(deps.issueAuthor && refAuthors.get(detail.number) === deps.issueAuthor
419
+ ? { fromAuthor: true }
420
+ : {}),
421
+ });
422
+ }
423
+ if (useCache)
424
+ cacheSet("issue", [owner, repo, String(issueNumber), "mentioned-prs"], out);
425
+ return out;
426
+ }
427
+ const WORKFLOW_CAP = 12;
428
+ /**
429
+ * List the repo's .github/workflows files and fetch their contents
430
+ * (best-effort, cached). Contents fetch uses raw.githubusercontent, so it
431
+ * does not consume the api.github.com rate limit. Capped at 12 files: a
432
+ * repo with more workflows still shows the signal on the suspiciously
433
+ * named ones first (they are not reordered; name matching catches them).
434
+ */
435
+ export async function listWorkflows(owner, repo, deps = {}) {
436
+ const useCache = !deps.listFiles;
437
+ if (useCache) {
438
+ const cached = cacheGet("workflows", [owner, repo]);
439
+ if (cached)
440
+ return cached;
441
+ }
442
+ let names = [];
443
+ if (deps.listFiles) {
444
+ names = (await deps.listFiles()) ?? [];
445
+ }
446
+ else {
447
+ try {
448
+ const items = (await apiGet(`/repos/${owner}/${repo}/contents/.github/workflows`));
449
+ names = items
450
+ .filter((i) => i.type === "file")
451
+ .map((i) => String(i.name ?? ""));
452
+ }
453
+ catch { /* no workflows dir or empty */ }
454
+ }
455
+ const yaml = names.filter((n) => /\.(ya?ml)$/i.test(n)).slice(0, WORKFLOW_CAP);
456
+ const headers = { "User-Agent": "greenlight-cli" };
457
+ const token = githubToken();
458
+ if (token)
459
+ headers["Authorization"] = `Bearer ${token}`;
460
+ const out = [];
461
+ for (const name of yaml) {
462
+ let content;
463
+ if (deps.fetchRaw) {
464
+ content = await deps.fetchRaw(name);
465
+ }
466
+ else {
467
+ try {
468
+ const res = await fetch(`${RAW}/${owner}/${repo}/HEAD/.github/workflows/${name}`, { headers });
469
+ if (res.ok)
470
+ content = await res.text();
471
+ }
472
+ catch { /* one bad file must not sink the scan */ }
473
+ }
474
+ out.push({ name, content });
475
+ }
476
+ if (useCache)
477
+ cacheSet("workflows", [owner, repo], out);
478
+ return out;
479
+ }
480
+ /** Scan a repo's workflows for auto-close automation; returns detected-only gates. */
481
+ export async function scanWorkflowGates(owner, repo, deps = {}) {
482
+ try {
483
+ return detectWorkflowGates(await listWorkflows(owner, repo, deps));
484
+ }
485
+ catch {
486
+ return [];
487
+ }
488
+ }
489
+ const DUP_URL = "https?:\\/\\/github\\.com\\/[\\w.-]+\\/[\\w.-]+\\/issues\\/(\\d+)";
490
+ const DUPLICATE_COMMENT_RES = [
491
+ /marked as (a )?duplicate of #(\d+)/gi,
492
+ /duplicate of #(\d+)/gi,
493
+ /closed as (a )?duplicate of #(\d+)/gi,
494
+ // "duplicate of https://github.com/<o>/<r>/issues/<n>" — the n8n #41215
495
+ // comment wrote the full URL instead of #40803.
496
+ new RegExp(`marked as (a )?duplicate of ${DUP_URL}`, "gi"),
497
+ new RegExp(`duplicate of ${DUP_URL}`, "gi"),
498
+ new RegExp(`closed as (a )?duplicate of ${DUP_URL}`, "gi"),
499
+ ];
500
+ /**
501
+ * Extract duplicate target issue numbers from comment bodies ("duplicate
502
+ * of #N", GitHub's "This issue has been marked as a duplicate of #N").
503
+ * Pure; tests drive it directly. The last capture group always holds the
504
+ * number.
505
+ */
506
+ export function extractDuplicateTargets(comments) {
507
+ const out = [];
508
+ for (const c of comments) {
509
+ for (const re of DUPLICATE_COMMENT_RES) {
510
+ re.lastIndex = 0;
511
+ let m;
512
+ while ((m = re.exec(c.body)) !== null) {
513
+ const n = Number(m[m.length - 1]);
514
+ if (n > 0 && !out.includes(n))
515
+ out.push(n);
516
+ }
517
+ }
518
+ }
519
+ return out;
520
+ }
521
+ /**
522
+ * Find other issues this one is marked a duplicate of. Signals: "duplicate
523
+ * of #N" comments (n8n's bot posts "marked as a duplicate of #N"), and
524
+ * marked_as_duplicate timeline events when the API surfaces them. Each
525
+ * unique target is fetched for its current state (bounded at 3), because
526
+ * only a target closed as completed means the work already shipped and a
527
+ * build here is wasted.
528
+ */
529
+ export async function fetchDuplicateTargets(owner, repo, number, comments, deps = {}) {
530
+ // The issue's own number ("/attempt #5563" style commands name it) is
531
+ // not a duplicate target.
532
+ const targets = new Set(extractDuplicateTargets(comments).filter((n) => n !== number));
533
+ let events = deps.timelineEvents;
534
+ if (!events) {
535
+ try {
536
+ events = await fetchTimelineEvents(owner, repo, number);
537
+ }
538
+ catch {
539
+ events = [];
540
+ }
541
+ }
542
+ for (const e of events) {
543
+ if (String(e.event) !== "marked_as_duplicate")
544
+ continue;
545
+ const src = e.source;
546
+ const srcIssue = src?.issue;
547
+ const n = Number(srcIssue?.number ?? 0);
548
+ if (n > 0 && n !== number)
549
+ targets.add(n);
550
+ }
551
+ const out = [];
552
+ const fetchIssue = deps.fetchIssue ??
553
+ (async (o, r, n) => {
554
+ try {
555
+ const d = (await apiGet(`/repos/${o}/${r}/issues/${n}`));
556
+ return {
557
+ state: String(d.state ?? ""),
558
+ stateReason: d.state_reason == null ? undefined : String(d.state_reason),
559
+ };
560
+ }
561
+ catch {
562
+ return null;
563
+ }
564
+ });
565
+ for (const n of [...targets].slice(0, 3)) {
566
+ let detail = null;
567
+ try {
568
+ detail = await fetchIssue(owner, repo, n);
569
+ }
570
+ catch {
571
+ continue;
572
+ }
573
+ if (!detail)
574
+ continue;
575
+ out.push({
576
+ number: n,
577
+ url: `https://github.com/${owner}/${repo}/issues/${n}`,
578
+ state: detail.state === "closed" ? "closed" : "open",
579
+ ...(detail.stateReason ? { stateReason: detail.stateReason } : {}),
580
+ });
581
+ }
582
+ return out;
583
+ }
584
+ // Comments that announce work is happening, whose PR URLs are race signals:
585
+ // "/attempt #5563" style commands, and narratives like "Submitted fix for
586
+ // bug #1 ... in https://github.com/<o>/<r>/pull/<n>". A bare PR link
587
+ // posted as prior art (no announcement language) does not qualify.
588
+ const FIX_ANNOUNCE_RES = [
589
+ /^\s*\/(attempt|claim)\b/i,
590
+ /submitted (a |the )?fix/i,
591
+ /opened (a |the )?pr/i,
592
+ /implemented (a |the )?fix/i,
593
+ ];
594
+ const CROSS_REPO_PR_URL_RE = /https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/gi;
595
+ /**
596
+ * Extract PR links from fix-announcement comments. Comments from bots and
597
+ * from the issue author are skipped. The URLs routinely point at sibling
598
+ * repos in the same org (tscircuit/tscircuit#5563's fixes live in
599
+ * tscircuit/core and tscircuit/checks), so they are not limited to the
600
+ * issue's own repo. Pure; tests drive it directly. Bounded at 5 links.
601
+ */
602
+ export function extractAttemptLinkedPRs(comments, issueAuthor) {
603
+ const seen = new Set();
604
+ const out = [];
605
+ for (const c of comments) {
606
+ if (c.author === issueAuthor || isBotAuthor(c.author))
607
+ continue;
608
+ if (!FIX_ANNOUNCE_RES.some((re) => re.test(c.body)))
609
+ continue;
610
+ CROSS_REPO_PR_URL_RE.lastIndex = 0;
611
+ let m;
612
+ while ((m = CROSS_REPO_PR_URL_RE.exec(c.body)) !== null) {
613
+ const url = m[0];
614
+ if (seen.has(url))
615
+ continue;
616
+ seen.add(url);
617
+ out.push({ author: c.author, owner: m[1], repo: m[2], number: Number(m[3]), url });
618
+ }
619
+ }
620
+ return out.slice(0, 5);
621
+ }
622
+ /** Count open issues labelled good-first-issue or help-wanted. */
623
+ export async function countStarterIssues(owner, repo) {
624
+ try {
625
+ const q = encodeURIComponent(`repo:${owner}/${repo} type:issue state:open (label:"good first issue" OR label:"help wanted")`);
626
+ const data = (await apiGet(`/search/issues?q=${q}&per_page=1`));
627
+ return data.total_count ?? null;
628
+ }
629
+ catch {
630
+ return null;
631
+ }
632
+ }
633
+ /** Detect a collaborators-only PR creation policy via the community metrics endpoint. */
634
+ export async function detectRepoPolicy(owner, repo) {
635
+ // Best-effort: no public API exposes pull_request_creation_policy directly,
636
+ // so we treat it as unknown (false) unless evidence appears elsewhere.
637
+ return { collaboratorsOnly: false };
638
+ }
639
+ /** Build a full IssueSnapshot from the live API. */
640
+ export async function buildIssueSnapshot(owner, repo, number) {
641
+ const cached = cacheGet("issue", [owner, repo, String(number), "snapshot"]);
642
+ if (cached)
643
+ return cached;
644
+ const issue = (await apiGet(`/repos/${owner}/${repo}/issues/${number}`));
645
+ if (issue.pull_request) {
646
+ throw new Error(`#${number} is a pull request, not an issue`);
647
+ }
648
+ const issueAuthor = String(issue.user?.login ?? "");
649
+ const [comments, linkedPRs, houseRules, workflowGates] = await Promise.all([
650
+ fetchComments(owner, repo, number),
651
+ fetchLinkedPRs(owner, repo, number),
652
+ fetchHouseRules(owner, repo),
653
+ scanWorkflowGates(owner, repo),
654
+ ]);
655
+ // Comment-mentioned PRs: references like "#861" in comments, cross-checked
656
+ // against the PR API. The open-PR downgrade trusts only non-author
657
+ // heads-ups (see rule 11), but the partially-addressed rule also reads
658
+ // the author's, since the maintainer narrating shipped work is usually
659
+ // the issue author. Best-effort; never blocks the snapshot.
660
+ let commentMentionedPRs = [];
661
+ try {
662
+ commentMentionedPRs = await fetchCommentMentionedPRs(owner, repo, number, comments, linkedPRs.map((p) => p.number), { issueAuthor });
663
+ }
664
+ catch { /* best-effort */ }
665
+ // Attempt-linked PRs: fix-announcement comments ("Submitted fix for bug
666
+ // #1 ... in <PR url>", "/attempt #N" commands) whose PRs often live in
667
+ // sibling repos the timeline and the issue-number search never see.
668
+ // Fetched best-effort (bounded at 5, the extractor's own cap) and merged
669
+ // into commentMentionedPRs with attemptLinked so the verdict engine
670
+ // quotes them as race evidence.
671
+ try {
672
+ const known = new Set([
673
+ ...linkedPRs.map((p) => p.url),
674
+ ...commentMentionedPRs.map((p) => p.url),
675
+ ]);
676
+ for (const link of extractAttemptLinkedPRs(comments, issueAuthor).slice(0, 5)) {
677
+ if (known.has(link.url))
678
+ continue;
679
+ known.add(link.url);
680
+ let detail;
681
+ try {
682
+ detail = await defaultFetchPull(link.owner, link.repo, link.number);
683
+ }
684
+ catch {
685
+ break; // best-effort: one failing provider ends the walk
686
+ }
687
+ if (!detail)
688
+ continue;
689
+ commentMentionedPRs.push({
690
+ number: detail.number,
691
+ url: link.url,
692
+ state: detail.merged ? "merged" : detail.state,
693
+ mentionsIssue: mentionsIssueNumber(`${detail.title}\n${detail.body}`, number),
694
+ attemptLinked: true,
695
+ });
696
+ }
697
+ }
698
+ catch { /* best-effort */ }
699
+ // Duplicate-of targets: "duplicate of #N" comments and marked-as-duplicate
700
+ // timeline signals, each fetched for its live state. Best-effort.
701
+ let duplicateOf = [];
702
+ try {
703
+ duplicateOf = await fetchDuplicateTargets(owner, repo, number, comments);
704
+ }
705
+ catch { /* best-effort */ }
706
+ const snapshot = {
707
+ sourceUrl: `https://github.com/${owner}/${repo}/issues/${number}`,
708
+ owner,
709
+ repo,
710
+ number,
711
+ state: String(issue.state),
712
+ title: String(issue.title ?? ""),
713
+ body: String(issue.body ?? ""),
714
+ author: (issue.user?.login ?? ""),
715
+ assignees: (issue.assignees ?? []).map((a) => a.login),
716
+ labels: (issue.labels ?? []).map((l) => l.name),
717
+ comments,
718
+ linkedPRs,
719
+ commentMentionedPRs,
720
+ createdAt: String(issue.created_at),
721
+ updatedAt: String(issue.updated_at),
722
+ // Joined text kept for compatibility; the verdict engine prefers the
723
+ // per-file houseRules when present so gates record their source file.
724
+ contributing: houseRules.map((r) => r.text).join("\n\n"),
725
+ houseRules,
726
+ workflowGates,
727
+ duplicateOf,
728
+ };
729
+ cacheSet("issue", [owner, repo, String(number), "snapshot"], snapshot);
730
+ return snapshot;
731
+ }
732
+ /** List open good-first-issue / help-wanted issues for scan mode. */
733
+ export async function listStarterIssues(owner, repo, limit = 25) {
734
+ const q = encodeURIComponent(`repo:${owner}/${repo} type:issue state:open no:assignee (label:"good first issue" OR label:"help wanted")`);
735
+ const data = (await apiGet(`/search/issues?q=${q}&per_page=${limit}&sort=updated&order=desc`));
736
+ return (data.items ?? []).map((i) => ({
737
+ number: i.number,
738
+ title: i.title,
739
+ url: i.html_url,
740
+ labels: i.labels.map((l) => l.name),
741
+ updatedAt: i.updated_at,
742
+ }));
743
+ }
744
+ /**
745
+ * Filter raw issue-list items down to scan candidates: real issues (not
746
+ * pull requests) with no assignees, newest first. Pure so tests can drive
747
+ * it with fixtures.
748
+ */
749
+ export function toScanCandidates(items) {
750
+ return items
751
+ .filter((i) => !i.pull_request && (i.assignees ?? []).length === 0)
752
+ .map((i) => ({
753
+ number: i.number,
754
+ title: i.title,
755
+ url: i.html_url,
756
+ labels: i.labels.map((l) => l.name),
757
+ updatedAt: i.updated_at,
758
+ }));
759
+ }
760
+ /**
761
+ * Fallback candidate list for scan mode: the most recent open unassigned
762
+ * issues in the repo, for repos whose landable issues do not carry
763
+ * good-first-issue / help-wanted labels. Lists broadly (up to `cap`) so
764
+ * the caller can take the newest slice.
765
+ */
766
+ export async function listRecentUnassignedIssues(owner, repo, cap = 25) {
767
+ const items = await paginate(`/repos/${owner}/${repo}/issues?state=open&sort=created&direction=desc`, 100, 3);
768
+ return toScanCandidates(items).slice(0, cap);
769
+ }
770
+ /**
771
+ * Scan candidates for a repo: the label-tagged starter list first; when it
772
+ * is empty, fall back to recent open unassigned issues (opt out with
773
+ * fallback: false). Injectable deps keep tests offline.
774
+ */
775
+ export async function scanCandidates(owner, repo, opts = {}, deps = {}) {
776
+ const limit = opts.limit ?? 10;
777
+ const starter = deps.starter ?? listStarterIssues;
778
+ const labeled = await starter(owner, repo, limit);
779
+ if (labeled.length > 0)
780
+ return { issues: labeled, fallbackUsed: false };
781
+ if (opts.fallback === false)
782
+ return { issues: [], fallbackUsed: false };
783
+ const recent = deps.recent ?? listRecentUnassignedIssues;
784
+ const issues = (await recent(owner, repo, 25)).slice(0, limit);
785
+ return { issues, fallbackUsed: true };
786
+ }
787
+ //# sourceMappingURL=github.js.map