@reneturcios/greenlight 0.18.0 → 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/friction.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { gateLabel, parseGatesFromSources } from "./gates.js";
2
- import { fetchPolicySources, detectRepoPolicy, countStarterIssues, resolveCanonicalRepo, } from "./github.js";
2
+ import { fetchPolicySourcesWithProvenance, detectRepoPolicy, countStarterIssues, countStarterCandidates, resolveCanonicalRepo, } from "./github.js";
3
3
  import { fetchMergeVelocity } from "./merge-velocity.js";
4
4
  import { detectBehavioralGate } from "./behavior.js";
5
5
  const GATE_WEIGHTS = {
@@ -106,9 +106,22 @@ export function attachOutsiderShare(report) {
106
106
  * and docs/, plus the org-level AI_POLICY.md), starter-issue count, and
107
107
  * merge velocity as a best-effort signal. Shared by the CLI, the MCP
108
108
  * repo_friction tool, and the protocol preflight (which pairs it with
109
- * the issue status).
109
+ * the issue status). Every report carries policy_provenance: whether
110
+ * each policy source was read, absent, or a fetch error.
111
+ *
112
+ * v0.19.0 lite mode (opts.lite, friction --lite) is the canonical
113
+ * lightweight computation: policy gates ONLY. It runs the policy-source
114
+ * fetch plus parseGatesFromSources (and the collaboratorsOnly push from
115
+ * detectRepoPolicy, which is free), and scores with scoreFriction using
116
+ * all outcome signals null and behavioralGate null, so the score is
117
+ * 100 + gate weights, clamped, with the same GATE_WEIGHTS. It skips the
118
+ * behavioral pass, merge velocity, and the starter-issue count, marks
119
+ * the report lite: true, and sets signals.note to "lite". Repo rename
120
+ * resolution stays: it is one cheap call and name correctness matters
121
+ * for the scanner.
110
122
  */
111
123
  export async function fetchFrictionSummary(owner, repo, opts = {}) {
124
+ const lite = opts.lite === true;
112
125
  // v0.18.0 (item 15): renamed repos (dbt-labs/dbt-core -> dbt-labs/dbt)
113
126
  // 422 search and confuse every name-based flow; resolve once, use the
114
127
  // canonical name everywhere below, and carry the old name in
@@ -117,16 +130,24 @@ export async function fetchFrictionSummary(owner, repo, opts = {}) {
117
130
  const resolvedFrom = canon.renamed ? `${owner}/${repo}` : undefined;
118
131
  owner = canon.owner;
119
132
  repo = canon.repo;
120
- const [sources, policy, starterCount, mergeVelocity] = await Promise.all([
121
- fetchPolicySources(owner, repo),
122
- detectRepoPolicy(owner, repo),
123
- countStarterIssues(owner, repo),
124
- // v0.18.0 (item 11): merge velocity feeds the documented external
125
- // merge-rate score term (see the formula in the module header).
126
- // Best-effort: a failed fetch leaves the term null and unscored,
127
- // never fabricated.
128
- fetchMergeVelocity(owner, repo).catch(() => null),
129
- ]);
133
+ const [sourcesResult, policy, starterCount, mergeVelocity] = lite
134
+ ? await Promise.all([
135
+ fetchPolicySourcesWithProvenance(owner, repo),
136
+ detectRepoPolicy(owner, repo),
137
+ Promise.resolve(null),
138
+ Promise.resolve(null),
139
+ ])
140
+ : await Promise.all([
141
+ fetchPolicySourcesWithProvenance(owner, repo),
142
+ detectRepoPolicy(owner, repo),
143
+ countStarterIssues(owner, repo),
144
+ // v0.18.0 (item 11): merge velocity feeds the documented external
145
+ // merge-rate score term (see the formula in the module header).
146
+ // Best-effort: a failed fetch leaves the term null and unscored,
147
+ // never fabricated.
148
+ fetchMergeVelocity(owner, repo).catch(() => null),
149
+ ]);
150
+ const sources = sourcesResult.sources;
130
151
  const gates = parseGatesFromSources(sources);
131
152
  if (policy.collaboratorsOnly && !gates.some((g) => g.kind === "collaborators_only" && g.detected)) {
132
153
  gates.push({
@@ -137,9 +158,9 @@ export async function fetchFrictionSummary(owner, repo, opts = {}) {
137
158
  }
138
159
  // Behavioral gate is a signal, best-effort and silent on failure: a
139
160
  // dead pass must never fabricate a hostile verdict, and a missing one
140
- // must never hide an actual gate.
161
+ // must never hide an actual gate. Lite mode skips it entirely.
141
162
  let behavioralGate = null;
142
- if (opts.behavioral !== false) {
163
+ if (!lite && opts.behavioral !== false) {
143
164
  try {
144
165
  behavioralGate = await detectBehavioralGate(owner, repo);
145
166
  }
@@ -147,6 +168,19 @@ export async function fetchFrictionSummary(owner, repo, opts = {}) {
147
168
  behavioralGate = null;
148
169
  }
149
170
  }
171
+ // v0.19.0 (item 16): when the classic starter labels read exactly 0,
172
+ // one bounded fallback estimates open unassigned candidates under a
173
+ // wider label set. The estimate is reported next to the label count,
174
+ // never merged into it and never scored.
175
+ let starterCandidateEstimate = null;
176
+ if (starterCount === 0) {
177
+ try {
178
+ starterCandidateEstimate = await countStarterCandidates(owner, repo);
179
+ }
180
+ catch {
181
+ starterCandidateEstimate = null;
182
+ }
183
+ }
150
184
  const signals = {
151
185
  owner,
152
186
  repo,
@@ -156,11 +190,17 @@ export async function fetchFrictionSummary(owner, repo, opts = {}) {
156
190
  medianFirstReviewHours: null,
157
191
  maintainerIssueResponseHours: null,
158
192
  openGoodFirstIssueCount: starterCount,
193
+ starterLabelCount: starterCount,
194
+ starterCandidateEstimate,
159
195
  behavioralGate,
160
- note: "live",
196
+ note: lite ? "lite" : "live",
161
197
  };
162
198
  const report = attachBehavioralGate(scoreFriction(signals), behavioralGate);
163
199
  report.mergeVelocity = mergeVelocity;
200
+ // v0.19.0 (item 7): every report says how its policy sources were obtained.
201
+ report.policy_provenance = sourcesResult.provenance;
202
+ if (lite)
203
+ report.lite = true;
164
204
  if (resolvedFrom)
165
205
  report.resolvedFrom = resolvedFrom;
166
206
  return attachOutsiderShare(report);
@@ -1 +1 @@
1
- {"version":3,"file":"friction.js","sourceRoot":"","sources":["../src/friction.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"friction.js","sourceRoot":"","sources":["../src/friction.ts"],"names":[],"mappings":"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"}
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
@@ -105,11 +105,31 @@ export interface MentionedPRDeps {
105
105
  */
106
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"];
107
107
  export interface HouseRuleDeps {
108
- /** 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
+ */
109
113
  fetchRaw?: (file: string) => Promise<string | undefined>;
110
114
  /** Test seam: fetch the org's AI_POLICY.md text, or undefined when absent. */
111
115
  fetchOrgPolicy?: (owner: string) => Promise<string | undefined>;
112
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
+ };
113
133
  /**
114
134
  * v0.9.3: org-level AI policy. Binding agent rules sometimes live in the
115
135
  * org's `.github` repo rather than the project repo (astral-sh's
@@ -117,17 +137,77 @@ export interface HouseRuleDeps {
117
137
  * CONTRIBUTING only links to mastodon/.github's AI_POLICY.md). Probed
118
138
  * once per org and cached under its own key so a sweep over N repos from
119
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.
120
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. */
121
150
  export declare function fetchOrgAiPolicy(owner: string, deps?: HouseRuleDeps): Promise<string | undefined>;
122
151
  /**
123
152
  * v0.9.3: the full policy source set for gate detection: house-rules
124
153
  * files (first hit per filename across root, .github/, docs/) plus the
125
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.
126
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
+ }>;
127
167
  export declare function fetchPolicySources(owner: string, repo: string, deps?: HouseRuleDeps): Promise<{
128
168
  file: string;
129
169
  text: string;
130
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
+ }>;
131
211
  /** Fetch all existing house-rules files for a repo (best-effort, cached). */
132
212
  export declare function fetchHouseRules(owner: string, repo: string, deps?: HouseRuleDeps): Promise<{
133
213
  file: string;
@@ -245,6 +325,23 @@ export declare function extractTimelineCrossRefPRs(events: Record<string, unknow
245
325
  }>;
246
326
  /** Count open issues labelled good-first-issue or help-wanted. */
247
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>;
248
345
  /** Detect a collaborators-only PR creation policy via the community metrics endpoint. */
249
346
  export declare function detectRepoPolicy(owner: string, repo: string): Promise<{
250
347
  collaboratorsOnly: boolean;
@@ -292,6 +389,20 @@ export interface ScanCandidateList {
292
389
  /** True when the label pass came back empty and the fallback list was used. */
293
390
  fallbackUsed: boolean;
294
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;
295
406
  export interface ScanDeps {
296
407
  starter?: (owner: string, repo: string, limit: number) => Promise<ScanCandidate[]>;
297
408
  recent?: (owner: string, repo: string, cap: number) => Promise<ScanCandidate[]>;
package/dist/github.js CHANGED
@@ -400,6 +400,17 @@ export const HOUSE_RULE_FILES = [
400
400
  ".github/AI_POLICY.md",
401
401
  "docs/AI_POLICY.md",
402
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
+ }
403
414
  /**
404
415
  * v0.9.3: org-level AI policy. Binding agent rules sometimes live in the
405
416
  * org's `.github` repo rather than the project repo (astral-sh's
@@ -407,95 +418,203 @@ export const HOUSE_RULE_FILES = [
407
418
  * CONTRIBUTING only links to mastodon/.github's AI_POLICY.md). Probed
408
419
  * once per org and cached under its own key so a sweep over N repos from
409
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.
410
425
  */
411
- export async function fetchOrgAiPolicy(owner, deps = {}) {
426
+ export async function fetchOrgAiPolicyDetailed(owner, deps = {}) {
427
+ const file = `AI_POLICY.md (org ${owner})`;
412
428
  if (!deps.fetchOrgPolicy) {
413
429
  const cached = cacheGet("org-ai-policy", [owner.toLowerCase()]);
414
- if (cached !== undefined)
415
- return cached || undefined;
430
+ if (cached !== undefined && typeof cached === "object")
431
+ return { file, outcome: cached };
416
432
  }
417
433
  const headers = { "User-Agent": "greenlight-cli" };
418
434
  const token = githubToken();
419
435
  if (token)
420
436
  headers["Authorization"] = `Bearer ${token}`;
421
- const fetchOrgPolicy = deps.fetchOrgPolicy ??
422
- ((o) => fetchRawFile(o, ".github", "AI_POLICY.md", headers));
423
- 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
+ }
424
453
  if (!deps.fetchOrgPolicy)
425
- cacheSet("org-ai-policy", [owner.toLowerCase()], text ?? "");
426
- 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;
427
461
  }
428
462
  /**
429
463
  * v0.9.3: the full policy source set for gate detection: house-rules
430
464
  * files (first hit per filename across root, .github/, docs/) plus the
431
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.
432
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
+ }
433
485
  export async function fetchPolicySources(owner, repo, deps = {}) {
434
- const rules = await fetchHouseRules(owner, repo, deps);
435
- const orgPolicy = await fetchOrgAiPolicy(owner, deps);
436
- if (orgPolicy)
437
- rules.push({ file: `AI_POLICY.md (org ${owner})`, text: orgPolicy });
438
- return rules;
486
+ const { sources } = await fetchPolicySourcesWithProvenance(owner, repo, deps);
487
+ return sources;
439
488
  }
440
489
  /**
441
490
  * v0.9.3: fetch one policy file's raw text, honoring the API base
442
491
  * override. In proxy mode (GREENLIGHT_GITHUB_BASE_URL set) the raw host
443
492
  * is unreachable with the surrogate credential, so policy files are read
444
493
  * through the contents API instead; otherwise the raw host is used as
445
- * 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.
446
500
  */
447
- async function fetchRawFile(owner, repo, file, headers) {
501
+ export async function fetchRawFileDetailed(owner, repo, file, headers) {
448
502
  if (githubApiBase() !== DEFAULT_API) {
449
503
  try {
450
504
  const data = (await apiGet(`/repos/${owner}/${repo}/contents/${file}`));
451
505
  if (typeof data.content === "string" && data.encoding === "base64") {
452
- return Buffer.from(data.content, "base64").toString("utf8");
506
+ return { status: "read", text: Buffer.from(data.content, "base64").toString("utf8") };
453
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) };
454
514
  }
455
- catch { /* absent */ }
456
- return undefined;
457
515
  }
458
516
  try {
459
- const res = await fetch(`${RAW}/${owner}/${repo}/HEAD/${file}`, { headers });
517
+ const res = await activeFetch()(`${RAW}/${owner}/${repo}/HEAD/${file}`, { headers });
460
518
  if (res.ok)
461
- 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) };
462
526
  }
463
- catch { /* try next */ }
464
- return undefined;
465
527
  }
466
- /** Fetch all existing house-rules files for a repo (best-effort, cached). */
467
- 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 = {}) {
468
554
  if (!deps.fetchRaw) {
469
555
  const cached = cacheGet("house-rules", [owner, repo]);
470
- if (cached)
471
- return cached;
556
+ if (cached && !Array.isArray(cached) && Array.isArray(cached.provenance)) {
557
+ return { sources: cached.sources, provenance: cached.provenance };
558
+ }
472
559
  }
473
560
  const headers = { "User-Agent": "greenlight-cli" };
474
561
  const token = githubToken();
475
562
  if (token)
476
563
  headers["Authorization"] = `Bearer ${token}`;
477
- const fetchRaw = deps.fetchRaw ??
478
- ((file) => fetchRawFile(owner, repo, file, headers));
479
- // v0.9.3: stop at the first hit per filename (root, then .github/, then
480
- // docs/), so a repo with CONTRIBUTING.md at the root does not pay for
481
- // two more 404s, and a repo with it only in docs/ is still found (the
482
- // AFFiNE shape the 5k scan caught: root-only probing called it
483
- // "no policy" while docs/CONTRIBUTING.md existed).
484
- const seenBasenames = new Set();
485
- 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 = [];
486
580
  for (const file of HOUSE_RULE_FILES) {
487
581
  const base = file.split("/").pop().toLowerCase();
488
- if (seenBasenames.has(base))
582
+ if (provenance.some((p) => p.file.split("/").pop().toLowerCase() === base && p.scope === "repo")) {
489
583
  continue;
490
- const text = await fetchRaw(file);
491
- if (text) {
492
- out.push({ file, text });
493
- 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" });
494
608
  }
495
609
  }
496
610
  if (!deps.fetchRaw)
497
- cacheSet("house-rules", [owner, repo], out);
498
- 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;
499
618
  }
500
619
  /** Fetch CONTRIBUTING.md raw text; returns undefined when the repo has none. */
501
620
  export async function fetchContributing(owner, repo) {
@@ -888,6 +1007,40 @@ export async function countStarterIssues(owner, repo) {
888
1007
  return null;
889
1008
  }
890
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
+ }
891
1044
  /** Detect a collaborators-only PR creation policy via the community metrics endpoint. */
892
1045
  export async function detectRepoPolicy(owner, repo) {
893
1046
  // Best-effort: no public API exposes pull_request_creation_policy directly,
@@ -1042,6 +1195,22 @@ export async function listRecentUnassignedIssues(owner, repo, cap = 25) {
1042
1195
  const items = await paginate(`/repos/${owner}/${repo}/issues?state=open&sort=created&direction=desc`, 100, 3);
1043
1196
  return toScanCandidates(items).slice(0, cap);
1044
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
+ }
1045
1214
  /**
1046
1215
  * Scan candidates for a repo: the label-tagged starter list first; when it
1047
1216
  * is empty, fall back to recent open unassigned issues (opt out with