@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/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
- /** Test seam: fetch a repo-root file's raw text, or undefined when absent. */
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 fetchOrgAiPolicy(owner, deps = {}) {
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 || undefined;
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
- const fetchOrgPolicy = deps.fetchOrgPolicy ??
398
- ((o) => fetchRawFile(o, ".github", "AI_POLICY.md", headers));
399
- const text = await fetchOrgPolicy(owner);
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()], text ?? "");
402
- return text;
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 rules = await fetchHouseRules(owner, repo, deps);
411
- const orgPolicy = await fetchOrgAiPolicy(owner, deps);
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. Returns undefined when the file does not exist.
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 fetchRawFile(owner, repo, file, headers) {
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 fetch(`${RAW}/${owner}/${repo}/HEAD/${file}`, { headers });
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
- /** Fetch all existing house-rules files for a repo (best-effort, cached). */
443
- export async function fetchHouseRules(owner, repo, deps = {}) {
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 fetchRaw = deps.fetchRaw ??
454
- ((file) => fetchRawFile(owner, repo, file, headers));
455
- // v0.9.3: stop at the first hit per filename (root, then .github/, then
456
- // docs/), so a repo with CONTRIBUTING.md at the root does not pay for
457
- // two more 404s, and a repo with it only in docs/ is still found (the
458
- // AFFiNE shape the 5k scan caught: root-only probing called it
459
- // "no policy" while docs/CONTRIBUTING.md existed).
460
- const seenBasenames = new Set();
461
- const out = [];
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 (seenBasenames.has(base))
582
+ if (provenance.some((p) => p.file.split("/").pop().toLowerCase() === base && p.scope === "repo")) {
465
583
  continue;
466
- const text = await fetchRaw(file);
467
- if (text) {
468
- out.push({ file, text });
469
- seenBasenames.add(base);
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], out);
474
- return out;
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