@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/LICENSE +21 -0
- package/README.md +213 -2
- package/dist/automate.d.ts +184 -0
- package/dist/automate.js +637 -0
- package/dist/automate.js.map +1 -0
- package/dist/claims.d.ts +20 -0
- package/dist/claims.js +117 -0
- package/dist/claims.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +337 -0
- package/dist/cli.js.map +1 -0
- package/dist/friction-index.d.ts +1 -0
- package/dist/friction-index.js +152 -0
- package/dist/friction-index.js.map +1 -0
- package/dist/friction.d.ts +30 -0
- package/dist/friction.js +56 -0
- package/dist/friction.js.map +1 -0
- package/dist/gates.d.ts +65 -0
- package/dist/gates.js +256 -0
- package/dist/gates.js.map +1 -0
- package/dist/github.d.ts +213 -0
- package/dist/github.js +787 -0
- package/dist/github.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +173 -0
- package/dist/index.js.map +1 -0
- package/dist/linkstrength.d.ts +27 -0
- package/dist/linkstrength.js +25 -0
- package/dist/linkstrength.js.map +1 -0
- package/dist/types.d.ts +169 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +81 -0
- package/dist/verdict.js +272 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +39 -3
- package/server.json +28 -0
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
|