@reneturcios/greenlight 0.17.1 → 0.19.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 +20 -8
- package/dist/behavior.d.ts +13 -3
- package/dist/behavior.js +41 -10
- package/dist/behavior.js.map +1 -1
- package/dist/bounty.d.ts +8 -0
- package/dist/bounty.js +15 -0
- package/dist/bounty.js.map +1 -1
- package/dist/cli.js +131 -8
- package/dist/cli.js.map +1 -1
- package/dist/friction-index.js +13 -4
- package/dist/friction-index.js.map +1 -1
- package/dist/friction.d.ts +17 -2
- package/dist/friction.js +67 -19
- package/dist/friction.js.map +1 -1
- package/dist/github.d.ts +131 -2
- package/dist/github.js +235 -42
- package/dist/github.js.map +1 -1
- package/dist/help.js +19 -6
- package/dist/help.js.map +1 -1
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/merge-velocity.js +5 -3
- package/dist/merge-velocity.js.map +1 -1
- package/dist/types.d.ts +52 -3
- package/dist/verdict.d.ts +16 -1
- package/dist/verdict.js +36 -2
- package/dist/verdict.js.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
package/dist/github.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IssueSnapshot, LinkedPR, CommentSignal, GateKind, Gate, MentionedPR, DuplicateTarget } from "./types.js";
|
|
1
|
+
import type { IssueSnapshot, LinkedPR, CommentSignal, GateKind, Gate, MentionedPR, DuplicateTarget, PolicySourceProvenance } from "./types.js";
|
|
2
2
|
import { type WorkflowFile } from "./gates.js";
|
|
3
3
|
/**
|
|
4
4
|
* The GitHub API base in use. Reads GREENLIGHT_GITHUB_BASE_URL so a
|
|
@@ -44,6 +44,24 @@ export declare function apiCallsUsed(): number;
|
|
|
44
44
|
/** Reset the call counter (tests). */
|
|
45
45
|
export declare function resetApiCalls(): void;
|
|
46
46
|
export declare function apiGet(path: string): Promise<unknown>;
|
|
47
|
+
export interface CanonicalRepo {
|
|
48
|
+
owner: string;
|
|
49
|
+
repo: string;
|
|
50
|
+
/** "owner/repo" as GitHub knows it today. */
|
|
51
|
+
fullName: string;
|
|
52
|
+
/** True when the input differed from the canonical name (a rename redirect). */
|
|
53
|
+
renamed: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a repo to its canonical name (v0.18.0, item 15). GitHub's
|
|
57
|
+
* repo endpoint follows rename redirects, so dbt-labs/dbt-core comes
|
|
58
|
+
* back as dbt-labs/dbt. Search-dependent flows (scan, wave starter
|
|
59
|
+
* counts, friction) 422 or return nothing on the old name, so every
|
|
60
|
+
* entry point resolves before searching. Failures keep the input
|
|
61
|
+
* name: a genuinely missing repo fails downstream with its real
|
|
62
|
+
* error, never with a rename-resolution one.
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolveCanonicalRepo(owner: string, repo: string): Promise<CanonicalRepo>;
|
|
47
65
|
/** POST helper for write operations (automation commands). Read paths use apiGet. */
|
|
48
66
|
export declare function apiPost(path: string, body: unknown): Promise<unknown>;
|
|
49
67
|
export interface IssueURL {
|
|
@@ -87,11 +105,31 @@ export interface MentionedPRDeps {
|
|
|
87
105
|
*/
|
|
88
106
|
export declare const HOUSE_RULE_FILES: readonly ["CONTRIBUTING.md", ".github/CONTRIBUTING.md", "docs/CONTRIBUTING.md", "contributing.md", "docs/development/contributing.mdx", "docs/development/contributing.md", "AGENTS.md", ".github/AGENTS.md", "docs/AGENTS.md", "CLAUDE.md", ".github/CLAUDE.md", "docs/CLAUDE.md", "AI_POLICY.md", ".github/AI_POLICY.md", "docs/AI_POLICY.md"];
|
|
89
107
|
export interface HouseRuleDeps {
|
|
90
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Test seam: fetch a repo-root file's raw text, or undefined when absent.
|
|
110
|
+
* v0.19.0: a fetchRaw that throws surfaces as a provenance "error" (with
|
|
111
|
+
* the error as the kind string), never as "absent".
|
|
112
|
+
*/
|
|
91
113
|
fetchRaw?: (file: string) => Promise<string | undefined>;
|
|
92
114
|
/** Test seam: fetch the org's AI_POLICY.md text, or undefined when absent. */
|
|
93
115
|
fetchOrgPolicy?: (owner: string) => Promise<string | undefined>;
|
|
94
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* v0.19.0: the detailed outcome of one raw policy-file fetch. The old
|
|
119
|
+
* fetchRawFile collapsed "no such file" and "the fetch failed" into the
|
|
120
|
+
* same undefined, so friction could not tell a repo with no policy from
|
|
121
|
+
* a repo whose policy read broke. "error" carries a short kind string
|
|
122
|
+
* (e.g. "rate limited", "http 500", "network error").
|
|
123
|
+
*/
|
|
124
|
+
export type RawFileFetchStatus = {
|
|
125
|
+
status: "read";
|
|
126
|
+
text: string;
|
|
127
|
+
} | {
|
|
128
|
+
status: "absent";
|
|
129
|
+
} | {
|
|
130
|
+
status: "error";
|
|
131
|
+
error: string;
|
|
132
|
+
};
|
|
95
133
|
/**
|
|
96
134
|
* v0.9.3: org-level AI policy. Binding agent rules sometimes live in the
|
|
97
135
|
* org's `.github` repo rather than the project repo (astral-sh's
|
|
@@ -99,17 +137,77 @@ export interface HouseRuleDeps {
|
|
|
99
137
|
* CONTRIBUTING only links to mastodon/.github's AI_POLICY.md). Probed
|
|
100
138
|
* once per org and cached under its own key so a sweep over N repos from
|
|
101
139
|
* one org costs one fetch, not N.
|
|
140
|
+
*
|
|
141
|
+
* v0.19.0: the cache entry now carries the full detailed outcome
|
|
142
|
+
* (read/absent/error); entries written before v0.19.0 (a bare text or
|
|
143
|
+
* empty string) are treated as a miss and re-fetched.
|
|
102
144
|
*/
|
|
145
|
+
export declare function fetchOrgAiPolicyDetailed(owner: string, deps?: HouseRuleDeps): Promise<{
|
|
146
|
+
file: string;
|
|
147
|
+
outcome: RawFileFetchStatus;
|
|
148
|
+
}>;
|
|
149
|
+
/** Org-level AI policy text, or undefined when absent or unreadable. */
|
|
103
150
|
export declare function fetchOrgAiPolicy(owner: string, deps?: HouseRuleDeps): Promise<string | undefined>;
|
|
104
151
|
/**
|
|
105
152
|
* v0.9.3: the full policy source set for gate detection: house-rules
|
|
106
153
|
* files (first hit per filename across root, .github/, docs/) plus the
|
|
107
154
|
* org-level AI_POLICY.md when the org publishes one.
|
|
155
|
+
*
|
|
156
|
+
* v0.19.0: fetchPolicySourcesWithProvenance adds the read provenance
|
|
157
|
+
* friction reports; this plain variant delegates and returns the sources
|
|
158
|
+
* alone, so its callers do not change.
|
|
108
159
|
*/
|
|
160
|
+
export declare function fetchPolicySourcesWithProvenance(owner: string, repo: string, deps?: HouseRuleDeps): Promise<{
|
|
161
|
+
sources: {
|
|
162
|
+
file: string;
|
|
163
|
+
text: string;
|
|
164
|
+
}[];
|
|
165
|
+
provenance: PolicySourceProvenance[];
|
|
166
|
+
}>;
|
|
109
167
|
export declare function fetchPolicySources(owner: string, repo: string, deps?: HouseRuleDeps): Promise<{
|
|
110
168
|
file: string;
|
|
111
169
|
text: string;
|
|
112
170
|
}[]>;
|
|
171
|
+
/**
|
|
172
|
+
* v0.9.3: fetch one policy file's raw text, honoring the API base
|
|
173
|
+
* override. In proxy mode (GREENLIGHT_GITHUB_BASE_URL set) the raw host
|
|
174
|
+
* is unreachable with the surrogate credential, so policy files are read
|
|
175
|
+
* through the contents API instead; otherwise the raw host is used as
|
|
176
|
+
* before.
|
|
177
|
+
*
|
|
178
|
+
* v0.19.0: fetchRawFileDetailed distinguishes three outcomes: read,
|
|
179
|
+
* absent (the file genuinely is not there), and error (the fetch
|
|
180
|
+
* failed). fetchRawFile stays as a thin wrapper that returns the text
|
|
181
|
+
* only when the fetch read, so its callers do not change.
|
|
182
|
+
*/
|
|
183
|
+
export declare function fetchRawFileDetailed(owner: string, repo: string, file: string, headers: Record<string, string>): Promise<RawFileFetchStatus>;
|
|
184
|
+
/**
|
|
185
|
+
* Fetch all existing house-rules files for a repo (best-effort, cached),
|
|
186
|
+
* with per-basename read provenance.
|
|
187
|
+
*
|
|
188
|
+
* v0.9.3: stop at the first hit per filename (root, then .github/, then
|
|
189
|
+
* docs/), so a repo with CONTRIBUTING.md at the root does not pay for
|
|
190
|
+
* two more 404s, and a repo with it only in docs/ is still found (the
|
|
191
|
+
* AFFiNE shape the 5k scan caught: root-only probing called it
|
|
192
|
+
* "no policy" while docs/CONTRIBUTING.md existed).
|
|
193
|
+
*
|
|
194
|
+
* v0.19.0: provenance carries ONE entry per basename probed: the path
|
|
195
|
+
* that was read (status "read"), the first candidate path with status
|
|
196
|
+
* "absent" when every location missed, or status "error" with the first
|
|
197
|
+
* error kind when a fetch failed before any hit. An error at one
|
|
198
|
+
* location does not stop the later locations: the read still wins when
|
|
199
|
+
* a later location has the file.
|
|
200
|
+
*
|
|
201
|
+
* The cache payload is { sources, provenance }; entries written before
|
|
202
|
+
* v0.19.0 (a bare sources array) count as a miss and are re-fetched.
|
|
203
|
+
*/
|
|
204
|
+
export declare function fetchHouseRulesDetailed(owner: string, repo: string, deps?: HouseRuleDeps): Promise<{
|
|
205
|
+
sources: {
|
|
206
|
+
file: string;
|
|
207
|
+
text: string;
|
|
208
|
+
}[];
|
|
209
|
+
provenance: PolicySourceProvenance[];
|
|
210
|
+
}>;
|
|
113
211
|
/** Fetch all existing house-rules files for a repo (best-effort, cached). */
|
|
114
212
|
export declare function fetchHouseRules(owner: string, repo: string, deps?: HouseRuleDeps): Promise<{
|
|
115
213
|
file: string;
|
|
@@ -227,6 +325,23 @@ export declare function extractTimelineCrossRefPRs(events: Record<string, unknow
|
|
|
227
325
|
}>;
|
|
228
326
|
/** Count open issues labelled good-first-issue or help-wanted. */
|
|
229
327
|
export declare function countStarterIssues(owner: string, repo: string): Promise<number | null>;
|
|
328
|
+
/**
|
|
329
|
+
* v0.19.0 (item 16): the wider starter label set for the candidate
|
|
330
|
+
* estimate. Repos that never adopted the classic "good first issue" /
|
|
331
|
+
* "help wanted" pair often label starter work one of these instead.
|
|
332
|
+
*/
|
|
333
|
+
export declare const STARTER_CANDIDATE_LABELS: readonly ["beginner friendly", "beginner", "easy", "starter", "first-timers-only", "up-for-grabs", "good-first-issue"];
|
|
334
|
+
export interface StarterSearchDeps {
|
|
335
|
+
/** Test seam: run a search query, return its total_count or null. */
|
|
336
|
+
searchCount?: (query: string) => Promise<number | null>;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Fallback starter-candidate estimate, used only when the classic
|
|
340
|
+
* label count is exactly 0. One search call: open, unassigned issues
|
|
341
|
+
* under the wider starter label set. An estimate for display, never a
|
|
342
|
+
* score input; null when the search itself fails.
|
|
343
|
+
*/
|
|
344
|
+
export declare function countStarterCandidates(owner: string, repo: string, deps?: StarterSearchDeps): Promise<number | null>;
|
|
230
345
|
/** Detect a collaborators-only PR creation policy via the community metrics endpoint. */
|
|
231
346
|
export declare function detectRepoPolicy(owner: string, repo: string): Promise<{
|
|
232
347
|
collaboratorsOnly: boolean;
|
|
@@ -274,6 +389,20 @@ export interface ScanCandidateList {
|
|
|
274
389
|
/** True when the label pass came back empty and the fallback list was used. */
|
|
275
390
|
fallbackUsed: boolean;
|
|
276
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Default wall-time bound for `greenlight scan`, in seconds (v0.19.0,
|
|
394
|
+
* item 17): on mega-backlog repos the per-issue verdict pipeline ran
|
|
395
|
+
* about 25 minutes, so the scan stops with marked partial results when
|
|
396
|
+
* the budget runs out instead of running open-ended. Override per run
|
|
397
|
+
* with --max-time <seconds>.
|
|
398
|
+
*/
|
|
399
|
+
export declare const DEFAULT_SCAN_MAX_TIME_SEC = 600;
|
|
400
|
+
/**
|
|
401
|
+
* Pure wall-time check for the scan loop (v0.19.0, item 17): true once
|
|
402
|
+
* the elapsed time since startedAtMs has reached the budget. Call with
|
|
403
|
+
* Date.now() for the nowMs argument. Exported for unit tests.
|
|
404
|
+
*/
|
|
405
|
+
export declare function scanBudgetExceeded(startedAtMs: number, maxTimeSec: number, nowMs: number): boolean;
|
|
277
406
|
export interface ScanDeps {
|
|
278
407
|
starter?: (owner: string, repo: string, limit: number) => Promise<ScanCandidate[]>;
|
|
279
408
|
recent?: (owner: string, repo: string, cap: number) => Promise<ScanCandidate[]>;
|
package/dist/github.js
CHANGED
|
@@ -194,6 +194,30 @@ export async function apiGet(path) {
|
|
|
194
194
|
}
|
|
195
195
|
return res.json();
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve a repo to its canonical name (v0.18.0, item 15). GitHub's
|
|
199
|
+
* repo endpoint follows rename redirects, so dbt-labs/dbt-core comes
|
|
200
|
+
* back as dbt-labs/dbt. Search-dependent flows (scan, wave starter
|
|
201
|
+
* counts, friction) 422 or return nothing on the old name, so every
|
|
202
|
+
* entry point resolves before searching. Failures keep the input
|
|
203
|
+
* name: a genuinely missing repo fails downstream with its real
|
|
204
|
+
* error, never with a rename-resolution one.
|
|
205
|
+
*/
|
|
206
|
+
export async function resolveCanonicalRepo(owner, repo) {
|
|
207
|
+
const input = { owner, repo, fullName: `${owner}/${repo}`, renamed: false };
|
|
208
|
+
try {
|
|
209
|
+
const data = (await apiGet(`/repos/${owner}/${repo}`));
|
|
210
|
+
const full = String(data.full_name ?? "");
|
|
211
|
+
const m = /^([^/]+)\/([^/]+)$/.exec(full);
|
|
212
|
+
if (m && (m[1] !== owner || m[2] !== repo)) {
|
|
213
|
+
return { owner: m[1], repo: m[2], fullName: full, renamed: true };
|
|
214
|
+
}
|
|
215
|
+
return { owner, repo, fullName: full || input.fullName, renamed: false };
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return input;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
197
221
|
/** POST helper for write operations (automation commands). Read paths use apiGet. */
|
|
198
222
|
export async function apiPost(path, body) {
|
|
199
223
|
const headers = {
|
|
@@ -376,6 +400,17 @@ export const HOUSE_RULE_FILES = [
|
|
|
376
400
|
".github/AI_POLICY.md",
|
|
377
401
|
"docs/AI_POLICY.md",
|
|
378
402
|
];
|
|
403
|
+
/** Short kind string for a policy-file fetch failure (never the full stack). */
|
|
404
|
+
function classifyFetchError(err) {
|
|
405
|
+
if (err instanceof RateLimitError)
|
|
406
|
+
return "rate limited";
|
|
407
|
+
if (err instanceof RequestTimeoutError)
|
|
408
|
+
return "timeout";
|
|
409
|
+
if (err instanceof Error && err.message) {
|
|
410
|
+
return err.message.length > 120 ? err.message.slice(0, 120) : err.message;
|
|
411
|
+
}
|
|
412
|
+
return "fetch error";
|
|
413
|
+
}
|
|
379
414
|
/**
|
|
380
415
|
* v0.9.3: org-level AI policy. Binding agent rules sometimes live in the
|
|
381
416
|
* org's `.github` repo rather than the project repo (astral-sh's
|
|
@@ -383,95 +418,203 @@ export const HOUSE_RULE_FILES = [
|
|
|
383
418
|
* CONTRIBUTING only links to mastodon/.github's AI_POLICY.md). Probed
|
|
384
419
|
* once per org and cached under its own key so a sweep over N repos from
|
|
385
420
|
* one org costs one fetch, not N.
|
|
421
|
+
*
|
|
422
|
+
* v0.19.0: the cache entry now carries the full detailed outcome
|
|
423
|
+
* (read/absent/error); entries written before v0.19.0 (a bare text or
|
|
424
|
+
* empty string) are treated as a miss and re-fetched.
|
|
386
425
|
*/
|
|
387
|
-
export async function
|
|
426
|
+
export async function fetchOrgAiPolicyDetailed(owner, deps = {}) {
|
|
427
|
+
const file = `AI_POLICY.md (org ${owner})`;
|
|
388
428
|
if (!deps.fetchOrgPolicy) {
|
|
389
429
|
const cached = cacheGet("org-ai-policy", [owner.toLowerCase()]);
|
|
390
|
-
if (cached !== undefined)
|
|
391
|
-
return cached
|
|
430
|
+
if (cached !== undefined && typeof cached === "object")
|
|
431
|
+
return { file, outcome: cached };
|
|
392
432
|
}
|
|
393
433
|
const headers = { "User-Agent": "greenlight-cli" };
|
|
394
434
|
const token = githubToken();
|
|
395
435
|
if (token)
|
|
396
436
|
headers["Authorization"] = `Bearer ${token}`;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
437
|
+
let outcome;
|
|
438
|
+
if (deps.fetchOrgPolicy) {
|
|
439
|
+
try {
|
|
440
|
+
const text = await deps.fetchOrgPolicy(owner);
|
|
441
|
+
outcome = text ? { status: "read", text } : { status: "absent" };
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
outcome =
|
|
445
|
+
err instanceof ApiNotFoundError
|
|
446
|
+
? { status: "absent" }
|
|
447
|
+
: { status: "error", error: classifyFetchError(err) };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
outcome = await fetchRawFileDetailed(owner, ".github", "AI_POLICY.md", headers);
|
|
452
|
+
}
|
|
400
453
|
if (!deps.fetchOrgPolicy)
|
|
401
|
-
cacheSet("org-ai-policy", [owner.toLowerCase()],
|
|
402
|
-
return
|
|
454
|
+
cacheSet("org-ai-policy", [owner.toLowerCase()], outcome);
|
|
455
|
+
return { file, outcome };
|
|
456
|
+
}
|
|
457
|
+
/** Org-level AI policy text, or undefined when absent or unreadable. */
|
|
458
|
+
export async function fetchOrgAiPolicy(owner, deps = {}) {
|
|
459
|
+
const { outcome } = await fetchOrgAiPolicyDetailed(owner, deps);
|
|
460
|
+
return outcome.status === "read" ? outcome.text : undefined;
|
|
403
461
|
}
|
|
404
462
|
/**
|
|
405
463
|
* v0.9.3: the full policy source set for gate detection: house-rules
|
|
406
464
|
* files (first hit per filename across root, .github/, docs/) plus the
|
|
407
465
|
* org-level AI_POLICY.md when the org publishes one.
|
|
466
|
+
*
|
|
467
|
+
* v0.19.0: fetchPolicySourcesWithProvenance adds the read provenance
|
|
468
|
+
* friction reports; this plain variant delegates and returns the sources
|
|
469
|
+
* alone, so its callers do not change.
|
|
408
470
|
*/
|
|
471
|
+
export async function fetchPolicySourcesWithProvenance(owner, repo, deps = {}) {
|
|
472
|
+
const [{ sources, provenance }, { file, outcome }] = await Promise.all([
|
|
473
|
+
fetchHouseRulesDetailed(owner, repo, deps),
|
|
474
|
+
fetchOrgAiPolicyDetailed(owner, deps),
|
|
475
|
+
]);
|
|
476
|
+
if (outcome.status === "read")
|
|
477
|
+
sources.push({ file, text: outcome.text });
|
|
478
|
+
provenance.push(outcome.status === "read"
|
|
479
|
+
? { file, scope: "org", status: "read" }
|
|
480
|
+
: outcome.status === "error"
|
|
481
|
+
? { file, scope: "org", status: "error", error: outcome.error }
|
|
482
|
+
: { file, scope: "org", status: "absent" });
|
|
483
|
+
return { sources, provenance };
|
|
484
|
+
}
|
|
409
485
|
export async function fetchPolicySources(owner, repo, deps = {}) {
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
if (orgPolicy)
|
|
413
|
-
rules.push({ file: `AI_POLICY.md (org ${owner})`, text: orgPolicy });
|
|
414
|
-
return rules;
|
|
486
|
+
const { sources } = await fetchPolicySourcesWithProvenance(owner, repo, deps);
|
|
487
|
+
return sources;
|
|
415
488
|
}
|
|
416
489
|
/**
|
|
417
490
|
* v0.9.3: fetch one policy file's raw text, honoring the API base
|
|
418
491
|
* override. In proxy mode (GREENLIGHT_GITHUB_BASE_URL set) the raw host
|
|
419
492
|
* is unreachable with the surrogate credential, so policy files are read
|
|
420
493
|
* through the contents API instead; otherwise the raw host is used as
|
|
421
|
-
* before.
|
|
494
|
+
* before.
|
|
495
|
+
*
|
|
496
|
+
* v0.19.0: fetchRawFileDetailed distinguishes three outcomes: read,
|
|
497
|
+
* absent (the file genuinely is not there), and error (the fetch
|
|
498
|
+
* failed). fetchRawFile stays as a thin wrapper that returns the text
|
|
499
|
+
* only when the fetch read, so its callers do not change.
|
|
422
500
|
*/
|
|
423
|
-
async function
|
|
501
|
+
export async function fetchRawFileDetailed(owner, repo, file, headers) {
|
|
424
502
|
if (githubApiBase() !== DEFAULT_API) {
|
|
425
503
|
try {
|
|
426
504
|
const data = (await apiGet(`/repos/${owner}/${repo}/contents/${file}`));
|
|
427
505
|
if (typeof data.content === "string" && data.encoding === "base64") {
|
|
428
|
-
return Buffer.from(data.content, "base64").toString("utf8");
|
|
506
|
+
return { status: "read", text: Buffer.from(data.content, "base64").toString("utf8") };
|
|
429
507
|
}
|
|
508
|
+
return { status: "absent" };
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
if (err instanceof ApiNotFoundError)
|
|
512
|
+
return { status: "absent" };
|
|
513
|
+
return { status: "error", error: classifyFetchError(err) };
|
|
430
514
|
}
|
|
431
|
-
catch { /* absent */ }
|
|
432
|
-
return undefined;
|
|
433
515
|
}
|
|
434
516
|
try {
|
|
435
|
-
const res = await
|
|
517
|
+
const res = await activeFetch()(`${RAW}/${owner}/${repo}/HEAD/${file}`, { headers });
|
|
436
518
|
if (res.ok)
|
|
437
|
-
return await res.text();
|
|
519
|
+
return { status: "read", text: await res.text() };
|
|
520
|
+
if (res.status === 404)
|
|
521
|
+
return { status: "absent" };
|
|
522
|
+
return { status: "error", error: `http ${res.status}` };
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
return { status: "error", error: classifyFetchError(err) };
|
|
438
526
|
}
|
|
439
|
-
catch { /* try next */ }
|
|
440
|
-
return undefined;
|
|
441
527
|
}
|
|
442
|
-
/**
|
|
443
|
-
|
|
528
|
+
/** Returns the file's text when the fetch reads it, otherwise undefined. */
|
|
529
|
+
async function fetchRawFile(owner, repo, file, headers) {
|
|
530
|
+
const outcome = await fetchRawFileDetailed(owner, repo, file, headers);
|
|
531
|
+
return outcome.status === "read" ? outcome.text : undefined;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Fetch all existing house-rules files for a repo (best-effort, cached),
|
|
535
|
+
* with per-basename read provenance.
|
|
536
|
+
*
|
|
537
|
+
* v0.9.3: stop at the first hit per filename (root, then .github/, then
|
|
538
|
+
* docs/), so a repo with CONTRIBUTING.md at the root does not pay for
|
|
539
|
+
* two more 404s, and a repo with it only in docs/ is still found (the
|
|
540
|
+
* AFFiNE shape the 5k scan caught: root-only probing called it
|
|
541
|
+
* "no policy" while docs/CONTRIBUTING.md existed).
|
|
542
|
+
*
|
|
543
|
+
* v0.19.0: provenance carries ONE entry per basename probed: the path
|
|
544
|
+
* that was read (status "read"), the first candidate path with status
|
|
545
|
+
* "absent" when every location missed, or status "error" with the first
|
|
546
|
+
* error kind when a fetch failed before any hit. An error at one
|
|
547
|
+
* location does not stop the later locations: the read still wins when
|
|
548
|
+
* a later location has the file.
|
|
549
|
+
*
|
|
550
|
+
* The cache payload is { sources, provenance }; entries written before
|
|
551
|
+
* v0.19.0 (a bare sources array) count as a miss and are re-fetched.
|
|
552
|
+
*/
|
|
553
|
+
export async function fetchHouseRulesDetailed(owner, repo, deps = {}) {
|
|
444
554
|
if (!deps.fetchRaw) {
|
|
445
555
|
const cached = cacheGet("house-rules", [owner, repo]);
|
|
446
|
-
if (cached)
|
|
447
|
-
return cached;
|
|
556
|
+
if (cached && !Array.isArray(cached) && Array.isArray(cached.provenance)) {
|
|
557
|
+
return { sources: cached.sources, provenance: cached.provenance };
|
|
558
|
+
}
|
|
448
559
|
}
|
|
449
560
|
const headers = { "User-Agent": "greenlight-cli" };
|
|
450
561
|
const token = githubToken();
|
|
451
562
|
if (token)
|
|
452
563
|
headers["Authorization"] = `Bearer ${token}`;
|
|
453
|
-
const
|
|
454
|
-
(
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
564
|
+
const fetchOne = async (file) => {
|
|
565
|
+
if (deps.fetchRaw) {
|
|
566
|
+
try {
|
|
567
|
+
const text = await deps.fetchRaw(file);
|
|
568
|
+
return text ? { status: "read", text } : { status: "absent" };
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
if (err instanceof ApiNotFoundError)
|
|
572
|
+
return { status: "absent" };
|
|
573
|
+
return { status: "error", error: classifyFetchError(err) };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return fetchRawFileDetailed(owner, repo, file, headers);
|
|
577
|
+
};
|
|
578
|
+
const sources = [];
|
|
579
|
+
const provenance = [];
|
|
462
580
|
for (const file of HOUSE_RULE_FILES) {
|
|
463
581
|
const base = file.split("/").pop().toLowerCase();
|
|
464
|
-
if (
|
|
582
|
+
if (provenance.some((p) => p.file.split("/").pop().toLowerCase() === base && p.scope === "repo")) {
|
|
465
583
|
continue;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
584
|
+
}
|
|
585
|
+
let readAt;
|
|
586
|
+
let firstError;
|
|
587
|
+
for (const candidate of HOUSE_RULE_FILES) {
|
|
588
|
+
if (candidate.split("/").pop().toLowerCase() !== base)
|
|
589
|
+
continue;
|
|
590
|
+
const outcome = await fetchOne(candidate);
|
|
591
|
+
if (outcome.status === "read") {
|
|
592
|
+
readAt = { file: candidate, text: outcome.text };
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
if (outcome.status === "error" && !firstError) {
|
|
596
|
+
firstError = { file: candidate, error: outcome.error };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (readAt) {
|
|
600
|
+
sources.push(readAt);
|
|
601
|
+
provenance.push({ file: readAt.file, scope: "repo", status: "read" });
|
|
602
|
+
}
|
|
603
|
+
else if (firstError) {
|
|
604
|
+
provenance.push({ file: firstError.file, scope: "repo", status: "error", error: firstError.error });
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
provenance.push({ file, scope: "repo", status: "absent" });
|
|
470
608
|
}
|
|
471
609
|
}
|
|
472
610
|
if (!deps.fetchRaw)
|
|
473
|
-
cacheSet("house-rules", [owner, repo],
|
|
474
|
-
return
|
|
611
|
+
cacheSet("house-rules", [owner, repo], { sources, provenance });
|
|
612
|
+
return { sources, provenance };
|
|
613
|
+
}
|
|
614
|
+
/** Fetch all existing house-rules files for a repo (best-effort, cached). */
|
|
615
|
+
export async function fetchHouseRules(owner, repo, deps = {}) {
|
|
616
|
+
const { sources } = await fetchHouseRulesDetailed(owner, repo, deps);
|
|
617
|
+
return sources;
|
|
475
618
|
}
|
|
476
619
|
/** Fetch CONTRIBUTING.md raw text; returns undefined when the repo has none. */
|
|
477
620
|
export async function fetchContributing(owner, repo) {
|
|
@@ -864,6 +1007,40 @@ export async function countStarterIssues(owner, repo) {
|
|
|
864
1007
|
return null;
|
|
865
1008
|
}
|
|
866
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* v0.19.0 (item 16): the wider starter label set for the candidate
|
|
1012
|
+
* estimate. Repos that never adopted the classic "good first issue" /
|
|
1013
|
+
* "help wanted" pair often label starter work one of these instead.
|
|
1014
|
+
*/
|
|
1015
|
+
export const STARTER_CANDIDATE_LABELS = [
|
|
1016
|
+
"beginner friendly",
|
|
1017
|
+
"beginner",
|
|
1018
|
+
"easy",
|
|
1019
|
+
"starter",
|
|
1020
|
+
"first-timers-only",
|
|
1021
|
+
"up-for-grabs",
|
|
1022
|
+
"good-first-issue",
|
|
1023
|
+
];
|
|
1024
|
+
/**
|
|
1025
|
+
* Fallback starter-candidate estimate, used only when the classic
|
|
1026
|
+
* label count is exactly 0. One search call: open, unassigned issues
|
|
1027
|
+
* under the wider starter label set. An estimate for display, never a
|
|
1028
|
+
* score input; null when the search itself fails.
|
|
1029
|
+
*/
|
|
1030
|
+
export async function countStarterCandidates(owner, repo, deps = {}) {
|
|
1031
|
+
const labels = STARTER_CANDIDATE_LABELS.map((l) => `"${l}"`).join(",");
|
|
1032
|
+
const query = `repo:${owner}/${repo} type:issue state:open no:assignee label:${labels}`;
|
|
1033
|
+
if (deps.searchCount)
|
|
1034
|
+
return deps.searchCount(query);
|
|
1035
|
+
try {
|
|
1036
|
+
const q = encodeURIComponent(query);
|
|
1037
|
+
const data = (await apiGet(`/search/issues?q=${q}&per_page=1`));
|
|
1038
|
+
return data.total_count ?? null;
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
867
1044
|
/** Detect a collaborators-only PR creation policy via the community metrics endpoint. */
|
|
868
1045
|
export async function detectRepoPolicy(owner, repo) {
|
|
869
1046
|
// Best-effort: no public API exposes pull_request_creation_policy directly,
|
|
@@ -1018,6 +1195,22 @@ export async function listRecentUnassignedIssues(owner, repo, cap = 25) {
|
|
|
1018
1195
|
const items = await paginate(`/repos/${owner}/${repo}/issues?state=open&sort=created&direction=desc`, 100, 3);
|
|
1019
1196
|
return toScanCandidates(items).slice(0, cap);
|
|
1020
1197
|
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Default wall-time bound for `greenlight scan`, in seconds (v0.19.0,
|
|
1200
|
+
* item 17): on mega-backlog repos the per-issue verdict pipeline ran
|
|
1201
|
+
* about 25 minutes, so the scan stops with marked partial results when
|
|
1202
|
+
* the budget runs out instead of running open-ended. Override per run
|
|
1203
|
+
* with --max-time <seconds>.
|
|
1204
|
+
*/
|
|
1205
|
+
export const DEFAULT_SCAN_MAX_TIME_SEC = 600;
|
|
1206
|
+
/**
|
|
1207
|
+
* Pure wall-time check for the scan loop (v0.19.0, item 17): true once
|
|
1208
|
+
* the elapsed time since startedAtMs has reached the budget. Call with
|
|
1209
|
+
* Date.now() for the nowMs argument. Exported for unit tests.
|
|
1210
|
+
*/
|
|
1211
|
+
export function scanBudgetExceeded(startedAtMs, maxTimeSec, nowMs) {
|
|
1212
|
+
return nowMs - startedAtMs >= maxTimeSec * 1000;
|
|
1213
|
+
}
|
|
1021
1214
|
/**
|
|
1022
1215
|
* Scan candidates for a repo: the label-tagged starter list first; when it
|
|
1023
1216
|
* is empty, fall back to recent open unassigned issues (opt out with
|