@loopover/miner 0.1.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.
Files changed (218) hide show
  1. package/DEPLOYMENT.md +156 -0
  2. package/Dockerfile +38 -0
  3. package/README.md +269 -0
  4. package/bin/gittensory-miner-mcp.d.ts +64 -0
  5. package/bin/gittensory-miner-mcp.js +279 -0
  6. package/bin/gittensory-miner.js +196 -0
  7. package/docs/coding-agent-driver.md +140 -0
  8. package/docs/config-precedence.md +96 -0
  9. package/docs/cross-repo-discovery-phase1.md +55 -0
  10. package/docs/discovery-index-contract.md +60 -0
  11. package/docs/discovery-plane-operator-guide.md +101 -0
  12. package/docs/env-reference.md +30 -0
  13. package/docs/fleet-run-manifest.md +50 -0
  14. package/docs/miner-goal-spec.md +76 -0
  15. package/docs/miner-selfimprove-calibration.md +105 -0
  16. package/docs/observability.md +60 -0
  17. package/docs/operations-runbook.md +209 -0
  18. package/docs/repo-agnostic-capability-audit.md +129 -0
  19. package/docs/unattended-scheduling.md +107 -0
  20. package/expected-engine.version +1 -0
  21. package/lib/ams-policy.d.ts +20 -0
  22. package/lib/ams-policy.js +68 -0
  23. package/lib/attempt-cli.d.ts +99 -0
  24. package/lib/attempt-cli.js +523 -0
  25. package/lib/attempt-input-builder.d.ts +24 -0
  26. package/lib/attempt-input-builder.js +91 -0
  27. package/lib/attempt-log.d.ts +37 -0
  28. package/lib/attempt-log.js +177 -0
  29. package/lib/attempt-runner.d.ts +59 -0
  30. package/lib/attempt-runner.js +164 -0
  31. package/lib/attempt-worktree.d.ts +31 -0
  32. package/lib/attempt-worktree.js +94 -0
  33. package/lib/calibration-cli.d.ts +1 -0
  34. package/lib/calibration-cli.js +92 -0
  35. package/lib/calibration-run.d.ts +161 -0
  36. package/lib/calibration-run.js +232 -0
  37. package/lib/calibration-types.d.ts +46 -0
  38. package/lib/calibration-types.js +74 -0
  39. package/lib/calibration.d.ts +24 -0
  40. package/lib/calibration.js +99 -0
  41. package/lib/ci-poller.d.ts +33 -0
  42. package/lib/ci-poller.js +234 -0
  43. package/lib/claim-adjudication.d.ts +21 -0
  44. package/lib/claim-adjudication.js +36 -0
  45. package/lib/claim-conflict-resolver.d.ts +29 -0
  46. package/lib/claim-conflict-resolver.js +101 -0
  47. package/lib/claim-ledger-cli.d.ts +59 -0
  48. package/lib/claim-ledger-cli.js +320 -0
  49. package/lib/claim-ledger-expiry.d.ts +20 -0
  50. package/lib/claim-ledger-expiry.js +41 -0
  51. package/lib/claim-ledger.d.ts +63 -0
  52. package/lib/claim-ledger.js +303 -0
  53. package/lib/cli-error.d.ts +3 -0
  54. package/lib/cli-error.js +27 -0
  55. package/lib/cli.d.ts +3 -0
  56. package/lib/cli.js +72 -0
  57. package/lib/coding-agent-construction.d.ts +17 -0
  58. package/lib/coding-agent-construction.js +103 -0
  59. package/lib/coding-agent-house-rules.d.ts +24 -0
  60. package/lib/coding-agent-house-rules.js +64 -0
  61. package/lib/coding-task-spec.d.ts +44 -0
  62. package/lib/coding-task-spec.js +183 -0
  63. package/lib/config-precedence.d.ts +25 -0
  64. package/lib/config-precedence.js +38 -0
  65. package/lib/deny-check.d.ts +11 -0
  66. package/lib/deny-check.js +76 -0
  67. package/lib/deny-hook-synthesis.d.ts +93 -0
  68. package/lib/deny-hook-synthesis.js +399 -0
  69. package/lib/deny-hooks.d.ts +28 -0
  70. package/lib/deny-hooks.js +155 -0
  71. package/lib/deployment-docs-audit.d.ts +41 -0
  72. package/lib/deployment-docs-audit.js +115 -0
  73. package/lib/discover-cli.d.ts +94 -0
  74. package/lib/discover-cli.js +284 -0
  75. package/lib/discovery-throttle.d.ts +8 -0
  76. package/lib/discovery-throttle.js +33 -0
  77. package/lib/env-file-indirection.d.ts +4 -0
  78. package/lib/env-file-indirection.js +45 -0
  79. package/lib/event-ledger-cli.d.ts +82 -0
  80. package/lib/event-ledger-cli.js +266 -0
  81. package/lib/event-ledger.d.ts +37 -0
  82. package/lib/event-ledger.js +210 -0
  83. package/lib/execute-local-write.d.ts +14 -0
  84. package/lib/execute-local-write.js +50 -0
  85. package/lib/feasibility-cli.d.ts +25 -0
  86. package/lib/feasibility-cli.js +80 -0
  87. package/lib/forge-config.d.ts +17 -0
  88. package/lib/forge-config.js +37 -0
  89. package/lib/governor-action-mode.d.ts +26 -0
  90. package/lib/governor-action-mode.js +47 -0
  91. package/lib/governor-chokepoint-persisted.d.ts +18 -0
  92. package/lib/governor-chokepoint-persisted.js +46 -0
  93. package/lib/governor-chokepoint.d.ts +14 -0
  94. package/lib/governor-chokepoint.js +52 -0
  95. package/lib/governor-kill-switch.d.ts +26 -0
  96. package/lib/governor-kill-switch.js +47 -0
  97. package/lib/governor-ledger-cli.d.ts +32 -0
  98. package/lib/governor-ledger-cli.js +155 -0
  99. package/lib/governor-ledger.d.ts +46 -0
  100. package/lib/governor-ledger.js +203 -0
  101. package/lib/governor-open-pr.d.ts +21 -0
  102. package/lib/governor-open-pr.js +27 -0
  103. package/lib/governor-pause-cli.d.ts +23 -0
  104. package/lib/governor-pause-cli.js +171 -0
  105. package/lib/governor-run-halt.d.ts +37 -0
  106. package/lib/governor-run-halt.js +58 -0
  107. package/lib/governor-state.d.ts +63 -0
  108. package/lib/governor-state.js +363 -0
  109. package/lib/governor-write-rate-limit.d.ts +30 -0
  110. package/lib/governor-write-rate-limit.js +64 -0
  111. package/lib/harness-submission-trigger.d.ts +69 -0
  112. package/lib/harness-submission-trigger.js +138 -0
  113. package/lib/http-retry.d.ts +12 -0
  114. package/lib/http-retry.js +52 -0
  115. package/lib/laptop-init.d.ts +53 -0
  116. package/lib/laptop-init.js +332 -0
  117. package/lib/live-issue-snapshot.d.ts +16 -0
  118. package/lib/live-issue-snapshot.js +117 -0
  119. package/lib/local-store.d.ts +18 -0
  120. package/lib/local-store.js +62 -0
  121. package/lib/logger.d.ts +58 -0
  122. package/lib/logger.js +167 -0
  123. package/lib/loop-cli.d.ts +66 -0
  124. package/lib/loop-cli.js +540 -0
  125. package/lib/loop-closure.d.ts +35 -0
  126. package/lib/loop-closure.js +66 -0
  127. package/lib/loop-reentry.d.ts +51 -0
  128. package/lib/loop-reentry.js +124 -0
  129. package/lib/manage-poll.d.ts +79 -0
  130. package/lib/manage-poll.js +254 -0
  131. package/lib/manage-status.d.ts +70 -0
  132. package/lib/manage-status.js +241 -0
  133. package/lib/metrics-cli.d.ts +9 -0
  134. package/lib/metrics-cli.js +50 -0
  135. package/lib/migrate-cli.d.ts +24 -0
  136. package/lib/migrate-cli.js +111 -0
  137. package/lib/miner-goal-spec.d.ts +12 -0
  138. package/lib/miner-goal-spec.js +67 -0
  139. package/lib/opportunity-fanout.d.ts +86 -0
  140. package/lib/opportunity-fanout.js +597 -0
  141. package/lib/opportunity-ranker.d.ts +36 -0
  142. package/lib/opportunity-ranker.js +118 -0
  143. package/lib/orb-export.d.ts +54 -0
  144. package/lib/orb-export.js +206 -0
  145. package/lib/plan-store-cli.d.ts +37 -0
  146. package/lib/plan-store-cli.js +151 -0
  147. package/lib/plan-store.d.ts +51 -0
  148. package/lib/plan-store.js +231 -0
  149. package/lib/policy-doc-cache.d.ts +25 -0
  150. package/lib/policy-doc-cache.js +79 -0
  151. package/lib/policy-verdict-cache.d.ts +35 -0
  152. package/lib/policy-verdict-cache.js +106 -0
  153. package/lib/portfolio-dashboard.d.ts +30 -0
  154. package/lib/portfolio-dashboard.js +105 -0
  155. package/lib/portfolio-discovery.d.ts +29 -0
  156. package/lib/portfolio-discovery.js +100 -0
  157. package/lib/portfolio-queue-cli.d.ts +87 -0
  158. package/lib/portfolio-queue-cli.js +509 -0
  159. package/lib/portfolio-queue-expiry.d.ts +20 -0
  160. package/lib/portfolio-queue-expiry.js +51 -0
  161. package/lib/portfolio-queue-manager.d.ts +50 -0
  162. package/lib/portfolio-queue-manager.js +134 -0
  163. package/lib/portfolio-queue.d.ts +62 -0
  164. package/lib/portfolio-queue.js +351 -0
  165. package/lib/pr-disposition-poller.d.ts +26 -0
  166. package/lib/pr-disposition-poller.js +163 -0
  167. package/lib/pr-number-parse.d.ts +4 -0
  168. package/lib/pr-number-parse.js +23 -0
  169. package/lib/pr-outcome.d.ts +41 -0
  170. package/lib/pr-outcome.js +90 -0
  171. package/lib/prediction-ledger.d.ts +47 -0
  172. package/lib/prediction-ledger.js +222 -0
  173. package/lib/pretooluse-hook.d.ts +31 -0
  174. package/lib/pretooluse-hook.js +93 -0
  175. package/lib/process-lifecycle.d.ts +31 -0
  176. package/lib/process-lifecycle.js +106 -0
  177. package/lib/purge-cli.d.ts +44 -0
  178. package/lib/purge-cli.js +198 -0
  179. package/lib/rejection-signal.d.ts +6 -0
  180. package/lib/rejection-signal.js +101 -0
  181. package/lib/rejection-state-machine.d.ts +37 -0
  182. package/lib/rejection-state-machine.js +81 -0
  183. package/lib/rejection-templates.d.ts +12 -0
  184. package/lib/rejection-templates.js +71 -0
  185. package/lib/replay-objective-anchor.d.ts +79 -0
  186. package/lib/replay-objective-anchor.js +179 -0
  187. package/lib/replay-snapshot.d.ts +41 -0
  188. package/lib/replay-snapshot.js +288 -0
  189. package/lib/replay-task-generation.d.ts +126 -0
  190. package/lib/replay-task-generation.js +223 -0
  191. package/lib/repo-clone.d.ts +19 -0
  192. package/lib/repo-clone.js +102 -0
  193. package/lib/run-state-cli.d.ts +27 -0
  194. package/lib/run-state-cli.js +154 -0
  195. package/lib/run-state.d.ts +37 -0
  196. package/lib/run-state.js +150 -0
  197. package/lib/schema-version.d.ts +17 -0
  198. package/lib/schema-version.js +71 -0
  199. package/lib/self-review-context.d.ts +28 -0
  200. package/lib/self-review-context.js +339 -0
  201. package/lib/slop-assessment.d.ts +3 -0
  202. package/lib/slop-assessment.js +20 -0
  203. package/lib/stack-detection.d.ts +41 -0
  204. package/lib/stack-detection.js +248 -0
  205. package/lib/status.d.ts +64 -0
  206. package/lib/status.js +441 -0
  207. package/lib/store-maintenance.d.ts +31 -0
  208. package/lib/store-maintenance.js +183 -0
  209. package/lib/submission-freshness-check.d.ts +32 -0
  210. package/lib/submission-freshness-check.js +93 -0
  211. package/lib/update-check.d.ts +36 -0
  212. package/lib/update-check.js +161 -0
  213. package/lib/version.d.ts +3 -0
  214. package/lib/version.js +10 -0
  215. package/lib/worktree-allocator.d.ts +39 -0
  216. package/lib/worktree-allocator.js +262 -0
  217. package/package.json +50 -0
  218. package/schema/miner-goal-spec.schema.json +111 -0
@@ -0,0 +1,597 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { resolveAiPolicyVerdict } from "@loopover/engine";
3
+ import { resolveForgeConfig } from "./forge-config.js";
4
+ import {
5
+ DEFAULT_RATE_LIMIT_HIGH_WATER_MARK,
6
+ DEFAULT_RATE_LIMIT_LOW_WATER_MARK,
7
+ resolveThrottledConcurrency,
8
+ } from "./discovery-throttle.js";
9
+ import { fetchWithRetry } from "./http-retry.js";
10
+
11
+ const defaultConcurrency = 5;
12
+ // How long a parked worker waits before re-checking the live rate-limit-derived concurrency limit (#4844).
13
+ const throttleParkMs = 25;
14
+ const defaultPerPage = 100;
15
+ // Follow the GitHub Link header past the first page so a repo/search with >100 open issues isn't silently
16
+ // truncated (#4831); cap the follow loop so a pathological Link chain can't run away.
17
+ const defaultMaxPages = 10;
18
+
19
+ function normalizeLimit(value, fallback, min, max) {
20
+ if (!Number.isFinite(value)) return fallback;
21
+ return Math.min(max, Math.max(min, Math.floor(value)));
22
+ }
23
+
24
+ function targetKey(target) {
25
+ return `${target.owner.toLowerCase()}/${target.repo.toLowerCase()}`;
26
+ }
27
+
28
+ function normalizeTargets(targets) {
29
+ const seen = new Set();
30
+ const normalized = [];
31
+ for (const target of Array.isArray(targets) ? targets : []) {
32
+ const owner = typeof target?.owner === "string" ? target.owner.trim() : "";
33
+ const repo = typeof target?.repo === "string" ? target.repo.trim() : "";
34
+ if (!owner || !repo) continue;
35
+ const key = targetKey({ owner, repo });
36
+ if (seen.has(key)) continue;
37
+ seen.add(key);
38
+ normalized.push({ owner, repo, repoFullName: `${owner}/${repo}` });
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ function targetFromFullName(fullName) {
44
+ if (typeof fullName !== "string") return null;
45
+ const [owner, repo, extra] = fullName.split("/");
46
+ if (!owner || !repo || extra) return null;
47
+ return { owner, repo, repoFullName: `${owner}/${repo}` };
48
+ }
49
+
50
+ function escapeRegExp(value) {
51
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+
54
+ // Derive owner/repo from a search hit when `repository.full_name` is absent, using the tenant forge's own
55
+ // `repoPathPrefix` for the API `repository_url` and a forge-agnostic host for the web `html_url` (#4784). Hardcoding
56
+ // `/repos/` and `github.com` here dropped every custom-forge search result whose payload omitted `full_name`.
57
+ function targetFromSearchIssue(issue, forge) {
58
+ const repositoryFullName = targetFromFullName(issue?.repository?.full_name);
59
+ if (repositoryFullName) return repositoryFullName;
60
+
61
+ const repoPathPrefix = escapeRegExp(forge.repoPathPrefix.replace(/\/+$/, ""));
62
+ const repositoryUrl =
63
+ typeof issue?.repository_url === "string"
64
+ ? issue.repository_url.match(new RegExp(`${repoPathPrefix}/([^/?#]+)/([^/?#]+)(?:[?#].*)?$`))
65
+ : null;
66
+ if (repositoryUrl) {
67
+ const owner = decodeURIComponent(repositoryUrl[1]);
68
+ const repo = decodeURIComponent(repositoryUrl[2]);
69
+ return { owner, repo, repoFullName: `${owner}/${repo}` };
70
+ }
71
+
72
+ const htmlUrl =
73
+ typeof issue?.html_url === "string"
74
+ ? issue.html_url.match(/^https:\/\/[^/]+\/([^/]+)\/([^/]+)\/issues\/\d+(?:[?#].*)?$/)
75
+ : null;
76
+ if (htmlUrl) {
77
+ const owner = decodeURIComponent(htmlUrl[1]);
78
+ const repo = decodeURIComponent(htmlUrl[2]);
79
+ return { owner, repo, repoFullName: `${owner}/${repo}` };
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ function githubHeaders(githubToken, forge) {
86
+ const headers = {
87
+ accept: forge.acceptHeader,
88
+ "user-agent": forge.userAgent,
89
+ [forge.apiVersionHeader]: forge.apiVersion,
90
+ };
91
+ const token = typeof githubToken === "string" ? githubToken.trim() : "";
92
+ if (token) headers.authorization = `Bearer ${token}`;
93
+ return headers;
94
+ }
95
+
96
+ function apiUrl(apiBaseUrl, path, query = "") {
97
+ return `${apiBaseUrl.replace(/\/+$/, "")}${path}${query}`;
98
+ }
99
+
100
+ function repoPath(forge, target, suffix) {
101
+ return `${forge.repoPathPrefix}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`;
102
+ }
103
+
104
+ function recordRateLimit(summary, response) {
105
+ const remaining = Number(response.headers.get("x-ratelimit-remaining"));
106
+ if (Number.isFinite(remaining)) {
107
+ summary.rateLimitRemaining =
108
+ summary.rateLimitRemaining === null
109
+ ? remaining
110
+ : Math.min(summary.rateLimitRemaining, remaining);
111
+ }
112
+ const resetSeconds = Number(response.headers.get("x-ratelimit-reset"));
113
+ if (Number.isFinite(resetSeconds) && resetSeconds > 0) {
114
+ const resetAt = new Date(resetSeconds * 1000).toISOString();
115
+ summary.rateLimitResetAt =
116
+ summary.rateLimitResetAt === null || resetAt > summary.rateLimitResetAt
117
+ ? resetAt
118
+ : summary.rateLimitResetAt;
119
+ }
120
+ }
121
+
122
+ async function githubGetJson(url, githubToken, summary, options, extraHeaders = {}) {
123
+ // Retry a transient 5xx from GitHub before dropping this target's results for the whole run (#4830) — the same
124
+ // discipline as the CI/gate-verdict pollers. A thrown network error still propagates to each caller's try/catch.
125
+ // `extraHeaders` carries per-call additions (e.g. a policy-doc If-None-Match, #4842) on top of the base auth set.
126
+ const response = await fetchWithRetry(
127
+ fetch,
128
+ url,
129
+ { method: "GET", headers: { ...githubHeaders(githubToken, options.forge), ...extraHeaders } },
130
+ { sleepFn: options?.sleepFn },
131
+ );
132
+ recordRateLimit(summary, response);
133
+ const payload = await response.json().catch(() => null);
134
+ return { response, payload };
135
+ }
136
+
137
+ function decodeContentPayload(payload) {
138
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
139
+ if (typeof payload.content !== "string") return null;
140
+ if (payload.encoding === "base64") {
141
+ return Buffer.from(payload.content.replace(/\s/g, ""), "base64").toString("utf8");
142
+ }
143
+ return payload.content;
144
+ }
145
+
146
+ function warning(target, stage, message) {
147
+ return { repoFullName: target.repoFullName, stage, message };
148
+ }
149
+
150
+ // Read a URL's prior ETag so an unchanged doc can be revalidated with a conditional GET (#4842). A cache that is
151
+ // absent, or whose read throws (corrupt/locked file), is treated as a plain miss: the caller does a full fetch,
152
+ // per the "never risk a stale policy" rule — the cache only ever makes discovery cheaper, never less correct.
153
+ function readCachedPolicyDoc(cache, url) {
154
+ if (!cache) return null;
155
+ try {
156
+ return cache.get(url);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ function readEtagHeader(response) {
163
+ const etag = response.headers.get("etag");
164
+ return typeof etag === "string" && etag.trim() ? etag : null;
165
+ }
166
+
167
+ // Persist the fresh ETag + body so the NEXT discover run can revalidate instead of re-downloading. Only a real
168
+ // ETag paired with decoded content is stored, and a write that throws must never fail discovery (same stale-safe
169
+ // rule) — it degrades to "not cached", so the next run simply refetches in full.
170
+ function writeCachedPolicyDoc(cache, url, etag, content) {
171
+ if (!cache || content === null || etag === null) return;
172
+ try {
173
+ cache.put(url, etag, content);
174
+ } catch {
175
+ // Leave this URL uncached; the next run refetches fully rather than serving anything stale.
176
+ }
177
+ }
178
+
179
+ // A bare `owner/repo` is NOT a safe policy-verdict cache key: two different tenant forge hosts (#4784's
180
+ // per-tenant `apiBaseUrl`) can each have their own unrelated repo of the same name, and their policy docs are
181
+ // wholly independent. Scope the key by host, mirroring policy-doc-cache.js's own precedent of keying on the full
182
+ // request URL rather than a bare path.
183
+ function policyVerdictCacheKey(apiBaseUrl, repoFullName) {
184
+ return `${apiBaseUrl}::${repoFullName}`;
185
+ }
186
+
187
+ // Read a repo scope's previously-resolved verdict for the SAME decisive doc + ETag (#4843). A cache that is
188
+ // absent, or whose read throws (corrupt/locked file), is treated as a plain miss — the caller resolves the
189
+ // verdict fresh, per the same "never risk a stale policy" rule as the doc cache above.
190
+ function readCachedPolicyVerdict(cache, repoScope) {
191
+ if (!cache) return null;
192
+ try {
193
+ return cache.get(repoScope);
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // Persist the freshly-resolved verdict against the ETag of the doc that decided it, so the next run can reuse it
200
+ // outright once that ETag is confirmed unchanged. Only ever called with a real ETag; a write that throws must
201
+ // never fail discovery — it degrades to "not cached", so the next run just resolves the verdict again.
202
+ function writeCachedPolicyVerdict(cache, repoScope, decisiveDoc, etag, verdict) {
203
+ if (!cache || etag === null) return;
204
+ try {
205
+ cache.put(repoScope, decisiveDoc, etag, verdict);
206
+ } catch {
207
+ // Leave this repo scope uncached; the next run just resolves the verdict fresh again.
208
+ }
209
+ }
210
+
211
+ async function fetchRepoDoc(target, path, githubToken, options, summary, warnings) {
212
+ const url = apiUrl(
213
+ options.apiBaseUrl,
214
+ repoPath(options.forge, target, `/contents/${encodeURIComponent(path)}`),
215
+ );
216
+ const cached = readCachedPolicyDoc(options.policyDocCache, url);
217
+ const conditionalHeaders = cached ? { "if-none-match": cached.etag } : {};
218
+ try {
219
+ const { response, payload } = await githubGetJson(url, githubToken, summary, options, conditionalHeaders);
220
+ // A 304 only ever follows the If-None-Match we send above, which we only send when `cached` exists — so the
221
+ // cached body is the GitHub-confirmed current content, served with no extra rate-limit spend.
222
+ if (response.status === 304) return { content: cached.content, etag: cached.etag };
223
+ if (response.status === 404) return { content: null, etag: null };
224
+ if (!response.ok) {
225
+ warnings.push(warning(target, `policy:${path}`, `GitHub returned ${response.status}`));
226
+ return { content: null, etag: null };
227
+ }
228
+ const content = decodeContentPayload(payload);
229
+ const etag = readEtagHeader(response);
230
+ writeCachedPolicyDoc(options.policyDocCache, url, etag, content);
231
+ return { content, etag };
232
+ } catch (error) {
233
+ warnings.push(
234
+ warning(target, `policy:${path}`, error instanceof Error ? error.message : "policy fetch failed"),
235
+ );
236
+ return { content: null, etag: null };
237
+ }
238
+ }
239
+
240
+ // Resolve a repo scope's AI-usage-policy verdict, reusing a cached one when the deciding doc's ETag hasn't moved
241
+ // since it was last resolved (#4843). Only ever consulted with an ETag that a same-run conditional-GET just
242
+ // confirmed is current, so a cache hit is exactly as correct as recomputing — it just skips the (cheap, but not
243
+ // free) parse.
244
+ function resolveOrCacheVerdict(cache, repoScope, decisiveDoc, etag, computeVerdict) {
245
+ if (etag !== null) {
246
+ const cached = readCachedPolicyVerdict(cache, repoScope);
247
+ if (cached && cached.decisiveDoc === decisiveDoc && cached.etag === etag) return cached.verdict;
248
+ }
249
+ const verdict = computeVerdict();
250
+ writeCachedPolicyVerdict(cache, repoScope, decisiveDoc, etag, verdict);
251
+ return verdict;
252
+ }
253
+
254
+ async function resolveRepoAiPolicy(target, githubToken, options, summary, warnings) {
255
+ const repoScope = policyVerdictCacheKey(options.apiBaseUrl, target.repoFullName);
256
+ const { content: aiUsage, etag: aiUsageEtag } = await fetchRepoDoc(
257
+ target,
258
+ "AI-USAGE.md",
259
+ githubToken,
260
+ options,
261
+ summary,
262
+ warnings,
263
+ );
264
+ // Short-circuit only on AI-USAGE.md that has real content. A present-but-blank AI-USAGE.md must still fall
265
+ // through to CONTRIBUTING.md — otherwise a stub AI-USAGE.md silently fails open and swallows a ban declared in
266
+ // CONTRIBUTING.md (the exact case resolveAiPolicyVerdict was fixed to handle in #2900, which can only fire if
267
+ // both docs reach it).
268
+ if (aiUsage !== null && aiUsage.trim().length > 0) {
269
+ return resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "AI-USAGE.md", aiUsageEtag, () =>
270
+ resolveAiPolicyVerdict({ aiUsage, contributing: null }),
271
+ );
272
+ }
273
+ const { content: contributing, etag: contributingEtag } = await fetchRepoDoc(
274
+ target,
275
+ "CONTRIBUTING.md",
276
+ githubToken,
277
+ options,
278
+ summary,
279
+ warnings,
280
+ );
281
+ return resolveOrCacheVerdict(
282
+ options.policyVerdictCache,
283
+ repoScope,
284
+ "CONTRIBUTING.md",
285
+ contributingEtag,
286
+ () => resolveAiPolicyVerdict({ aiUsage: null, contributing }),
287
+ );
288
+ }
289
+
290
+ function labelNames(labels) {
291
+ if (!Array.isArray(labels)) return [];
292
+ return labels
293
+ .map((label) => {
294
+ if (typeof label === "string") return label;
295
+ if (label && typeof label === "object" && typeof label.name === "string") return label.name;
296
+ return "";
297
+ })
298
+ .filter((name) => name.length > 0);
299
+ }
300
+
301
+ function normalizeIssue(target, issue, policySource) {
302
+ if (!issue || typeof issue !== "object" || issue.pull_request) return null;
303
+ if (!Number.isInteger(issue.number) || issue.number <= 0) return null;
304
+ if (typeof issue.title !== "string" || issue.title.trim().length === 0) return null;
305
+ return {
306
+ owner: target.owner,
307
+ repo: target.repo,
308
+ repoFullName: target.repoFullName,
309
+ issueNumber: issue.number,
310
+ title: issue.title,
311
+ labels: labelNames(issue.labels),
312
+ commentsCount: Number.isFinite(issue.comments) ? issue.comments : 0,
313
+ createdAt: typeof issue.created_at === "string" ? issue.created_at : null,
314
+ updatedAt: typeof issue.updated_at === "string" ? issue.updated_at : null,
315
+ htmlUrl: typeof issue.html_url === "string" ? issue.html_url : null,
316
+ aiPolicyAllowed: true,
317
+ aiPolicySource: policySource,
318
+ };
319
+ }
320
+
321
+ function searchQueryWithIssueQualifiers(searchQuery, forge) {
322
+ const trimmed = typeof searchQuery === "string" ? searchQuery.trim() : "";
323
+ if (!trimmed) return "";
324
+ return `${trimmed} ${forge.searchQualifiers}`;
325
+ }
326
+
327
+ // The URL of the next page from a GitHub Link header (`<url>; rel="next"`), constrained to the current
328
+ // token-bearing GitHub API endpoint so a forged Link header cannot redirect credentials off-origin.
329
+ function nextPageUrl(response, apiBaseUrl, expectedPath) {
330
+ const linkHeader = response.headers.get("link") ?? "";
331
+ const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
332
+ if (match === null) return null;
333
+
334
+ let nextUrl;
335
+ let expectedUrl;
336
+ try {
337
+ expectedUrl = new URL(apiUrl(apiBaseUrl, expectedPath));
338
+ nextUrl = new URL(match[1], expectedUrl);
339
+ } catch {
340
+ return null;
341
+ }
342
+
343
+ if (
344
+ nextUrl.protocol !== "https:" ||
345
+ nextUrl.origin !== expectedUrl.origin ||
346
+ nextUrl.pathname !== expectedUrl.pathname
347
+ ) {
348
+ return null;
349
+ }
350
+ return nextUrl.toString();
351
+ }
352
+
353
+ async function fetchTargetIssues(target, githubToken, options, summary, warnings) {
354
+ const verdict = await resolveRepoAiPolicy(target, githubToken, options, summary, warnings);
355
+ if (!verdict.allowed) return [];
356
+
357
+ const issuesPath = repoPath(options.forge, target, "/issues");
358
+ let url = apiUrl(options.apiBaseUrl, issuesPath, `?state=open&per_page=${options.perPage}`);
359
+ const issues = [];
360
+ try {
361
+ for (let page = 0; url !== null && page < options.maxPages; page += 1) {
362
+ const { response, payload } = await githubGetJson(url, githubToken, summary, options);
363
+ if (!response.ok) {
364
+ warnings.push(warning(target, "issues", `GitHub returned ${response.status}`));
365
+ return issues;
366
+ }
367
+ if (!Array.isArray(payload)) {
368
+ warnings.push(warning(target, "issues", "GitHub returned a non-array issues payload"));
369
+ return issues;
370
+ }
371
+ for (const issue of payload) {
372
+ const normalized = normalizeIssue(target, issue, verdict.source);
373
+ if (normalized !== null) issues.push(normalized);
374
+ }
375
+ url = nextPageUrl(response, options.apiBaseUrl, issuesPath);
376
+ }
377
+ return issues;
378
+ } catch (error) {
379
+ warnings.push(
380
+ warning(target, "issues", error instanceof Error ? error.message : "issue fetch failed"),
381
+ );
382
+ return issues;
383
+ }
384
+ }
385
+
386
+ async function fetchSearchIssues(searchQuery, githubToken, options, summary, warnings) {
387
+ const qualifiedQuery = searchQueryWithIssueQualifiers(searchQuery, options.forge);
388
+ if (!qualifiedQuery) return [];
389
+
390
+ const searchPath = options.forge.searchEndpoint;
391
+ let url = apiUrl(
392
+ options.apiBaseUrl,
393
+ searchPath,
394
+ `?q=${encodeURIComponent(qualifiedQuery)}&per_page=${options.perPage}`,
395
+ );
396
+ const items = [];
397
+ try {
398
+ for (let page = 0; url !== null && page < options.maxPages; page += 1) {
399
+ const { response, payload } = await githubGetJson(url, githubToken, summary, options);
400
+ if (!response.ok) {
401
+ warnings.push({
402
+ repoFullName: "*",
403
+ stage: "search",
404
+ message: `GitHub returned ${response.status}`,
405
+ });
406
+ return items;
407
+ }
408
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.items)) {
409
+ warnings.push({
410
+ repoFullName: "*",
411
+ stage: "search",
412
+ message: "GitHub returned a non-array search payload",
413
+ });
414
+ return items;
415
+ }
416
+ items.push(...payload.items);
417
+ url = nextPageUrl(response, options.apiBaseUrl, searchPath);
418
+ }
419
+ return items;
420
+ } catch (error) {
421
+ warnings.push({
422
+ repoFullName: "*",
423
+ stage: "search",
424
+ message: error instanceof Error ? error.message : "issue search failed",
425
+ });
426
+ return items;
427
+ }
428
+ }
429
+
430
+ function delay(ms) {
431
+ return new Promise((resolve) => setTimeout(resolve, ms));
432
+ }
433
+
434
+ // Run `worker` over `items` with a dynamic in-flight cap (#4844). The pool spawns `maxConcurrency` loops, but a
435
+ // loop parks (re-checking every `throttleParkMs`) whenever the live `resolveLimit()` — derived from the recorded
436
+ // rate-limit budget — is already met by the number of in-flight workers, so effective concurrency tapers off as
437
+ // the budget drops instead of sprinting into a 403. `sleepFn` lets tests inject an instant wait for the park.
438
+ export async function mapWithConcurrency(items, maxConcurrency, worker, resolveLimit, sleepFn) {
439
+ const results = new Array(items.length);
440
+ const sleep = sleepFn ?? delay;
441
+ let next = 0;
442
+ let active = 0;
443
+ const runOne = async () => {
444
+ while (next < items.length) {
445
+ // Park while the live limit is already saturated. The check and the `active`/`next` bumps below run without
446
+ // an intervening await, so two loops can never claim the same slot.
447
+ while (active >= resolveLimit()) {
448
+ await sleep(throttleParkMs);
449
+ }
450
+ // The shared cursor can be drained by other loops while this one is parked, so re-check before claiming.
451
+ if (next >= items.length) return;
452
+ const index = next;
453
+ next += 1;
454
+ active += 1;
455
+ try {
456
+ results[index] = await worker(items[index], index);
457
+ } finally {
458
+ active -= 1;
459
+ }
460
+ }
461
+ };
462
+ const workers = Array.from({ length: Math.min(maxConcurrency, items.length) }, runOne);
463
+ await Promise.all(workers);
464
+ return results;
465
+ }
466
+
467
+ /** A live limit resolver for `mapWithConcurrency`, reading the summary's rate-limit budget as it is updated (#4844). */
468
+ function liveConcurrencyResolver(normalizedOptions, summary) {
469
+ return () =>
470
+ resolveThrottledConcurrency(
471
+ normalizedOptions.concurrency,
472
+ summary.rateLimitRemaining,
473
+ normalizedOptions.rateLimitLowWaterMark,
474
+ normalizedOptions.rateLimitHighWaterMark,
475
+ );
476
+ }
477
+
478
+ function normalizeOptions(options = {}) {
479
+ // A legacy top-level `apiBaseUrl` (the pre-#4784 GitHub-Enterprise override every existing caller uses) still wins
480
+ // over `forge.apiBaseUrl`, so nothing that already passes `apiBaseUrl` changes behavior.
481
+ const apiBaseUrlOverride =
482
+ typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim()
483
+ ? { apiBaseUrl: options.apiBaseUrl }
484
+ : {};
485
+ const forge = resolveForgeConfig({ ...(options.forge ?? {}), ...apiBaseUrlOverride });
486
+ return {
487
+ forge,
488
+ apiBaseUrl: forge.apiBaseUrl,
489
+ concurrency: normalizeLimit(options.concurrency, defaultConcurrency, 1, 10),
490
+ // Below/above these recorded-rate-limit-remaining marks the fanout serializes / runs at full concurrency; in
491
+ // between it scales down linearly (#4844).
492
+ rateLimitLowWaterMark: normalizeLimit(
493
+ options.rateLimitLowWaterMark,
494
+ DEFAULT_RATE_LIMIT_LOW_WATER_MARK,
495
+ 0,
496
+ 1_000_000,
497
+ ),
498
+ rateLimitHighWaterMark: normalizeLimit(
499
+ options.rateLimitHighWaterMark,
500
+ DEFAULT_RATE_LIMIT_HIGH_WATER_MARK,
501
+ 1,
502
+ 1_000_000,
503
+ ),
504
+ perPage: normalizeLimit(options.perPage, defaultPerPage, 1, 100),
505
+ maxPages: normalizeLimit(options.maxPages, defaultMaxPages, 1, 100),
506
+ // Passed through to the per-fetch retry so tests can inject an instant sleep; undefined uses the real backoff.
507
+ sleepFn: typeof options.sleepFn === "function" ? options.sleepFn : undefined,
508
+ // Optional local ETag cache for policy-doc revalidation (#4842). Absent (null) => every policy doc is fetched
509
+ // in full, exactly as before; discover-cli.js supplies the real on-disk store for a live run.
510
+ policyDocCache: options.policyDocCache ?? null,
511
+ // Optional local cache of resolved policy verdicts (#4843). Absent (null) => every verdict is resolved fresh,
512
+ // exactly as before; discover-cli.js supplies the real on-disk store for a live run.
513
+ policyVerdictCache: options.policyVerdictCache ?? null,
514
+ };
515
+ }
516
+
517
+ export async function fetchCandidateIssuesWithSummary(targets, githubToken, options = {}) {
518
+ const normalizedOptions = normalizeOptions(options);
519
+ const normalizedTargets = normalizeTargets(targets);
520
+ const summary = {
521
+ rateLimitRemaining: null,
522
+ rateLimitResetAt: null,
523
+ };
524
+ const warnings = [];
525
+ const batches = await mapWithConcurrency(
526
+ normalizedTargets,
527
+ normalizedOptions.concurrency,
528
+ (target) => fetchTargetIssues(target, githubToken, normalizedOptions, summary, warnings),
529
+ liveConcurrencyResolver(normalizedOptions, summary),
530
+ normalizedOptions.sleepFn,
531
+ );
532
+ return {
533
+ issues: batches.flat(),
534
+ rateLimitRemaining: summary.rateLimitRemaining,
535
+ rateLimitResetAt: summary.rateLimitResetAt,
536
+ warnings,
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Metadata-only GitHub discovery (#2307): never clones source, never fetches blobs beyond small policy docs,
542
+ * never uploads source, and never performs writes. Call the WithSummary variant when rate-limit telemetry is
543
+ * needed.
544
+ */
545
+ export async function fetchCandidateIssues(targets, githubToken, options = {}) {
546
+ const result = await fetchCandidateIssuesWithSummary(targets, githubToken, options);
547
+ return result.issues;
548
+ }
549
+
550
+ export async function searchCandidateIssuesWithSummary(searchQuery, githubToken, options = {}) {
551
+ const normalizedOptions = normalizeOptions(options);
552
+ const summary = {
553
+ rateLimitRemaining: null,
554
+ rateLimitResetAt: null,
555
+ };
556
+ const warnings = [];
557
+ const searchItems = await fetchSearchIssues(searchQuery, githubToken, normalizedOptions, summary, warnings);
558
+ const targetsByKey = new Map();
559
+ for (const item of searchItems) {
560
+ if (!item || typeof item !== "object" || item.pull_request) continue;
561
+ const target = targetFromSearchIssue(item, normalizedOptions.forge);
562
+ if (target && !targetsByKey.has(targetKey(target))) targetsByKey.set(targetKey(target), target);
563
+ }
564
+
565
+ const policyEntries = await mapWithConcurrency(
566
+ [...targetsByKey.values()],
567
+ normalizedOptions.concurrency,
568
+ async (target) => {
569
+ const verdict = await resolveRepoAiPolicy(target, githubToken, normalizedOptions, summary, warnings);
570
+ return [targetKey(target), verdict];
571
+ },
572
+ liveConcurrencyResolver(normalizedOptions, summary),
573
+ normalizedOptions.sleepFn,
574
+ );
575
+ const policiesByKey = new Map(policyEntries);
576
+ const issues = [];
577
+ for (const item of searchItems) {
578
+ const target = targetFromSearchIssue(item, normalizedOptions.forge);
579
+ if (!target) continue;
580
+ const policy = policiesByKey.get(targetKey(target));
581
+ if (!policy?.allowed) continue;
582
+ const normalizedIssue = normalizeIssue(target, item, policy.source);
583
+ if (normalizedIssue) issues.push(normalizedIssue);
584
+ }
585
+
586
+ return {
587
+ issues,
588
+ rateLimitRemaining: summary.rateLimitRemaining,
589
+ rateLimitResetAt: summary.rateLimitResetAt,
590
+ warnings,
591
+ };
592
+ }
593
+
594
+ export async function searchCandidateIssues(searchQuery, githubToken, options = {}) {
595
+ const result = await searchCandidateIssuesWithSummary(searchQuery, githubToken, options);
596
+ return result.issues;
597
+ }
@@ -0,0 +1,36 @@
1
+ import type { MinerGoalSpec } from "@loopover/engine";
2
+ import type { RawCandidateIssue } from "./opportunity-fanout.js";
3
+
4
+ export type RankedCandidateIssue = RawCandidateIssue & {
5
+ potential: number;
6
+ feasibility: number;
7
+ laneFit: number;
8
+ freshness: number;
9
+ dupRisk: number;
10
+ rankScore: number;
11
+ };
12
+
13
+ export type RankCandidateIssuesOptions = {
14
+ nowMs?: number;
15
+ highRiskDuplicateClusters?: number;
16
+ openPullRequests?: number;
17
+ goalSpecsByRepo?: Record<string, MinerGoalSpec>;
18
+ goalSpecContentByRepo?: Record<string, string>;
19
+ };
20
+
21
+ export type RankedCandidateSummary = {
22
+ issues: RankedCandidateIssue[];
23
+ skippedInvalid: number;
24
+ usedDefaultGoalSpec: boolean;
25
+ defaultGoalSpec: MinerGoalSpec;
26
+ };
27
+
28
+ export function rankCandidateIssues(
29
+ candidates: RawCandidateIssue[],
30
+ options?: RankCandidateIssuesOptions,
31
+ ): RankedCandidateIssue[];
32
+
33
+ export function rankCandidateIssuesWithSummary(
34
+ candidates: RawCandidateIssue[],
35
+ options?: RankCandidateIssuesOptions,
36
+ ): RankedCandidateSummary;