better-symphony 1.0.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 (63) hide show
  1. package/CLAUDE.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +292 -0
  4. package/dist/web/app.css +2 -0
  5. package/dist/web/index.html +13 -0
  6. package/dist/web/main.js +235 -0
  7. package/package.json +62 -0
  8. package/src/agent/claude-runner.ts +576 -0
  9. package/src/agent/protocol.ts +2 -0
  10. package/src/agent/runner.ts +2 -0
  11. package/src/agent/session.ts +113 -0
  12. package/src/cli.ts +354 -0
  13. package/src/config/loader.ts +379 -0
  14. package/src/config/types.ts +382 -0
  15. package/src/index.ts +53 -0
  16. package/src/linear-cli.ts +414 -0
  17. package/src/logging/logger.ts +143 -0
  18. package/src/orchestrator/multi-orchestrator.ts +266 -0
  19. package/src/orchestrator/orchestrator.ts +1357 -0
  20. package/src/orchestrator/scheduler.ts +195 -0
  21. package/src/orchestrator/state.ts +201 -0
  22. package/src/prompts/github-system-prompt.md +51 -0
  23. package/src/prompts/linear-system-prompt.md +44 -0
  24. package/src/tracker/client.ts +577 -0
  25. package/src/tracker/github-issues-tracker.ts +280 -0
  26. package/src/tracker/github-pr-tracker.ts +298 -0
  27. package/src/tracker/index.ts +9 -0
  28. package/src/tracker/interface.ts +76 -0
  29. package/src/tracker/linear-tracker.ts +147 -0
  30. package/src/tracker/queries.ts +281 -0
  31. package/src/tracker/types.ts +125 -0
  32. package/src/tui/App.tsx +157 -0
  33. package/src/tui/LogView.tsx +120 -0
  34. package/src/tui/StatusBar.tsx +72 -0
  35. package/src/tui/TabBar.tsx +55 -0
  36. package/src/tui/sink.ts +47 -0
  37. package/src/tui/types.ts +6 -0
  38. package/src/tui/useOrchestrator.ts +244 -0
  39. package/src/web/server.ts +182 -0
  40. package/src/web/sink.ts +67 -0
  41. package/src/web-ui/App.tsx +60 -0
  42. package/src/web-ui/components/agent-table.tsx +57 -0
  43. package/src/web-ui/components/header.tsx +72 -0
  44. package/src/web-ui/components/log-stream.tsx +111 -0
  45. package/src/web-ui/components/retry-table.tsx +58 -0
  46. package/src/web-ui/components/stats-cards.tsx +142 -0
  47. package/src/web-ui/components/ui/badge.tsx +30 -0
  48. package/src/web-ui/components/ui/button.tsx +39 -0
  49. package/src/web-ui/components/ui/card.tsx +32 -0
  50. package/src/web-ui/globals.css +27 -0
  51. package/src/web-ui/index.html +13 -0
  52. package/src/web-ui/lib/use-sse.ts +98 -0
  53. package/src/web-ui/lib/utils.ts +25 -0
  54. package/src/web-ui/main.tsx +4 -0
  55. package/src/workspace/hooks.ts +97 -0
  56. package/src/workspace/manager.ts +211 -0
  57. package/src/workspace/render-hook.ts +13 -0
  58. package/workflows/dev.md +127 -0
  59. package/workflows/github-issues.md +107 -0
  60. package/workflows/pr-review.md +89 -0
  61. package/workflows/prd.md +170 -0
  62. package/workflows/ralph.md +95 -0
  63. package/workflows/smoke.md +66 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * GitHub Issues Tracker for Symphony
3
+ * Implements Tracker interface using gh CLI for GitHub Issues (not PRs)
4
+ */
5
+
6
+ import type { Issue, Comment } from "../config/types.js";
7
+ import type { Tracker, TrackerConfig, FetchOptions } from "./interface.js";
8
+ import { execSync } from "child_process";
9
+
10
+ /**
11
+ * Raw GitHub issue structure from gh CLI JSON output
12
+ */
13
+ interface GitHubIssue {
14
+ number: number;
15
+ title: string;
16
+ body: string;
17
+ author: { login: string };
18
+ labels: { name: string }[];
19
+ state: string; // "OPEN" or "CLOSED"
20
+ assignees: { login: string }[];
21
+ milestone: { title: string } | null;
22
+ createdAt: string;
23
+ updatedAt: string;
24
+ comments?: { author: { login: string }; body: string; createdAt: string }[];
25
+ }
26
+
27
+ /**
28
+ * GitHub Issues Tracker
29
+ * Fetches issues from a GitHub repository using the gh CLI
30
+ */
31
+ export class GitHubIssuesTracker implements Tracker {
32
+ private repo: string;
33
+ private excludedLabels: string[];
34
+ private requiredLabels: string[];
35
+ private activeStates: string[];
36
+ private terminalStates: string[];
37
+
38
+ /**
39
+ * Create a new GitHub Issues tracker
40
+ * @param config - Tracker configuration (must include repo)
41
+ * @throws Error if repo is not specified
42
+ */
43
+ constructor(config: TrackerConfig) {
44
+ if (!config.repo) {
45
+ throw new Error("GitHub Issues tracker requires 'repo' in config (e.g., 'owner/repo')");
46
+ }
47
+ this.repo = config.repo;
48
+ this.excludedLabels = config.excluded_labels ?? [];
49
+ this.requiredLabels = config.required_labels ?? [];
50
+ this.activeStates = config.active_states ?? ["open"];
51
+ this.terminalStates = config.terminal_states ?? ["closed"];
52
+ }
53
+
54
+ /**
55
+ * Execute a gh CLI command
56
+ * @param args - Arguments to pass to gh
57
+ * @returns Command output as string
58
+ */
59
+ private gh(args: string): string {
60
+ try {
61
+ return execSync(`gh ${args}`, {
62
+ encoding: "utf-8",
63
+ timeout: 30000,
64
+ env: { ...process.env, GH_REPO: this.repo },
65
+ }).trim();
66
+ } catch (err: any) {
67
+ throw new Error(`gh CLI failed: ${err.message}`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Execute a gh CLI command and parse JSON output
73
+ * @param args - Arguments to pass to gh
74
+ * @returns Parsed JSON response
75
+ */
76
+ private ghJson<T>(args: string): T {
77
+ const output = this.gh(args);
78
+ return JSON.parse(output) as T;
79
+ }
80
+
81
+ /**
82
+ * Convert a GitHub issue to Symphony Issue format
83
+ * @param issue - Raw GitHub issue from gh CLI
84
+ * @returns Symphony Issue object
85
+ */
86
+ private issueToSymphonyIssue(issue: GitHubIssue): Issue {
87
+ return {
88
+ id: `ISSUE-${issue.number}`,
89
+ identifier: `ISSUE-${issue.number}`,
90
+ title: issue.title,
91
+ description: issue.body || "",
92
+ priority: null,
93
+ url: `https://github.com/${this.repo}/issues/${issue.number}`,
94
+ branch_name: null,
95
+ state: issue.state === "OPEN" ? "open" : "closed",
96
+ labels: issue.labels.map((l) => l.name),
97
+ author: issue.author.login,
98
+ number: issue.number,
99
+ children: [],
100
+ blocked_by: [],
101
+ comments: (issue.comments ?? []).map((c) => ({
102
+ id: `${issue.number}-${c.createdAt}`,
103
+ body: c.body,
104
+ created_at: new Date(c.createdAt),
105
+ user: c.author.login,
106
+ })),
107
+ created_at: new Date(issue.createdAt),
108
+ updated_at: new Date(issue.updatedAt),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Fetch issues that are candidates for processing
114
+ * @param options - Fetch options (labels, states, limit)
115
+ * @returns Array of Symphony Issues
116
+ */
117
+ async fetchCandidates(options: FetchOptions): Promise<Issue[]> {
118
+ const excludeLabels = [...this.excludedLabels, ...(options.excludedLabels ?? [])];
119
+ const requireLabels = [...this.requiredLabels, ...(options.requiredLabels ?? [])];
120
+ const activeStates = options.activeStates ?? this.activeStates;
121
+
122
+ // Build state filter - gh issue list uses "open" or "closed"
123
+ const stateFilter = activeStates.includes("open") ? "open" : "closed";
124
+
125
+ // Build label filter for required labels
126
+ const labelFilter = requireLabels.length > 0
127
+ ? requireLabels.map((l) => `--label "${l}"`).join(" ")
128
+ : "";
129
+
130
+ // Fetch issues with full details
131
+ const issues = this.ghJson<GitHubIssue[]>(
132
+ `issue list --state ${stateFilter} ${labelFilter} --json number,title,body,author,labels,state,assignees,milestone,createdAt,updatedAt`
133
+ );
134
+
135
+ // Filter by excluded labels
136
+ const filtered = issues.filter((issue) => {
137
+ const issueLabels = issue.labels.map((l) => l.name);
138
+
139
+ // Must not have any excluded labels
140
+ if (excludeLabels.some((el) => issueLabels.includes(el))) {
141
+ return false;
142
+ }
143
+
144
+ return true;
145
+ });
146
+
147
+ // Sort by created date (oldest first)
148
+ filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
149
+
150
+ // Apply limit
151
+ const limited = options.limit ? filtered.slice(0, options.limit) : filtered;
152
+
153
+ return limited.map((issue) => this.issueToSymphonyIssue(issue));
154
+ }
155
+
156
+ /**
157
+ * Get a single issue by identifier
158
+ * @param identifier - Issue identifier (e.g., "ISSUE-123" or "123")
159
+ * @returns Symphony Issue or null if not found
160
+ */
161
+ async getIssue(identifier: string): Promise<Issue | null> {
162
+ const number = identifier.replace("ISSUE-", "");
163
+ try {
164
+ const issue = this.ghJson<GitHubIssue>(
165
+ `issue view ${number} --json number,title,body,author,labels,state,assignees,milestone,createdAt,updatedAt,comments`
166
+ );
167
+ return this.issueToSymphonyIssue(issue);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Fetch issues in terminal states (for cleanup)
175
+ * @param terminalStates - Array of terminal state names
176
+ * @returns Array of Symphony Issues in terminal states
177
+ */
178
+ async fetchTerminalIssues(terminalStates: string[]): Promise<Issue[]> {
179
+ // For GitHub Issues, "terminal" typically means closed
180
+ // Or having the excluded label
181
+ if (this.excludedLabels.length === 0 && !terminalStates.includes("closed")) {
182
+ return [];
183
+ }
184
+
185
+ // Fetch closed issues with excluded labels
186
+ const labelFilter = this.excludedLabels.length > 0
187
+ ? `--label "${this.excludedLabels[0]}"`
188
+ : "";
189
+
190
+ const state = terminalStates.includes("closed") ? "closed" : "all";
191
+
192
+ const issues = this.ghJson<GitHubIssue[]>(
193
+ `issue list --state ${state} ${labelFilter} --json number,title,body,author,labels,state,createdAt,updatedAt`
194
+ );
195
+
196
+ return issues.map((issue) => this.issueToSymphonyIssue(issue));
197
+ }
198
+
199
+ /**
200
+ * Batch fetch states for multiple issues
201
+ * @param ids - Array of issue identifiers
202
+ * @returns Map of identifier to state
203
+ */
204
+ async fetchStatesByIds(ids: string[]): Promise<Map<string, string>> {
205
+ const result = new Map<string, string>();
206
+ for (const id of ids) {
207
+ const issue = await this.getIssue(id);
208
+ if (issue) {
209
+ result.set(id, issue.state);
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
215
+ /**
216
+ * Add or update a comment on an issue
217
+ * @param issueId - Issue identifier
218
+ * @param body - Comment body
219
+ * @param commentId - Optional comment ID for updates (not supported by gh CLI)
220
+ * @returns URL of the created comment
221
+ */
222
+ async upsertComment(issueId: string, body: string, commentId?: string): Promise<string> {
223
+ const number = issueId.replace("ISSUE-", "");
224
+ // gh doesn't support editing comments easily, so we just add new ones
225
+ const escapedBody = body.replace(/"/g, '\\"').replace(/`/g, '\\`');
226
+ const result = this.gh(`issue comment ${number} --body "${escapedBody}"`);
227
+ // Extract comment URL from result
228
+ const match = result.match(/https:\/\/github\.com\/[^\s]+/);
229
+ return match ? match[0] : "";
230
+ }
231
+
232
+ /**
233
+ * Add a label to an issue
234
+ * @param issueId - Issue identifier
235
+ * @param label - Label name to add
236
+ */
237
+ async addLabel(issueId: string, label: string): Promise<void> {
238
+ const number = issueId.replace("ISSUE-", "");
239
+ this.gh(`issue edit ${number} --add-label "${label}"`);
240
+ }
241
+
242
+ /**
243
+ * Remove a label from an issue
244
+ * @param issueId - Issue identifier
245
+ * @param label - Label name to remove
246
+ */
247
+ async removeLabel(issueId: string, label: string): Promise<void> {
248
+ const number = issueId.replace("ISSUE-", "");
249
+ this.gh(`issue edit ${number} --remove-label "${label}"`);
250
+ }
251
+
252
+ /**
253
+ * Update issue state (open/close)
254
+ * @param issueId - Issue identifier
255
+ * @param state - New state ("open" or "closed")
256
+ */
257
+ async updateState(issueId: string, state: string): Promise<void> {
258
+ const number = issueId.replace("ISSUE-", "");
259
+ const normalizedState = state.toLowerCase();
260
+
261
+ if (normalizedState === "closed" || normalizedState === "close") {
262
+ this.gh(`issue close ${number}`);
263
+ } else if (normalizedState === "open" || normalizedState === "reopen") {
264
+ this.gh(`issue reopen ${number}`);
265
+ }
266
+ // Other states are handled via labels
267
+ }
268
+
269
+ /**
270
+ * Get rate limit state
271
+ * gh CLI handles rate limiting internally, so return generous defaults
272
+ */
273
+ getRateLimitState() {
274
+ return {
275
+ remaining: 5000,
276
+ limit: 5000,
277
+ reset: Date.now() + 3600000,
278
+ };
279
+ }
280
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * GitHub PR Tracker for Symphony
3
+ * Implements Tracker interface using gh CLI for Pull Requests
4
+ */
5
+
6
+ import type { Issue, Comment } from "../config/types.js";
7
+ import type { Tracker, TrackerConfig, FetchOptions } from "./interface.js";
8
+ import { execSync } from "child_process";
9
+
10
+ /**
11
+ * Raw GitHub PR structure from gh CLI JSON output
12
+ */
13
+ interface GitHubPR {
14
+ number: number;
15
+ title: string;
16
+ body: string;
17
+ headRefName: string;
18
+ baseRefName: string;
19
+ author: { login: string };
20
+ labels: { name: string }[];
21
+ state: string; // "OPEN", "CLOSED", "MERGED"
22
+ mergeable: string;
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ comments?: { author: { login: string }; body: string; createdAt: string }[];
26
+ files?: { path: string }[];
27
+ }
28
+
29
+ /**
30
+ * GitHub PR Tracker
31
+ * Fetches Pull Requests from a GitHub repository using the gh CLI
32
+ */
33
+ export class GitHubPRTracker implements Tracker {
34
+ private repo: string;
35
+ private excludedLabels: string[];
36
+ private requiredLabels: string[];
37
+ private activeStates: string[];
38
+ private terminalStates: string[];
39
+
40
+ /**
41
+ * Create a new GitHub PR tracker
42
+ * @param config - Tracker configuration (must include repo)
43
+ * @throws Error if repo is not specified
44
+ */
45
+ constructor(config: TrackerConfig) {
46
+ if (!config.repo) {
47
+ throw new Error("GitHub PR tracker requires 'repo' in config (e.g., 'owner/repo')");
48
+ }
49
+ this.repo = config.repo;
50
+ this.excludedLabels = config.excluded_labels ?? [];
51
+ this.requiredLabels = config.required_labels ?? [];
52
+ this.activeStates = config.active_states ?? ["open"];
53
+ this.terminalStates = config.terminal_states ?? ["closed", "merged"];
54
+ }
55
+
56
+ /**
57
+ * Execute a gh CLI command
58
+ * @param args - Arguments to pass to gh
59
+ * @returns Command output as string
60
+ */
61
+ private gh(args: string): string {
62
+ try {
63
+ return execSync(`gh ${args}`, {
64
+ encoding: "utf-8",
65
+ timeout: 30000,
66
+ env: { ...process.env, GH_REPO: this.repo },
67
+ }).trim();
68
+ } catch (err: any) {
69
+ throw new Error(`gh CLI failed: ${err.message}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Execute a gh CLI command and parse JSON output
75
+ * @param args - Arguments to pass to gh
76
+ * @returns Parsed JSON response
77
+ */
78
+ private ghJson<T>(args: string): T {
79
+ const output = this.gh(args);
80
+ return JSON.parse(output) as T;
81
+ }
82
+
83
+ /**
84
+ * Convert a GitHub PR to Symphony Issue format
85
+ * @param pr - Raw GitHub PR from gh CLI
86
+ * @returns Symphony Issue object
87
+ */
88
+ private prToIssue(pr: GitHubPR): Issue {
89
+ // Normalize state: OPEN -> open, CLOSED -> closed, MERGED -> merged
90
+ const normalizedState = pr.state.toLowerCase();
91
+
92
+ return {
93
+ id: `PR-${pr.number}`,
94
+ identifier: `PR-${pr.number}`,
95
+ title: pr.title,
96
+ description: pr.body || "",
97
+ priority: null,
98
+ url: `https://github.com/${this.repo}/pull/${pr.number}`,
99
+ branch_name: pr.headRefName,
100
+ base_branch: pr.baseRefName,
101
+ state: normalizedState,
102
+ labels: pr.labels.map((l) => l.name),
103
+ author: pr.author.login,
104
+ files_changed: pr.files?.length ?? 0,
105
+ number: pr.number,
106
+ children: [],
107
+ blocked_by: [],
108
+ comments: (pr.comments ?? []).map((c) => ({
109
+ id: `${pr.number}-${c.createdAt}`,
110
+ body: c.body,
111
+ created_at: new Date(c.createdAt),
112
+ user: c.author.login,
113
+ })),
114
+ created_at: new Date(pr.createdAt),
115
+ updated_at: new Date(pr.updatedAt),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Fetch PRs that are candidates for processing
121
+ * @param options - Fetch options (labels, states, limit)
122
+ * @returns Array of Symphony Issues
123
+ */
124
+ async fetchCandidates(options: FetchOptions): Promise<Issue[]> {
125
+ const excludeLabels = [...this.excludedLabels, ...(options.excludedLabels ?? [])];
126
+ const requireLabels = [...this.requiredLabels, ...(options.requiredLabels ?? [])];
127
+ const activeStates = options.activeStates ?? this.activeStates;
128
+
129
+ // Build state filter - gh pr list uses "open", "closed", "merged", or "all"
130
+ const stateFilter = activeStates.includes("open") ? "open" : "all";
131
+
132
+ // Fetch PRs with full details
133
+ const prs = this.ghJson<GitHubPR[]>(
134
+ `pr list --state ${stateFilter} --json number,title,body,headRefName,baseRefName,author,labels,state,mergeable,createdAt,updatedAt,files`
135
+ );
136
+
137
+ // Filter by labels and state
138
+ const filtered = prs.filter((pr) => {
139
+ const prLabels = pr.labels.map((l) => l.name);
140
+ const prState = pr.state.toLowerCase();
141
+
142
+ // Must be in active state
143
+ if (!activeStates.some((s) => s.toLowerCase() === prState)) {
144
+ return false;
145
+ }
146
+
147
+ // Must not have any excluded labels
148
+ if (excludeLabels.some((el) => prLabels.includes(el))) {
149
+ return false;
150
+ }
151
+
152
+ // Must have all required labels (if any)
153
+ if (requireLabels.length > 0 && !requireLabels.every((rl) => prLabels.includes(rl))) {
154
+ return false;
155
+ }
156
+
157
+ return true;
158
+ });
159
+
160
+ // Sort by created date (oldest first)
161
+ filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
162
+
163
+ // Apply limit
164
+ const limited = options.limit ? filtered.slice(0, options.limit) : filtered;
165
+
166
+ return limited.map((pr) => this.prToIssue(pr));
167
+ }
168
+
169
+ /**
170
+ * Get a single PR by identifier
171
+ * @param identifier - PR identifier (e.g., "PR-123" or "123")
172
+ * @returns Symphony Issue or null if not found
173
+ */
174
+ async getIssue(identifier: string): Promise<Issue | null> {
175
+ const number = identifier.replace("PR-", "");
176
+ try {
177
+ const pr = this.ghJson<GitHubPR>(
178
+ `pr view ${number} --json number,title,body,headRefName,baseRefName,author,labels,state,mergeable,createdAt,updatedAt,comments,files`
179
+ );
180
+ return this.prToIssue(pr);
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Fetch PRs in terminal states (for cleanup)
188
+ * @param terminalStates - Array of terminal state names
189
+ * @returns Array of Symphony Issues in terminal states
190
+ */
191
+ async fetchTerminalIssues(terminalStates: string[]): Promise<Issue[]> {
192
+ // For GitHub PRs, "terminal" means closed/merged or having excluded label
193
+ if (this.excludedLabels.length === 0 && terminalStates.length === 0) {
194
+ return [];
195
+ }
196
+
197
+ // If looking for closed/merged PRs
198
+ if (terminalStates.some((s) => ["closed", "merged"].includes(s.toLowerCase()))) {
199
+ const prs = this.ghJson<GitHubPR[]>(
200
+ `pr list --state closed --json number,title,body,headRefName,baseRefName,author,labels,state,createdAt,updatedAt`
201
+ );
202
+ return prs.map((pr) => this.prToIssue(pr));
203
+ }
204
+
205
+ // Otherwise look for open PRs with excluded labels
206
+ if (this.excludedLabels.length > 0) {
207
+ const prs = this.ghJson<GitHubPR[]>(
208
+ `pr list --state open --label "${this.excludedLabels[0]}" --json number,title,body,headRefName,baseRefName,author,labels,state,createdAt,updatedAt`
209
+ );
210
+ return prs.map((pr) => this.prToIssue(pr));
211
+ }
212
+
213
+ return [];
214
+ }
215
+
216
+ /**
217
+ * Batch fetch states for multiple PRs
218
+ * @param ids - Array of PR identifiers
219
+ * @returns Map of identifier to state
220
+ */
221
+ async fetchStatesByIds(ids: string[]): Promise<Map<string, string>> {
222
+ const result = new Map<string, string>();
223
+ for (const id of ids) {
224
+ const issue = await this.getIssue(id);
225
+ if (issue) {
226
+ result.set(id, issue.state);
227
+ }
228
+ }
229
+ return result;
230
+ }
231
+
232
+ /**
233
+ * Add or update a comment on a PR
234
+ * @param issueId - PR identifier
235
+ * @param body - Comment body
236
+ * @param commentId - Optional comment ID for updates (not supported by gh CLI)
237
+ * @returns URL of the created comment
238
+ */
239
+ async upsertComment(issueId: string, body: string, commentId?: string): Promise<string> {
240
+ const number = issueId.replace("PR-", "");
241
+ // gh doesn't support editing comments easily, so we just add new ones
242
+ const escapedBody = body.replace(/"/g, '\\"').replace(/`/g, '\\`');
243
+ const result = this.gh(`pr comment ${number} --body "${escapedBody}"`);
244
+ // Extract comment URL from result
245
+ const match = result.match(/https:\/\/github\.com\/[^\s]+/);
246
+ return match ? match[0] : "";
247
+ }
248
+
249
+ /**
250
+ * Add a label to a PR
251
+ * @param issueId - PR identifier
252
+ * @param label - Label name to add
253
+ */
254
+ async addLabel(issueId: string, label: string): Promise<void> {
255
+ const number = issueId.replace("PR-", "");
256
+ this.gh(`pr edit ${number} --add-label "${label}"`);
257
+ }
258
+
259
+ /**
260
+ * Remove a label from a PR
261
+ * @param issueId - PR identifier
262
+ * @param label - Label name to remove
263
+ */
264
+ async removeLabel(issueId: string, label: string): Promise<void> {
265
+ const number = issueId.replace("PR-", "");
266
+ this.gh(`pr edit ${number} --remove-label "${label}"`);
267
+ }
268
+
269
+ /**
270
+ * Update PR state (close/reopen)
271
+ * @param issueId - PR identifier
272
+ * @param state - New state ("closed" or "open")
273
+ */
274
+ async updateState(issueId: string, state: string): Promise<void> {
275
+ const number = issueId.replace("PR-", "");
276
+ const normalizedState = state.toLowerCase();
277
+
278
+ if (normalizedState === "closed" || normalizedState === "close") {
279
+ this.gh(`pr close ${number}`);
280
+ } else if (normalizedState === "open" || normalizedState === "reopen") {
281
+ this.gh(`pr reopen ${number}`);
282
+ }
283
+ // "merged" state is handled via gh pr merge, but we don't support that here
284
+ // as it requires more configuration (merge method, delete branch, etc.)
285
+ }
286
+
287
+ /**
288
+ * Get rate limit state
289
+ * gh CLI handles rate limiting internally, so return generous defaults
290
+ */
291
+ getRateLimitState() {
292
+ return {
293
+ remaining: 5000,
294
+ limit: 5000,
295
+ reset: Date.now() + 3600000,
296
+ };
297
+ }
298
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Tracker module exports
3
+ */
4
+
5
+ export * from "./interface.js";
6
+ export * from "./types.js";
7
+ export { LinearClient } from "./client.js";
8
+ export { LinearTracker } from "./linear-tracker.js";
9
+ export { GitHubPRTracker } from "./github-pr-tracker.js";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Abstract Tracker Interface
3
+ * All tracker implementations (Linear, GitHub PR, etc.) must implement this
4
+ */
5
+
6
+ import type { Issue, ChildIssue, Comment } from "../config/types.js";
7
+
8
+ export interface TrackerConfig {
9
+ kind: "linear" | "github-pr" | "github-issues";
10
+ // Linear-specific
11
+ api_key?: string;
12
+ endpoint?: string;
13
+ project_slug?: string;
14
+ active_states?: string[];
15
+ terminal_states?: string[];
16
+ // GitHub-specific
17
+ repo?: string;
18
+ // Shared
19
+ required_labels?: string[];
20
+ excluded_labels?: string[];
21
+ }
22
+
23
+ export interface FetchOptions {
24
+ requiredLabels?: string[];
25
+ excludedLabels?: string[];
26
+ activeStates?: string[];
27
+ limit?: number;
28
+ }
29
+
30
+ export interface Tracker {
31
+ /** Fetch issues/PRs that are candidates for processing */
32
+ fetchCandidates(options: FetchOptions): Promise<Issue[]>;
33
+
34
+ /** Get a single issue/PR by identifier */
35
+ getIssue(identifier: string): Promise<Issue | null>;
36
+
37
+ /** Get issues/PRs by their terminal state (for cleanup) */
38
+ fetchTerminalIssues(terminalStates: string[]): Promise<Issue[]>;
39
+
40
+ /** Batch fetch states for multiple issues */
41
+ fetchStatesByIds(ids: string[]): Promise<Map<string, string>>;
42
+
43
+ /** Add or update a comment on an issue/PR */
44
+ upsertComment(issueId: string, body: string, commentId?: string): Promise<string>;
45
+
46
+ /** Add a label to an issue/PR */
47
+ addLabel(issueId: string, label: string): Promise<void>;
48
+
49
+ /** Remove a label from an issue/PR */
50
+ removeLabel(issueId: string, label: string): Promise<void>;
51
+
52
+ /** Update issue state/status */
53
+ updateState(issueId: string, state: string): Promise<void>;
54
+
55
+ /** Get rate limit state (for throttling) */
56
+ getRateLimitState(): { remaining: number; limit: number; reset: number };
57
+ }
58
+
59
+ export async function createTracker(config: TrackerConfig): Promise<Tracker> {
60
+ switch (config.kind) {
61
+ case "linear": {
62
+ const { LinearTracker } = await import("./linear-tracker.js");
63
+ return new LinearTracker(config);
64
+ }
65
+ case "github-pr": {
66
+ const { GitHubPRTracker } = await import("./github-pr-tracker.js");
67
+ return new GitHubPRTracker(config);
68
+ }
69
+ case "github-issues": {
70
+ const { GitHubIssuesTracker } = await import("./github-issues-tracker.js");
71
+ return new GitHubIssuesTracker(config);
72
+ }
73
+ default:
74
+ throw new Error(`Unknown tracker kind: ${(config as any).kind}`);
75
+ }
76
+ }