@aion0/forge 0.3.2 → 0.3.4
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 +2 -1
- package/app/api/issue-scanner/route.ts +116 -0
- package/app/api/pipelines/route.ts +11 -3
- package/app/api/skills/route.ts +12 -0
- package/app/login/page.tsx +1 -1
- package/bin/forge-server.mjs +80 -21
- package/cli/mw.ts +2 -1
- package/components/DocTerminal.tsx +3 -2
- package/components/PipelineView.tsx +3 -2
- package/components/ProjectManager.tsx +273 -2
- package/components/SkillsPanel.tsx +28 -6
- package/components/WebTerminal.tsx +4 -3
- package/lib/cloudflared.ts +1 -1
- package/lib/init.ts +6 -0
- package/lib/issue-scanner.ts +298 -0
- package/lib/pipeline.ts +296 -28
- package/lib/skills.ts +30 -2
- package/lib/task-manager.ts +39 -0
- package/lib/terminal-standalone.ts +5 -1
- package/next-env.d.ts +1 -1
- package/next.config.ts +3 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +1 -0
- package/src/types/index.ts +1 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Scanner — periodically scans GitHub issues for configured projects
|
|
3
|
+
* and triggers issue-auto-fix pipeline for new issues.
|
|
4
|
+
*
|
|
5
|
+
* Per-project config stored in DB:
|
|
6
|
+
* - enabled: boolean
|
|
7
|
+
* - interval: minutes (0 = manual only)
|
|
8
|
+
* - labels: string[] (only process issues with these labels, empty = all)
|
|
9
|
+
* - baseBranch: string (default: auto-detect)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
13
|
+
import { getDb } from '@/src/core/db/database';
|
|
14
|
+
import { getDbPath } from '@/src/config';
|
|
15
|
+
import { startPipeline } from './pipeline';
|
|
16
|
+
import { loadSettings } from './settings';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
function db() { return getDb(getDbPath()); }
|
|
20
|
+
|
|
21
|
+
export interface IssueAutofixConfig {
|
|
22
|
+
projectPath: string;
|
|
23
|
+
projectName: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
interval: number; // minutes, 0 = manual
|
|
26
|
+
labels: string[]; // filter labels
|
|
27
|
+
baseBranch: string; // default: '' (auto-detect)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── DB setup ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function migrateTable() {
|
|
33
|
+
try { db().exec('ALTER TABLE issue_autofix_config ADD COLUMN last_scan_at TEXT'); } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ensureTable() {
|
|
37
|
+
db().exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS issue_autofix_config (
|
|
39
|
+
project_path TEXT PRIMARY KEY,
|
|
40
|
+
project_name TEXT NOT NULL,
|
|
41
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
interval_min INTEGER NOT NULL DEFAULT 30,
|
|
43
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
44
|
+
base_branch TEXT NOT NULL DEFAULT '',
|
|
45
|
+
last_scan_at TEXT
|
|
46
|
+
);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS issue_autofix_processed (
|
|
48
|
+
project_path TEXT NOT NULL,
|
|
49
|
+
issue_number INTEGER NOT NULL,
|
|
50
|
+
pipeline_id TEXT,
|
|
51
|
+
pr_number INTEGER,
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'processing',
|
|
53
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
54
|
+
PRIMARY KEY (project_path, issue_number)
|
|
55
|
+
);
|
|
56
|
+
`);
|
|
57
|
+
migrateTable();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Config CRUD ─────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export function getConfig(projectPath: string): IssueAutofixConfig | null {
|
|
63
|
+
ensureTable();
|
|
64
|
+
const row = db().prepare('SELECT * FROM issue_autofix_config WHERE project_path = ?').get(projectPath) as any;
|
|
65
|
+
if (!row) return null;
|
|
66
|
+
return {
|
|
67
|
+
projectPath: row.project_path,
|
|
68
|
+
projectName: row.project_name,
|
|
69
|
+
enabled: !!row.enabled,
|
|
70
|
+
interval: row.interval_min,
|
|
71
|
+
labels: JSON.parse(row.labels || '[]'),
|
|
72
|
+
baseBranch: row.base_branch || '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function saveConfig(config: IssueAutofixConfig): void {
|
|
77
|
+
ensureTable();
|
|
78
|
+
db().prepare(`
|
|
79
|
+
INSERT OR REPLACE INTO issue_autofix_config (project_path, project_name, enabled, interval_min, labels, base_branch)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
81
|
+
`).run(config.projectPath, config.projectName, config.enabled ? 1 : 0, config.interval, JSON.stringify(config.labels), config.baseBranch);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function listConfigs(): IssueAutofixConfig[] {
|
|
85
|
+
ensureTable();
|
|
86
|
+
return (db().prepare('SELECT * FROM issue_autofix_config WHERE enabled = 1').all() as any[]).map(row => ({
|
|
87
|
+
projectPath: row.project_path,
|
|
88
|
+
projectName: row.project_name,
|
|
89
|
+
enabled: !!row.enabled,
|
|
90
|
+
interval: row.interval_min,
|
|
91
|
+
labels: JSON.parse(row.labels || '[]'),
|
|
92
|
+
baseBranch: row.base_branch || '',
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Issue scanning ──────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function getRepoFromProject(projectPath: string): string | null {
|
|
99
|
+
try {
|
|
100
|
+
return execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
|
|
101
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
102
|
+
}).trim() || null;
|
|
103
|
+
} catch {
|
|
104
|
+
try {
|
|
105
|
+
const url = execSync('git remote get-url origin', {
|
|
106
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
}).trim();
|
|
108
|
+
return url.replace(/.*github\.com[:/]/, '').replace(/\.git$/, '') || null;
|
|
109
|
+
} catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fetchOpenIssues(projectPath: string, labels: string[]): { number: number; title: string; error?: string }[] {
|
|
114
|
+
const repo = getRepoFromProject(projectPath);
|
|
115
|
+
if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
|
|
116
|
+
try {
|
|
117
|
+
const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
|
|
118
|
+
const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
|
|
119
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
});
|
|
121
|
+
return JSON.parse(out) || [];
|
|
122
|
+
} catch (e: any) {
|
|
123
|
+
const msg = e.stderr?.toString() || e.message || 'gh CLI failed';
|
|
124
|
+
return [{ number: -1, title: '', error: msg.includes('auth') ? 'GitHub CLI not authenticated. Run: gh auth login' : msg }];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isProcessed(projectPath: string, issueNumber: number): boolean {
|
|
129
|
+
const row = db().prepare('SELECT 1 FROM issue_autofix_processed WHERE project_path = ? AND issue_number = ?')
|
|
130
|
+
.get(projectPath, issueNumber);
|
|
131
|
+
return !!row;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function markProcessed(projectPath: string, issueNumber: number, pipelineId: string): void {
|
|
135
|
+
db().prepare(`
|
|
136
|
+
INSERT OR REPLACE INTO issue_autofix_processed (project_path, issue_number, pipeline_id, status)
|
|
137
|
+
VALUES (?, ?, ?, 'processing')
|
|
138
|
+
`).run(projectPath, issueNumber, pipelineId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function updateProcessedStatus(projectPath: string, issueNumber: number, status: string, prNumber?: number): void {
|
|
142
|
+
if (prNumber) {
|
|
143
|
+
db().prepare('UPDATE issue_autofix_processed SET status = ?, pr_number = ? WHERE project_path = ? AND issue_number = ?')
|
|
144
|
+
.run(status, prNumber, projectPath, issueNumber);
|
|
145
|
+
} else {
|
|
146
|
+
db().prepare('UPDATE issue_autofix_processed SET status = ? WHERE project_path = ? AND issue_number = ?')
|
|
147
|
+
.run(status, projectPath, issueNumber);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Reset a processed issue so it can be re-scanned or retried */
|
|
152
|
+
export function resetProcessedIssue(projectPath: string, issueNumber: number): void {
|
|
153
|
+
db().prepare('DELETE FROM issue_autofix_processed WHERE project_path = ? AND issue_number = ?')
|
|
154
|
+
.run(projectPath, issueNumber);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function getProcessedIssues(projectPath: string): { issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[] {
|
|
158
|
+
ensureTable();
|
|
159
|
+
return (db().prepare('SELECT * FROM issue_autofix_processed WHERE project_path = ? ORDER BY created_at DESC').all(projectPath) as any[]).map(r => ({
|
|
160
|
+
issueNumber: r.issue_number,
|
|
161
|
+
pipelineId: r.pipeline_id,
|
|
162
|
+
prNumber: r.pr_number,
|
|
163
|
+
status: r.status,
|
|
164
|
+
createdAt: r.created_at,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Scan & trigger ──────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function saveLastScan(projectPath: string) {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
scannerState.lastScan.set(projectPath, now);
|
|
173
|
+
try { db().prepare('UPDATE issue_autofix_config SET last_scan_at = ? WHERE project_path = ?').run(new Date(now).toISOString(), projectPath); } catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function loadLastScan(projectPath: string): number | null {
|
|
177
|
+
try {
|
|
178
|
+
const row = db().prepare('SELECT last_scan_at FROM issue_autofix_config WHERE project_path = ?').get(projectPath) as any;
|
|
179
|
+
return row?.last_scan_at ? new Date(row.last_scan_at).getTime() : null;
|
|
180
|
+
} catch { return null; }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function scanAndTrigger(config: IssueAutofixConfig): { triggered: number; issues: number[]; total: number; error?: string } {
|
|
184
|
+
saveLastScan(config.projectPath);
|
|
185
|
+
const issues = fetchOpenIssues(config.projectPath, config.labels);
|
|
186
|
+
|
|
187
|
+
// Check for errors
|
|
188
|
+
if (issues.length === 1 && (issues[0] as any).error) {
|
|
189
|
+
return { triggered: 0, issues: [], total: 0, error: (issues[0] as any).error };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const triggered: number[] = [];
|
|
193
|
+
|
|
194
|
+
for (const issue of issues) {
|
|
195
|
+
if (issue.number < 0) continue;
|
|
196
|
+
if (isProcessed(config.projectPath, issue.number)) continue;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const pipeline = startPipeline('issue-auto-fix', {
|
|
200
|
+
issue_id: String(issue.number),
|
|
201
|
+
project: config.projectName,
|
|
202
|
+
base_branch: config.baseBranch || 'auto-detect',
|
|
203
|
+
});
|
|
204
|
+
markProcessed(config.projectPath, issue.number, pipeline.id);
|
|
205
|
+
triggered.push(issue.number);
|
|
206
|
+
console.log(`[issue-scanner] Triggered fix for #${issue.number} "${issue.title}" in ${config.projectName} (pipeline: ${pipeline.id})`);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.error(`[issue-scanner] Failed to trigger for #${issue.number}:`, e);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { triggered: triggered.length, issues: triggered, total: issues.length };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Periodic scanner ────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
// Use global symbol to prevent multiple workers from starting duplicate scanners
|
|
218
|
+
const scannerKey = Symbol.for('forge-issue-scanner');
|
|
219
|
+
const gAny = globalThis as any;
|
|
220
|
+
if (!gAny[scannerKey]) gAny[scannerKey] = { timers: new Map<string, NodeJS.Timeout>(), started: false, lastScan: new Map<string, number>() };
|
|
221
|
+
const scannerState = gAny[scannerKey] as { timers: Map<string, NodeJS.Timeout>; started: boolean; lastScan: Map<string, number> };
|
|
222
|
+
|
|
223
|
+
export function startScanner() {
|
|
224
|
+
if (scannerState.started) return;
|
|
225
|
+
scannerState.started = true;
|
|
226
|
+
ensureTable();
|
|
227
|
+
const configs = listConfigs();
|
|
228
|
+
for (const config of configs) {
|
|
229
|
+
if (config.interval > 0 && !scannerState.timers.has(config.projectPath)) {
|
|
230
|
+
const projectPath = config.projectPath;
|
|
231
|
+
const intervalMs = config.interval * 60 * 1000;
|
|
232
|
+
|
|
233
|
+
// Restore lastScan from DB
|
|
234
|
+
const dbLastScan = loadLastScan(projectPath);
|
|
235
|
+
if (dbLastScan) {
|
|
236
|
+
scannerState.lastScan.set(projectPath, dbLastScan);
|
|
237
|
+
} else {
|
|
238
|
+
scannerState.lastScan.set(projectPath, Date.now());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Calculate delay: if time since lastScan > interval, run soon; otherwise wait remaining
|
|
242
|
+
const elapsed = Date.now() - (scannerState.lastScan.get(projectPath) || 0);
|
|
243
|
+
const firstDelay = Math.max(0, intervalMs - elapsed);
|
|
244
|
+
|
|
245
|
+
const startInterval = () => {
|
|
246
|
+
const timer = setInterval(() => {
|
|
247
|
+
const latestConfig = getConfig(projectPath);
|
|
248
|
+
if (!latestConfig || !latestConfig.enabled) return;
|
|
249
|
+
try { scanAndTrigger(latestConfig); } catch {}
|
|
250
|
+
}, intervalMs);
|
|
251
|
+
scannerState.timers.set(projectPath, timer);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// First run after remaining delay, then regular interval
|
|
255
|
+
if (firstDelay > 0) {
|
|
256
|
+
setTimeout(() => {
|
|
257
|
+
const latestConfig = getConfig(projectPath);
|
|
258
|
+
if (latestConfig?.enabled) { try { scanAndTrigger(latestConfig); } catch {} }
|
|
259
|
+
startInterval();
|
|
260
|
+
}, firstDelay);
|
|
261
|
+
} else {
|
|
262
|
+
// Overdue — run immediately then start interval
|
|
263
|
+
const latestConfig = getConfig(projectPath);
|
|
264
|
+
if (latestConfig?.enabled) { try { scanAndTrigger(latestConfig); } catch {} }
|
|
265
|
+
startInterval();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const nextTime = new Date((scannerState.lastScan.get(projectPath) || Date.now()) + intervalMs);
|
|
269
|
+
console.log(`[issue-scanner] Started scanner for ${config.projectName} (every ${config.interval}min, next: ${nextTime.toLocaleTimeString()})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getNextScanTime(projectPath: string): { lastScan: string | null; nextScan: string | null } {
|
|
275
|
+
const last = scannerState.lastScan.get(projectPath);
|
|
276
|
+
const config = getConfig(projectPath);
|
|
277
|
+
if (!last || !config || !config.enabled || config.interval <= 0) return { lastScan: null, nextScan: null };
|
|
278
|
+
const next = last + config.interval * 60 * 1000;
|
|
279
|
+
return {
|
|
280
|
+
lastScan: new Date(last).toISOString(),
|
|
281
|
+
nextScan: new Date(next).toISOString(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function restartScanner() {
|
|
286
|
+
for (const timer of scannerState.timers.values()) clearInterval(timer);
|
|
287
|
+
scannerState.timers.clear();
|
|
288
|
+
scannerState.started = false;
|
|
289
|
+
startScanner();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function stopScanner(projectPath: string) {
|
|
293
|
+
const timer = scannerState.timers.get(projectPath);
|
|
294
|
+
if (timer) {
|
|
295
|
+
clearInterval(timer);
|
|
296
|
+
scannerState.timers.delete(projectPath);
|
|
297
|
+
}
|
|
298
|
+
}
|