@h-rig/github-provider-plugin 0.0.6-alpha.156

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.
@@ -0,0 +1,147 @@
1
+ // @bun
2
+ // packages/github-provider-plugin/src/projects.ts
3
+ function asRecord(value) {
4
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
5
+ }
6
+ function asString(value) {
7
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
8
+ }
9
+ function asNumber(value) {
10
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
11
+ }
12
+ async function defaultGraphQLFetch(query, variables, token) {
13
+ const response = await fetch("https://api.github.com/graphql", {
14
+ method: "POST",
15
+ headers: {
16
+ "content-type": "application/json",
17
+ authorization: `Bearer ${token}`,
18
+ accept: "application/vnd.github+json"
19
+ },
20
+ body: JSON.stringify({ query, variables })
21
+ });
22
+ const json = await response.json().catch(() => ({}));
23
+ if (!response.ok || json.errors) {
24
+ throw new Error(`GitHub Projects GraphQL request failed: ${JSON.stringify(json.errors ?? { status: response.status })}`);
25
+ }
26
+ return json.data;
27
+ }
28
+ function projectNodesFrom(data) {
29
+ const root = asRecord(data);
30
+ const owner = asRecord(root?.organization) ?? asRecord(root?.user);
31
+ const projects = asRecord(owner?.projectsV2);
32
+ const nodes = projects?.nodes;
33
+ return Array.isArray(nodes) ? nodes : [];
34
+ }
35
+ async function listGitHubProjects(input) {
36
+ const query = `
37
+ query RigListProjects($owner: String!, $first: Int!) {
38
+ organization(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
39
+ user(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
40
+ }
41
+ `;
42
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
43
+ const data = await fetchGraphQL(query, { owner: input.owner, first: input.first ?? 20 }, input.token);
44
+ return projectNodesFrom(data).flatMap((node) => {
45
+ const record = asRecord(node);
46
+ const id = asString(record?.id);
47
+ const number = asNumber(record?.number);
48
+ const title = asString(record?.title);
49
+ if (!id || number === undefined || !title)
50
+ return [];
51
+ return [{ id, number, title, ...asString(record?.url) ? { url: asString(record?.url) } : {} }];
52
+ });
53
+ }
54
+ async function resolveProjectStatusField(input) {
55
+ const query = `
56
+ query RigProjectStatusField($projectId: ID!) {
57
+ node(id: $projectId) {
58
+ ... on ProjectV2 {
59
+ fields(first: 50) {
60
+ nodes {
61
+ ... on ProjectV2FieldCommon { id name }
62
+ ... on ProjectV2SingleSelectField { id name options { id name } }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ `;
69
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
70
+ const data = await fetchGraphQL(query, { projectId: input.projectId }, input.token);
71
+ const fields = asRecord(asRecord(asRecord(data)?.node)?.fields)?.nodes;
72
+ for (const node of Array.isArray(fields) ? fields : []) {
73
+ const record = asRecord(node);
74
+ if (asString(record?.name)?.toLowerCase() !== "status")
75
+ continue;
76
+ const id = asString(record?.id);
77
+ if (!id)
78
+ continue;
79
+ const options = Array.isArray(record?.options) ? record.options.flatMap((option) => {
80
+ const optionRecord = asRecord(option);
81
+ const optionId = asString(optionRecord?.id);
82
+ const name = asString(optionRecord?.name);
83
+ return optionId && name ? [{ id: optionId, name }] : [];
84
+ }) : [];
85
+ return { id, name: "Status", options };
86
+ }
87
+ throw new Error(`GitHub Project ${input.projectId} does not expose a Status single-select field.`);
88
+ }
89
+ async function ensureIssueProjectItem(input) {
90
+ const query = `
91
+ query RigFindProjectIssueItem($projectId: ID!, $issueNodeId: ID!) {
92
+ node(id: $projectId) {
93
+ ... on ProjectV2 {
94
+ items(first: 100) { nodes { id content { ... on Issue { id } } } }
95
+ }
96
+ }
97
+ }
98
+ `;
99
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
100
+ const data = await fetchGraphQL(query, { projectId: input.projectId, issueNodeId: input.issueNodeId }, input.token);
101
+ const nodes = asRecord(asRecord(asRecord(data)?.node)?.items)?.nodes;
102
+ for (const node of Array.isArray(nodes) ? nodes : []) {
103
+ const record = asRecord(node);
104
+ const content = asRecord(record?.content);
105
+ if (asString(content?.id) === input.issueNodeId) {
106
+ const id2 = asString(record?.id);
107
+ if (id2)
108
+ return { id: id2, created: false };
109
+ }
110
+ }
111
+ const mutation = `
112
+ mutation RigAddIssueToProject($projectId: ID!, $contentId: ID!) {
113
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } }
114
+ }
115
+ `;
116
+ const created = await fetchGraphQL(mutation, { projectId: input.projectId, contentId: input.issueNodeId }, input.token);
117
+ const addResult = asRecord(asRecord(created)?.addProjectV2ItemById);
118
+ const id = asString(asRecord(addResult?.item)?.id);
119
+ if (!id)
120
+ throw new Error("GitHub Project item creation did not return an item id.");
121
+ return { id, created: true };
122
+ }
123
+ async function updateIssueProjectStatus(input) {
124
+ const mutation = `
125
+ mutation RigUpdateProjectStatus($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
126
+ updateProjectV2ItemFieldValue(input: {
127
+ projectId: $projectId,
128
+ itemId: $itemId,
129
+ fieldId: $fieldId,
130
+ value: { singleSelectOptionId: $optionId }
131
+ }) { projectV2Item { id } }
132
+ }
133
+ `;
134
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
135
+ await fetchGraphQL(mutation, {
136
+ projectId: input.projectId,
137
+ itemId: input.itemId,
138
+ fieldId: input.fieldId,
139
+ optionId: input.optionId
140
+ }, input.token);
141
+ }
142
+ export {
143
+ updateIssueProjectStatus,
144
+ resolveProjectStatusField,
145
+ listGitHubProjects,
146
+ ensureIssueProjectItem
147
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Lazily-loaded GitHub-provider capability service. The config-light `plugin.ts`
3
+ * only imports this module inside the capability `run()`, so evaluating
4
+ * rig.config never pulls the GitHub implementation (fs/crypto/fetch) into scope.
5
+ */
6
+ import type { GitHubProviderService } from "@rig/contracts";
7
+ export declare const githubProviderService: GitHubProviderService;
@@ -0,0 +1,43 @@
1
+ // @bun
2
+ // packages/github-provider-plugin/src/credentials.ts
3
+ function selectedRepoTokenKey(input) {
4
+ return `user:${input.userId}|repo:${input.owner}/${input.repo}|workspace:${input.workspaceId}`;
5
+ }
6
+ function cleanToken(value) {
7
+ const trimmed = value?.trim() ?? "";
8
+ return trimmed.length > 0 ? trimmed : null;
9
+ }
10
+ function createGitHubCredentialProvider(options = {}) {
11
+ const sessionTokens = options.sessionTokens ?? {};
12
+ const hostToken = cleanToken(options.hostToken ?? process.env.GH_TOKEN ?? null);
13
+ return {
14
+ async resolveGitHubToken(input) {
15
+ const owner = input.owner.trim();
16
+ const repo = input.repo.trim();
17
+ const workspaceId = input.workspaceId.trim();
18
+ const userId = input.userId?.trim() ?? "";
19
+ if (input.purpose === "selected-repo") {
20
+ if (!owner || !repo || !workspaceId || !userId) {
21
+ throw new Error("No signed-in GitHub token is available for the selected repo; sign in to GitHub for this workspace.");
22
+ }
23
+ const token = cleanToken(sessionTokens[selectedRepoTokenKey({ owner, repo, workspaceId, userId })]);
24
+ if (!token) {
25
+ throw new Error("No signed-in GitHub token is available for the selected repo; sign in to GitHub for this workspace.");
26
+ }
27
+ return { token, source: "signed-in-user" };
28
+ }
29
+ if (hostToken) {
30
+ return { token: hostToken, source: "host-admin-fallback" };
31
+ }
32
+ throw new Error("No host GitHub token is configured for the explicit admin fallback operation.");
33
+ }
34
+ };
35
+ }
36
+
37
+ // packages/github-provider-plugin/src/service.ts
38
+ var githubProviderService = {
39
+ createCredentialProvider: (options) => createGitHubCredentialProvider(options)
40
+ };
41
+ export {
42
+ githubProviderService
43
+ };
@@ -0,0 +1,25 @@
1
+ import { type IssueAnalyzer, type IssueAnalysisPluginContext, type PiIssueAnalysisCommandRunner } from "./issue-analysis";
2
+ export interface IssueAnalysisTriageRunResult {
3
+ ok: true;
4
+ enabled: boolean;
5
+ reason: string;
6
+ sourceId: string | null;
7
+ sourceKind: string | null;
8
+ analyzedIssues: number;
9
+ metadataPatches: number;
10
+ labelsAdded: number;
11
+ labelsRemoved: number;
12
+ generatedIssues: number;
13
+ writeBackRefreshes: number;
14
+ refreshedIssueCount: number | null;
15
+ skippedReason?: "no-config" | "disabled";
16
+ }
17
+ export interface RunIssueAnalysisTriageOptions {
18
+ projectRoot: string;
19
+ reason?: string;
20
+ context?: IssueAnalysisPluginContext;
21
+ analyzer?: IssueAnalyzer;
22
+ runCommand?: PiIssueAnalysisCommandRunner;
23
+ onWriteBack?: () => void | Promise<void>;
24
+ }
25
+ export declare function runIssueAnalysisTriage(options: RunIssueAnalysisTriageOptions): Promise<IssueAnalysisTriageRunResult>;
@@ -0,0 +1,474 @@
1
+ // @bun
2
+ // packages/github-provider-plugin/src/triage-run.ts
3
+ import { buildPluginHostContext } from "@rig/runtime/control-plane/plugin-host-context";
4
+
5
+ // packages/github-provider-plugin/src/issue-analysis.ts
6
+ import { createHash } from "crypto";
7
+ function stableIssueHash(issue) {
8
+ const labels = Array.isArray(issue.labels) ? [...issue.labels].map(String).sort() : [];
9
+ const body = typeof issue.body === "string" ? issue.body : "";
10
+ const title = typeof issue.title === "string" ? issue.title : "";
11
+ return createHash("sha256").update(JSON.stringify({ id: issue.id, title, body, labels, deps: issue.deps, status: issue.status })).digest("hex");
12
+ }
13
+ function renderIssueAnalysisPrompt(input) {
14
+ const issue = input.issue;
15
+ const neighbors = input.neighbors ?? [];
16
+ return [
17
+ "You are Rig issue analysis running inside Pi.",
18
+ "Return JSON only with optional metadataPatch, labelsToAdd, labelsToRemove, and generatedIssues; analyze backlog dependencies, children, readiness, size, risk, and planning.",
19
+ "Preserve all human-authored issue body content. Only propose edits for Rig-owned metadata/status sections, labels, and generated issues.",
20
+ "Generated issues must be concrete, minimal follow-up tasks and will be labeled rig:generated by Rig.",
21
+ "",
22
+ "Issue:",
23
+ JSON.stringify({
24
+ id: issue.id,
25
+ title: issue.title,
26
+ body: issue.body,
27
+ labels: issue.labels,
28
+ deps: issue.deps,
29
+ status: issue.status
30
+ }, null, 2),
31
+ "",
32
+ "Neighbor tasks:",
33
+ JSON.stringify(neighbors.map((task) => ({ id: task.id, title: task.title, status: task.status, deps: task.deps })), null, 2)
34
+ ].join(`
35
+ `);
36
+ }
37
+ function isRecord(value) {
38
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
39
+ }
40
+ function stringArray(value) {
41
+ if (!Array.isArray(value))
42
+ return;
43
+ return value.map(String).filter((entry) => entry.trim().length > 0);
44
+ }
45
+ function generatedIssues(value) {
46
+ if (!Array.isArray(value))
47
+ return;
48
+ return value.flatMap((entry) => {
49
+ if (!isRecord(entry) || typeof entry.title !== "string")
50
+ return [];
51
+ return [{
52
+ title: entry.title,
53
+ body: typeof entry.body === "string" ? entry.body : "",
54
+ labels: stringArray(entry.labels) ?? [],
55
+ ...Array.isArray(entry.dependsOn) ? { dependsOn: entry.dependsOn.map(String) } : {}
56
+ }];
57
+ });
58
+ }
59
+ function findJsonLikeText(value) {
60
+ if (typeof value === "string") {
61
+ const trimmed = value.trim();
62
+ if (trimmed.startsWith("{") || trimmed.startsWith("```"))
63
+ return trimmed;
64
+ return null;
65
+ }
66
+ if (Array.isArray(value)) {
67
+ for (const entry of value) {
68
+ const found = findJsonLikeText(entry);
69
+ if (found)
70
+ return found;
71
+ }
72
+ return null;
73
+ }
74
+ if (!isRecord(value))
75
+ return null;
76
+ for (const key of ["text", "content", "message", "output_text", "response", "stdout"]) {
77
+ const found = findJsonLikeText(value[key]);
78
+ if (found)
79
+ return found;
80
+ }
81
+ for (const entry of Object.values(value)) {
82
+ const found = findJsonLikeText(entry);
83
+ if (found)
84
+ return found;
85
+ }
86
+ return null;
87
+ }
88
+ function candidateAnalysisObject(value) {
89
+ if (!isRecord(value))
90
+ return null;
91
+ if (isRecord(value.result))
92
+ return candidateAnalysisObject(value.result) ?? value.result;
93
+ if (isRecord(value.analysis))
94
+ return candidateAnalysisObject(value.analysis) ?? value.analysis;
95
+ if (isRecord(value.metadataPatch) || Array.isArray(value.labelsToAdd) || Array.isArray(value.labelsToRemove) || Array.isArray(value.generatedIssues)) {
96
+ return value;
97
+ }
98
+ const nested = findJsonLikeText(value);
99
+ if (nested && nested !== JSON.stringify(value)) {
100
+ try {
101
+ const parsedNested = JSON.parse(nested.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim() ?? nested);
102
+ return candidateAnalysisObject(parsedNested);
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ function parseIssueAnalysisResult(raw) {
110
+ let parsed = raw;
111
+ if (typeof raw === "string") {
112
+ const trimmed = raw.trim();
113
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1]?.trim();
114
+ try {
115
+ parsed = JSON.parse(fenced ?? trimmed);
116
+ } catch {
117
+ const lastJsonLine = trimmed.split(/\r?\n/).reverse().find((line) => line.trim().startsWith("{"));
118
+ parsed = lastJsonLine ? JSON.parse(lastJsonLine) : {};
119
+ }
120
+ }
121
+ const candidate = candidateAnalysisObject(parsed);
122
+ if (!candidate)
123
+ return {};
124
+ const result = {};
125
+ if (isRecord(candidate.metadataPatch))
126
+ result.metadataPatch = candidate.metadataPatch;
127
+ const add = stringArray(candidate.labelsToAdd);
128
+ if (add?.length)
129
+ result.labelsToAdd = add;
130
+ const remove = stringArray(candidate.labelsToRemove);
131
+ if (remove?.length)
132
+ result.labelsToRemove = remove;
133
+ const generated = generatedIssues(candidate.generatedIssues);
134
+ if (generated?.length)
135
+ result.generatedIssues = generated;
136
+ return result;
137
+ }
138
+ function createDefaultPiIssueAnalysisCommandRunner() {
139
+ return async (command, args, options) => {
140
+ const env = options.env ? { ...process.env, ...options.env } : process.env;
141
+ const proc = Bun.spawn([command, ...args], {
142
+ stdout: "pipe",
143
+ stderr: "pipe",
144
+ env
145
+ });
146
+ let timedOut = false;
147
+ const timer = setTimeout(() => {
148
+ timedOut = true;
149
+ proc.kill();
150
+ }, options.timeoutMs);
151
+ try {
152
+ const [stdout, stderr, exitCode] = await Promise.all([
153
+ new Response(proc.stdout).text(),
154
+ new Response(proc.stderr).text(),
155
+ proc.exited
156
+ ]);
157
+ return {
158
+ exitCode: timedOut && exitCode === 0 ? 1 : exitCode,
159
+ stdout,
160
+ stderr: timedOut && stderr.trim().length === 0 ? `Pi issue analysis timed out after ${options.timeoutMs}ms` : stderr
161
+ };
162
+ } finally {
163
+ clearTimeout(timer);
164
+ }
165
+ };
166
+ }
167
+ function createPiIssueAnalyzer(input = {}) {
168
+ const piBinary = input.piBinary ?? process.env.RIG_ISSUE_ANALYSIS_PI_BINARY ?? "pi";
169
+ const timeoutMs = Math.max(1000, Math.trunc(input.timeoutMs ?? Number(process.env.RIG_ISSUE_ANALYSIS_TIMEOUT_MS ?? 120000)));
170
+ const runCommand = input.runCommand ?? createDefaultPiIssueAnalysisCommandRunner();
171
+ return async ({ prompt }) => {
172
+ const args = ["--print", "--mode", "json", "--no-session"];
173
+ const provider = input.provider?.trim() || process.env.RIG_ISSUE_ANALYSIS_PROVIDER?.trim() || process.env.RIG_PI_PROVIDER?.trim();
174
+ const model = input.model?.trim() || process.env.RIG_ISSUE_ANALYSIS_MODEL?.trim() || process.env.RIG_PI_MODEL?.trim() || "openai-codex/gpt-5.5";
175
+ if (provider)
176
+ args.push("--provider", provider);
177
+ if (model)
178
+ args.push("--model", model);
179
+ args.push(prompt);
180
+ const result = await runCommand(piBinary, args, { timeoutMs, ...input.env ? { env: input.env } : {} });
181
+ if (result.exitCode !== 0) {
182
+ throw new Error(`Pi issue analysis failed (exit ${result.exitCode}): ${result.stderr ?? result.stdout}`);
183
+ }
184
+ return parseIssueAnalysisResult(result.stdout);
185
+ };
186
+ }
187
+ function defaultStatusComment(input) {
188
+ const changes = [
189
+ input.result.metadataPatch ? "metadata" : null,
190
+ input.result.labelsToAdd?.length ? `labels added: ${input.result.labelsToAdd.join(", ")}` : null,
191
+ input.result.labelsToRemove?.length ? `labels removed: ${input.result.labelsToRemove.join(", ")}` : null,
192
+ input.result.generatedIssues?.length ? `generated issues: ${input.result.generatedIssues.length}` : null
193
+ ].filter((entry) => Boolean(entry));
194
+ if (changes.length === 0)
195
+ return null;
196
+ return [
197
+ "<!-- rig:status-comment -->",
198
+ "### Rig issue analysis",
199
+ "",
200
+ `Analyzed issue ${input.issue.id}${input.reason ? ` (${input.reason})` : ""}.`,
201
+ "",
202
+ ...changes.map((change) => `- ${change}`)
203
+ ].join(`
204
+ `);
205
+ }
206
+ function uniqueLabels(labels, required = []) {
207
+ return [...new Set([...labels ?? [], ...required].map((label) => label.trim()).filter(Boolean))];
208
+ }
209
+ function createIssueAnalysisWriteBack(input) {
210
+ return async ({ issue, result, reason }) => {
211
+ if (result.metadataPatch && Object.keys(result.metadataPatch).length > 0) {
212
+ if (!input.target.updateTask)
213
+ throw new Error("Issue analysis writeback requires updateTask for metadata patches.");
214
+ await input.target.updateTask(issue.id, { metadata: result.metadataPatch });
215
+ }
216
+ if (result.labelsToAdd?.length) {
217
+ if (!input.target.addLabels)
218
+ throw new Error("Issue analysis writeback requires addLabels for labelsToAdd.");
219
+ await input.target.addLabels(issue.id, uniqueLabels(result.labelsToAdd));
220
+ }
221
+ if (result.labelsToRemove?.length) {
222
+ if (!input.target.removeLabels)
223
+ throw new Error("Issue analysis writeback requires removeLabels for labelsToRemove.");
224
+ await input.target.removeLabels(issue.id, uniqueLabels(result.labelsToRemove));
225
+ }
226
+ const comment = (input.buildStatusComment ?? defaultStatusComment)({ issue, result, reason });
227
+ if (comment?.trim()) {
228
+ if (!input.target.updateTask)
229
+ throw new Error("Issue analysis writeback requires updateTask for sticky status comments.");
230
+ await input.target.updateTask(issue.id, { comment });
231
+ }
232
+ for (const generated of result.generatedIssues ?? []) {
233
+ if (!input.target.createIssue)
234
+ throw new Error("Issue analysis writeback requires createIssue for generated issues.");
235
+ await input.target.createIssue({
236
+ title: generated.title,
237
+ body: generated.dependsOn?.length ? `${generated.body.trimEnd()}
238
+
239
+ depends-on: ${generated.dependsOn.map((dep) => dep.startsWith("#") ? dep : `#${dep}`).join(", ")}
240
+ ` : generated.body,
241
+ labels: uniqueLabels(generated.labels, ["rig:generated"])
242
+ });
243
+ }
244
+ };
245
+ }
246
+ function sourceWithWriteBackCapabilities(source) {
247
+ const candidate = source;
248
+ if (typeof candidate.updateTask !== "function")
249
+ return null;
250
+ return {
251
+ get: candidate.get?.bind(candidate),
252
+ updateTask: candidate.updateTask.bind(candidate),
253
+ ...typeof candidate.addLabels === "function" ? { addLabels: candidate.addLabels.bind(candidate) } : {},
254
+ ...typeof candidate.removeLabels === "function" ? { removeLabels: candidate.removeLabels.bind(candidate) } : {},
255
+ ...typeof candidate.createIssue === "function" ? { createIssue: candidate.createIssue.bind(candidate) } : {}
256
+ };
257
+ }
258
+ function issueAnalysisEnabled(config) {
259
+ const issueAnalysis = config.issueAnalysis;
260
+ if (!issueAnalysis)
261
+ return false;
262
+ if (issueAnalysis.enabled !== true)
263
+ return false;
264
+ if (issueAnalysis.mode === "off")
265
+ return false;
266
+ if (issueAnalysis.harness && issueAnalysis.harness !== "pi")
267
+ return false;
268
+ return true;
269
+ }
270
+ function createConfiguredIssueAnalysisRunner(input) {
271
+ if (!issueAnalysisEnabled(input.context.config))
272
+ return null;
273
+ const source = input.context.taskSourceRegistry.list()[0];
274
+ if (!source)
275
+ return null;
276
+ const target = sourceWithWriteBackCapabilities(source);
277
+ if (!target)
278
+ return null;
279
+ const analyzer = input.analyzer ?? createPiIssueAnalyzer({
280
+ runCommand: input.runCommand,
281
+ model: input.context.config.issueAnalysis?.model
282
+ });
283
+ const baseWriteBack = createIssueAnalysisWriteBack({ target });
284
+ const service = createIssueAnalysisService({
285
+ analyzer,
286
+ writeBack: async (writeBackInput) => {
287
+ await baseWriteBack(writeBackInput);
288
+ await input.onWriteBack?.();
289
+ }
290
+ });
291
+ return createContinuousIssueAnalysisRunner({
292
+ loadIssues: async () => [...await source.list()],
293
+ service,
294
+ intervalMs: input.intervalMs,
295
+ reason: "continuous-issue-analysis",
296
+ ...input.setIntervalFn ? { setIntervalFn: input.setIntervalFn } : {},
297
+ ...input.clearIntervalFn ? { clearIntervalFn: input.clearIntervalFn } : {},
298
+ ...input.onError ? { onError: input.onError } : {}
299
+ });
300
+ }
301
+ function createIssueAnalysisService(input) {
302
+ const analyzedHashes = new Map;
303
+ return {
304
+ async analyze(issues, options = {}) {
305
+ const results = [];
306
+ const neighbors = options.neighbors ?? issues;
307
+ for (const issue of issues) {
308
+ const hash = stableIssueHash(issue);
309
+ if (analyzedHashes.get(issue.id) === hash)
310
+ continue;
311
+ const prompt = renderIssueAnalysisPrompt({ issue, neighbors: neighbors.filter((candidate) => candidate.id !== issue.id) });
312
+ const result = await input.analyzer({ issue, neighbors, prompt });
313
+ analyzedHashes.set(issue.id, hash);
314
+ if (result.metadataPatch || result.labelsToAdd?.length || result.labelsToRemove?.length || result.generatedIssues?.length) {
315
+ await input.writeBack?.({ issue, result, reason: options.reason });
316
+ }
317
+ results.push({ issue, result });
318
+ }
319
+ return results;
320
+ },
321
+ clearCache() {
322
+ analyzedHashes.clear();
323
+ }
324
+ };
325
+ }
326
+ function createContinuousIssueAnalysisRunner(input) {
327
+ const intervalMs = Math.max(1000, Math.trunc(input.intervalMs ?? 60000));
328
+ const setIntervalFn = input.setIntervalFn ?? ((callback, ms) => setInterval(() => {
329
+ callback();
330
+ }, ms));
331
+ const clearIntervalFn = input.clearIntervalFn ?? ((timer2) => clearInterval(timer2));
332
+ let timer;
333
+ let running = false;
334
+ let inFlight = null;
335
+ const tick = async (reason = input.reason ?? "continuous") => {
336
+ if (inFlight)
337
+ return inFlight;
338
+ inFlight = (async () => {
339
+ const issues = await input.loadIssues();
340
+ return input.service.analyze(issues, { reason });
341
+ })();
342
+ try {
343
+ return await inFlight;
344
+ } finally {
345
+ inFlight = null;
346
+ }
347
+ };
348
+ return {
349
+ start() {
350
+ if (running)
351
+ return;
352
+ running = true;
353
+ timer = setIntervalFn(async () => {
354
+ try {
355
+ await tick();
356
+ } catch (error) {
357
+ input.onError?.(error);
358
+ }
359
+ }, intervalMs);
360
+ },
361
+ stop() {
362
+ if (!running)
363
+ return;
364
+ running = false;
365
+ if (timer !== undefined)
366
+ clearIntervalFn(timer);
367
+ timer = undefined;
368
+ },
369
+ tick,
370
+ isRunning() {
371
+ return running;
372
+ }
373
+ };
374
+ }
375
+
376
+ // packages/github-provider-plugin/src/triage-run.ts
377
+ function summarizeResults(results) {
378
+ return results.reduce((summary, entry) => {
379
+ if (entry.result.metadataPatch && Object.keys(entry.result.metadataPatch).length > 0) {
380
+ summary.metadataPatches += 1;
381
+ }
382
+ summary.labelsAdded += entry.result.labelsToAdd?.length ?? 0;
383
+ summary.labelsRemoved += entry.result.labelsToRemove?.length ?? 0;
384
+ summary.generatedIssues += entry.result.generatedIssues?.length ?? 0;
385
+ return summary;
386
+ }, { metadataPatches: 0, labelsAdded: 0, labelsRemoved: 0, generatedIssues: 0 });
387
+ }
388
+ async function loadContext(projectRoot) {
389
+ const context = await buildPluginHostContext(projectRoot);
390
+ if (!context)
391
+ return null;
392
+ return {
393
+ config: context.config,
394
+ taskSourceRegistry: context.taskSourceRegistry
395
+ };
396
+ }
397
+ async function runIssueAnalysisTriage(options) {
398
+ const reason = options.reason?.trim() || "triage";
399
+ const context = options.context ?? await loadContext(options.projectRoot);
400
+ if (!context) {
401
+ return {
402
+ ok: true,
403
+ enabled: false,
404
+ reason,
405
+ sourceId: null,
406
+ sourceKind: null,
407
+ analyzedIssues: 0,
408
+ metadataPatches: 0,
409
+ labelsAdded: 0,
410
+ labelsRemoved: 0,
411
+ generatedIssues: 0,
412
+ writeBackRefreshes: 0,
413
+ refreshedIssueCount: null,
414
+ skippedReason: "no-config"
415
+ };
416
+ }
417
+ const source = context.taskSourceRegistry.list()[0] ?? null;
418
+ const sourceId = source?.id ?? null;
419
+ const sourceKind = source?.kind ?? null;
420
+ if (!issueAnalysisEnabled(context.config)) {
421
+ return {
422
+ ok: true,
423
+ enabled: false,
424
+ reason,
425
+ sourceId,
426
+ sourceKind,
427
+ analyzedIssues: 0,
428
+ metadataPatches: 0,
429
+ labelsAdded: 0,
430
+ labelsRemoved: 0,
431
+ generatedIssues: 0,
432
+ writeBackRefreshes: 0,
433
+ refreshedIssueCount: null,
434
+ skippedReason: "disabled"
435
+ };
436
+ }
437
+ if (!source) {
438
+ throw new Error("Issue analysis is enabled, but no configured task source is registered.");
439
+ }
440
+ let writeBackRefreshes = 0;
441
+ let refreshedIssueCount = null;
442
+ const refreshSnapshotAfterWriteBack = async () => {
443
+ writeBackRefreshes += 1;
444
+ const refreshed = await source.list();
445
+ refreshedIssueCount = refreshed.length;
446
+ await options.onWriteBack?.();
447
+ };
448
+ const runner = createConfiguredIssueAnalysisRunner({
449
+ projectRoot: options.projectRoot,
450
+ context,
451
+ analyzer: options.analyzer,
452
+ runCommand: options.runCommand,
453
+ onWriteBack: refreshSnapshotAfterWriteBack
454
+ });
455
+ if (!runner) {
456
+ throw new Error(`Issue analysis is enabled for ${sourceKind ?? "the configured source"}, but that task source does not expose Rig write-back capabilities.`);
457
+ }
458
+ const results = await runner.tick(reason);
459
+ const summary = summarizeResults(results);
460
+ return {
461
+ ok: true,
462
+ enabled: true,
463
+ reason,
464
+ sourceId,
465
+ sourceKind,
466
+ analyzedIssues: results.length,
467
+ ...summary,
468
+ writeBackRefreshes,
469
+ refreshedIssueCount
470
+ };
471
+ }
472
+ export {
473
+ runIssueAnalysisTriage
474
+ };