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.
- package/CLAUDE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/web/app.css +2 -0
- package/dist/web/index.html +13 -0
- package/dist/web/main.js +235 -0
- package/package.json +62 -0
- package/src/agent/claude-runner.ts +576 -0
- package/src/agent/protocol.ts +2 -0
- package/src/agent/runner.ts +2 -0
- package/src/agent/session.ts +113 -0
- package/src/cli.ts +354 -0
- package/src/config/loader.ts +379 -0
- package/src/config/types.ts +382 -0
- package/src/index.ts +53 -0
- package/src/linear-cli.ts +414 -0
- package/src/logging/logger.ts +143 -0
- package/src/orchestrator/multi-orchestrator.ts +266 -0
- package/src/orchestrator/orchestrator.ts +1357 -0
- package/src/orchestrator/scheduler.ts +195 -0
- package/src/orchestrator/state.ts +201 -0
- package/src/prompts/github-system-prompt.md +51 -0
- package/src/prompts/linear-system-prompt.md +44 -0
- package/src/tracker/client.ts +577 -0
- package/src/tracker/github-issues-tracker.ts +280 -0
- package/src/tracker/github-pr-tracker.ts +298 -0
- package/src/tracker/index.ts +9 -0
- package/src/tracker/interface.ts +76 -0
- package/src/tracker/linear-tracker.ts +147 -0
- package/src/tracker/queries.ts +281 -0
- package/src/tracker/types.ts +125 -0
- package/src/tui/App.tsx +157 -0
- package/src/tui/LogView.tsx +120 -0
- package/src/tui/StatusBar.tsx +72 -0
- package/src/tui/TabBar.tsx +55 -0
- package/src/tui/sink.ts +47 -0
- package/src/tui/types.ts +6 -0
- package/src/tui/useOrchestrator.ts +244 -0
- package/src/web/server.ts +182 -0
- package/src/web/sink.ts +67 -0
- package/src/web-ui/App.tsx +60 -0
- package/src/web-ui/components/agent-table.tsx +57 -0
- package/src/web-ui/components/header.tsx +72 -0
- package/src/web-ui/components/log-stream.tsx +111 -0
- package/src/web-ui/components/retry-table.tsx +58 -0
- package/src/web-ui/components/stats-cards.tsx +142 -0
- package/src/web-ui/components/ui/badge.tsx +30 -0
- package/src/web-ui/components/ui/button.tsx +39 -0
- package/src/web-ui/components/ui/card.tsx +32 -0
- package/src/web-ui/globals.css +27 -0
- package/src/web-ui/index.html +13 -0
- package/src/web-ui/lib/use-sse.ts +98 -0
- package/src/web-ui/lib/utils.ts +25 -0
- package/src/web-ui/main.tsx +4 -0
- package/src/workspace/hooks.ts +97 -0
- package/src/workspace/manager.ts +211 -0
- package/src/workspace/render-hook.ts +13 -0
- package/workflows/dev.md +127 -0
- package/workflows/github-issues.md +107 -0
- package/workflows/pr-review.md +89 -0
- package/workflows/prd.md +170 -0
- package/workflows/ralph.md +95 -0
- 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,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
|
+
}
|