@aion0/forge 0.4.6 → 0.4.8
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/RELEASE_NOTES.md +6 -7
- package/app/api/code/route.ts +14 -1
- package/app/api/docs/route.ts +16 -1
- package/app/api/project-pipelines/route.ts +23 -0
- package/components/Dashboard.tsx +17 -1
- package/components/DocsViewer.tsx +50 -13
- package/components/PipelineView.tsx +12 -1
- package/components/ProjectDetail.tsx +190 -21
- package/components/TabBar.tsx +1 -1
- package/lib/cloudflared.ts +10 -7
- package/lib/help-docs/05-pipelines.md +5 -5
- package/lib/help-docs/09-issue-autofix.md +26 -22
- package/lib/init.ts +1 -7
- package/lib/pipeline-scheduler.ts +147 -25
- package/lib/pipeline.ts +24 -10
- package/lib/task-manager.ts +11 -4
- package/package.json +1 -1
- package/src/core/db/database.ts +19 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.8
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-22
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.7
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
-
|
|
9
|
-
- imporve help features
|
|
7
|
+
### Features
|
|
8
|
+
- feat: image preview, all file types support, docs tab limit
|
|
10
9
|
|
|
11
10
|
|
|
12
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.7...v0.4.8
|
package/app/api/code/route.ts
CHANGED
|
@@ -26,11 +26,13 @@ const CODE_EXTS = new Set([
|
|
|
26
26
|
'.xml', '.csv', '.lock',
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
|
|
30
|
+
|
|
29
31
|
function isCodeFile(name: string): boolean {
|
|
30
32
|
if (name.startsWith('.') && !name.startsWith('.env') && !name.startsWith('.git')) return false;
|
|
31
33
|
const ext = extname(name);
|
|
32
34
|
if (!ext) return !name.includes('.'); // files like Makefile, Dockerfile
|
|
33
|
-
return CODE_EXTS.has(ext);
|
|
35
|
+
return CODE_EXTS.has(ext) || IMAGE_EXTS.has(ext);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
|
|
@@ -140,6 +142,17 @@ export async function GET(req: Request) {
|
|
|
140
142
|
'class', 'jar', 'war',
|
|
141
143
|
'pyc', 'pyo', 'wasm',
|
|
142
144
|
]);
|
|
145
|
+
// Image files — return base64 for preview
|
|
146
|
+
const IMAGE_PREVIEW = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
|
|
147
|
+
if (IMAGE_PREVIEW.has(ext)) {
|
|
148
|
+
if (size > 5_000_000) {
|
|
149
|
+
return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: `${sizeMB} MB`, message: 'Image too large to preview (> 5 MB)' });
|
|
150
|
+
}
|
|
151
|
+
const data = readFileSync(fullPath);
|
|
152
|
+
const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
153
|
+
const base64 = `data:${mime};base64,${data.toString('base64')}`;
|
|
154
|
+
return NextResponse.json({ image: true, dataUrl: base64, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
|
|
155
|
+
}
|
|
143
156
|
if (BINARY_EXTS.has(ext)) {
|
|
144
157
|
return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
|
|
145
158
|
}
|
package/app/api/docs/route.ts
CHANGED
|
@@ -105,9 +105,24 @@ export async function GET(req: Request) {
|
|
|
105
105
|
try {
|
|
106
106
|
const stat = statSync(fullPath);
|
|
107
107
|
const size = stat.size;
|
|
108
|
+
const ext = extname(fullPath).replace('.', '').toLowerCase();
|
|
108
109
|
const sizeKB = Math.round(size / 1024);
|
|
109
110
|
const sizeMB = (size / (1024 * 1024)).toFixed(1);
|
|
110
111
|
|
|
112
|
+
// Binary file types
|
|
113
|
+
const BINARY_EXTS = new Set([
|
|
114
|
+
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'avif',
|
|
115
|
+
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi',
|
|
116
|
+
'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
|
|
117
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
118
|
+
'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a',
|
|
119
|
+
'woff', 'woff2', 'ttf', 'eot', 'otf',
|
|
120
|
+
'sqlite', 'db', 'sqlite3', 'class', 'jar', 'pyc', 'wasm',
|
|
121
|
+
]);
|
|
122
|
+
if (BINARY_EXTS.has(ext)) {
|
|
123
|
+
return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
if (size > 2_000_000) {
|
|
112
127
|
return NextResponse.json({ tooLarge: true, size, sizeLabel: `${sizeMB} MB`, message: 'File exceeds 2 MB limit' });
|
|
113
128
|
}
|
|
@@ -115,7 +130,7 @@ export async function GET(req: Request) {
|
|
|
115
130
|
return NextResponse.json({ large: true, size, sizeLabel: `${sizeKB} KB` });
|
|
116
131
|
}
|
|
117
132
|
const content = readFileSync(fullPath, 'utf-8');
|
|
118
|
-
return NextResponse.json({ content });
|
|
133
|
+
return NextResponse.json({ content, language: ext });
|
|
119
134
|
} catch {
|
|
120
135
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
121
136
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
deleteRun,
|
|
9
9
|
triggerPipeline,
|
|
10
10
|
getNextRunTime,
|
|
11
|
+
scanAndTriggerIssues,
|
|
12
|
+
resetDedup,
|
|
11
13
|
} from '@/lib/pipeline-scheduler';
|
|
12
14
|
import { listWorkflows } from '@/lib/pipeline';
|
|
13
15
|
|
|
@@ -64,5 +66,26 @@ export async function POST(req: Request) {
|
|
|
64
66
|
return NextResponse.json({ ok: true });
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
if (body.action === 'scan-now') {
|
|
70
|
+
const { projectPath, projectName, workflowName } = body;
|
|
71
|
+
if (!projectPath || !workflowName) return NextResponse.json({ error: 'projectPath and workflowName required' }, { status: 400 });
|
|
72
|
+
const bindings = getBindings(projectPath);
|
|
73
|
+
const binding = bindings.find(b => b.workflowName === workflowName);
|
|
74
|
+
if (!binding) return NextResponse.json({ error: 'Binding not found' }, { status: 404 });
|
|
75
|
+
try {
|
|
76
|
+
const result = scanAndTriggerIssues(binding);
|
|
77
|
+
return NextResponse.json({ ok: true, ...result });
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
return NextResponse.json({ ok: false, error: e.message }, { status: 500 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (body.action === 'reset-dedup') {
|
|
84
|
+
const { projectPath, workflowName, dedupKey } = body;
|
|
85
|
+
if (!projectPath || !workflowName || !dedupKey) return NextResponse.json({ error: 'projectPath, workflowName, dedupKey required' }, { status: 400 });
|
|
86
|
+
resetDedup(projectPath, workflowName, dedupKey);
|
|
87
|
+
return NextResponse.json({ ok: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
68
91
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -101,6 +101,18 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
101
101
|
return () => window.removeEventListener('forge:open-terminal', handler);
|
|
102
102
|
}, []);
|
|
103
103
|
|
|
104
|
+
// Listen for navigation events (e.g. from ProjectDetail → Pipelines)
|
|
105
|
+
const [pendingPipelineId, setPendingPipelineId] = useState<string | null>(null);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const handler = (e: Event) => {
|
|
108
|
+
const { view, pipelineId } = (e as CustomEvent).detail;
|
|
109
|
+
if (view) setViewMode(view);
|
|
110
|
+
if (pipelineId) setPendingPipelineId(pipelineId);
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener('forge:navigate', handler);
|
|
113
|
+
return () => window.removeEventListener('forge:navigate', handler);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
104
116
|
// Version check (on mount + every 10 min)
|
|
105
117
|
useEffect(() => {
|
|
106
118
|
const check = () => fetch('/api/version').then(r => r.json()).then(setVersionInfo).catch(() => {});
|
|
@@ -561,7 +573,11 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
561
573
|
{/* Pipelines */}
|
|
562
574
|
{viewMode === 'pipelines' && (
|
|
563
575
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
564
|
-
<PipelineView
|
|
576
|
+
<PipelineView
|
|
577
|
+
onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }}
|
|
578
|
+
focusPipelineId={pendingPipelineId}
|
|
579
|
+
onFocusHandled={() => setPendingPipelineId(null)}
|
|
580
|
+
/>
|
|
565
581
|
</Suspense>
|
|
566
582
|
)}
|
|
567
583
|
|
|
@@ -55,20 +55,18 @@ function TreeNode({ node, depth, selected, onSelect }: {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const isSelected = selected === node.path;
|
|
58
|
-
const canOpen = node.fileType === 'md' || node.fileType === 'image';
|
|
59
58
|
|
|
60
59
|
return (
|
|
61
60
|
<button
|
|
62
|
-
onClick={() =>
|
|
61
|
+
onClick={() => onSelect(node.path)}
|
|
63
62
|
className={`w-full text-left flex items-center gap-1 px-1 py-0.5 rounded text-xs truncate ${
|
|
64
|
-
|
|
65
|
-
: isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
63
|
+
isSelected ? 'bg-[var(--accent)]/20 text-[var(--accent)]'
|
|
66
64
|
: 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
|
|
67
65
|
}`}
|
|
68
66
|
style={{ paddingLeft: depth * 12 + 16 }}
|
|
69
67
|
title={node.path}
|
|
70
68
|
>
|
|
71
|
-
{node.fileType === 'image' ? '🖼 ' : ''}{node.name
|
|
69
|
+
{node.fileType === 'image' ? '🖼 ' : ''}{node.name}
|
|
72
70
|
</button>
|
|
73
71
|
);
|
|
74
72
|
}
|
|
@@ -84,6 +82,20 @@ function flattenTree(nodes: FileNode[]): FileNode[] {
|
|
|
84
82
|
return result;
|
|
85
83
|
}
|
|
86
84
|
|
|
85
|
+
const BINARY_EXTS = /\.(png|jpg|jpeg|gif|bmp|ico|webp|avif|mp3|mp4|wav|ogg|webm|mov|avi|zip|gz|tar|bz2|xz|7z|rar|pdf|doc|docx|xls|xlsx|ppt|pptx|exe|dll|so|dylib|bin|woff|woff2|ttf|eot|otf|sqlite|db|class|jar|pyc|wasm|o|a)$/i;
|
|
86
|
+
|
|
87
|
+
function filterTree(nodes: FileNode[]): FileNode[] {
|
|
88
|
+
return nodes.reduce<FileNode[]>((acc, node) => {
|
|
89
|
+
if (node.type === 'dir') {
|
|
90
|
+
const children = filterTree(node.children || []);
|
|
91
|
+
if (children.length > 0) acc.push({ ...node, children });
|
|
92
|
+
} else if (!BINARY_EXTS.test(node.name)) {
|
|
93
|
+
acc.push(node);
|
|
94
|
+
}
|
|
95
|
+
return acc;
|
|
96
|
+
}, []);
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
// ─── Main Component ──────────────────────────────────────
|
|
88
100
|
|
|
89
101
|
export default function DocsViewer() {
|
|
@@ -176,7 +188,9 @@ export default function DocsViewer() {
|
|
|
176
188
|
setLoading(true);
|
|
177
189
|
const res = await fetch(`/api/docs?root=${activeRoot}&file=${encodeURIComponent(path)}`);
|
|
178
190
|
const data = await res.json();
|
|
179
|
-
if (data.
|
|
191
|
+
if (data.binary) {
|
|
192
|
+
setFileWarning(`${data.fileType?.toUpperCase() || 'Binary'} file — ${data.sizeLabel} — cannot be displayed`);
|
|
193
|
+
} else if (data.tooLarge) {
|
|
180
194
|
setFileWarning(`File too large (${data.sizeLabel})`);
|
|
181
195
|
} else {
|
|
182
196
|
fileContent = data.content || null;
|
|
@@ -185,11 +199,15 @@ export default function DocsViewer() {
|
|
|
185
199
|
}
|
|
186
200
|
setContent(fileContent);
|
|
187
201
|
|
|
202
|
+
const MAX_TABS = 8;
|
|
188
203
|
const newTab: DocTab = { id: genTabId(), filePath: path, fileName, rootIdx: activeRoot, isImage: isImg, content: fileContent };
|
|
189
204
|
setDocTabs(prev => {
|
|
190
|
-
// Double-check no duplicate
|
|
191
205
|
if (prev.find(t => t.filePath === path)) return prev;
|
|
192
|
-
|
|
206
|
+
let updated = [...prev, newTab];
|
|
207
|
+
// Auto-close oldest tabs if over limit
|
|
208
|
+
while (updated.length > MAX_TABS) {
|
|
209
|
+
updated = updated.slice(1);
|
|
210
|
+
}
|
|
193
211
|
setActiveDocTabId(newTab.id);
|
|
194
212
|
persistDocTabs(updated, newTab.id);
|
|
195
213
|
return updated;
|
|
@@ -259,6 +277,7 @@ export default function DocsViewer() {
|
|
|
259
277
|
}, [activeRoot, fetchTree]);
|
|
260
278
|
|
|
261
279
|
const [fileWarning, setFileWarning] = useState<string | null>(null);
|
|
280
|
+
const [hideUnsupported, setHideUnsupported] = useState(true);
|
|
262
281
|
|
|
263
282
|
// Fetch file content
|
|
264
283
|
const isImageFile = (path: string) => /\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|avif)$/i.test(path);
|
|
@@ -321,9 +340,9 @@ export default function DocsViewer() {
|
|
|
321
340
|
}
|
|
322
341
|
|
|
323
342
|
return (
|
|
324
|
-
<div className="flex-1 flex flex-col min-h-0">
|
|
343
|
+
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
|
325
344
|
{/* Doc content area */}
|
|
326
|
-
<div className="flex-1 flex min-h-0">
|
|
345
|
+
<div className="flex-1 flex min-h-0 min-w-0 overflow-hidden">
|
|
327
346
|
{/* Collapsible sidebar — file tree */}
|
|
328
347
|
{sidebarOpen && (
|
|
329
348
|
<aside style={{ width: sidebarWidth }} className="flex flex-col shrink-0 overflow-hidden">
|
|
@@ -343,9 +362,16 @@ export default function DocsViewer() {
|
|
|
343
362
|
{/* Header with refresh */}
|
|
344
363
|
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center">
|
|
345
364
|
<span className="text-[10px] text-[var(--text-secondary)] truncate">{roots[activeRoot] || 'Docs'}</span>
|
|
365
|
+
<button
|
|
366
|
+
onClick={() => setHideUnsupported(v => !v)}
|
|
367
|
+
className={`text-[9px] ml-auto shrink-0 px-1 rounded ${hideUnsupported ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)]'} hover:text-[var(--text-primary)]`}
|
|
368
|
+
title={hideUnsupported ? 'Show all files' : 'Hide binary files'}
|
|
369
|
+
>
|
|
370
|
+
{hideUnsupported ? 'Docs' : 'All'}
|
|
371
|
+
</button>
|
|
346
372
|
<button
|
|
347
373
|
onClick={() => { fetchTree(activeRoot); if (selectedFile) openFile(selectedFile); }}
|
|
348
|
-
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
|
374
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
349
375
|
title="Refresh files"
|
|
350
376
|
>
|
|
351
377
|
↻
|
|
@@ -384,7 +410,7 @@ export default function DocsViewer() {
|
|
|
384
410
|
))
|
|
385
411
|
)
|
|
386
412
|
) : (
|
|
387
|
-
tree.map(node => (
|
|
413
|
+
(hideUnsupported ? filterTree(tree) : tree).map(node => (
|
|
388
414
|
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
|
|
389
415
|
))
|
|
390
416
|
)}
|
|
@@ -493,7 +519,7 @@ export default function DocsViewer() {
|
|
|
493
519
|
spellCheck={false}
|
|
494
520
|
/>
|
|
495
521
|
</div>
|
|
496
|
-
) : (
|
|
522
|
+
) : selectedFile.endsWith('.md') ? (
|
|
497
523
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
|
498
524
|
{loading ? (
|
|
499
525
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -503,6 +529,17 @@ export default function DocsViewer() {
|
|
|
503
529
|
</div>
|
|
504
530
|
)}
|
|
505
531
|
</div>
|
|
532
|
+
) : (
|
|
533
|
+
<div className="flex-1 overflow-y-auto">
|
|
534
|
+
<pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
|
|
535
|
+
{content.split('\n').map((line, i) => (
|
|
536
|
+
<div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
|
|
537
|
+
<span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
|
|
538
|
+
<span className="flex-1">{line || ' '}</span>
|
|
539
|
+
</div>
|
|
540
|
+
))}
|
|
541
|
+
</pre>
|
|
542
|
+
</div>
|
|
506
543
|
)
|
|
507
544
|
) : (
|
|
508
545
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
@@ -64,7 +64,7 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
64
64
|
skipped: 'text-gray-500',
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: string) => void }) {
|
|
67
|
+
export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandled }: { onViewTask?: (taskId: string) => void; focusPipelineId?: string | null; onFocusHandled?: () => void }) {
|
|
68
68
|
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 256, minWidth: 140, maxWidth: 480 });
|
|
69
69
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
70
70
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
|
@@ -100,6 +100,17 @@ export default function PipelineView({ onViewTask }: { onViewTask?: (taskId: str
|
|
|
100
100
|
return () => clearInterval(timer);
|
|
101
101
|
}, [fetchData]);
|
|
102
102
|
|
|
103
|
+
// Focus on a specific pipeline (from external navigation)
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!focusPipelineId || pipelines.length === 0) return;
|
|
106
|
+
const target = pipelines.find(p => p.id === focusPipelineId);
|
|
107
|
+
if (target) {
|
|
108
|
+
setSelectedPipeline(target);
|
|
109
|
+
setShowEditor(false);
|
|
110
|
+
onFocusHandled?.();
|
|
111
|
+
}
|
|
112
|
+
}, [focusPipelineId, pipelines, onFocusHandled]);
|
|
113
|
+
|
|
103
114
|
// Refresh selected pipeline
|
|
104
115
|
useEffect(() => {
|
|
105
116
|
if (!selectedPipeline || selectedPipeline.status !== 'running') return;
|
|
@@ -59,6 +59,8 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
59
59
|
const [fileTree, setFileTree] = useState<any[]>([]);
|
|
60
60
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
61
61
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
62
|
+
const [fileImageUrl, setFileImageUrl] = useState<string | null>(null);
|
|
63
|
+
const [fileBinaryInfo, setFileBinaryInfo] = useState<{ fileType: string; sizeLabel: string; message?: string } | null>(null);
|
|
62
64
|
const [fileLanguage, setFileLanguage] = useState('');
|
|
63
65
|
const [fileLoading, setFileLoading] = useState(false);
|
|
64
66
|
const [showLog, setShowLog] = useState(false);
|
|
@@ -69,10 +71,12 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
69
71
|
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
70
72
|
// Pipeline bindings state
|
|
71
73
|
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
72
|
-
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; createdAt: string }[]>([]);
|
|
74
|
+
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
|
|
73
75
|
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean }[]>([]);
|
|
74
76
|
const [showAddPipeline, setShowAddPipeline] = useState(false);
|
|
75
77
|
const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
|
|
78
|
+
const [runMenu, setRunMenu] = useState<string | null>(null); // workflowName of open run menu
|
|
79
|
+
const [issueInput, setIssueInput] = useState('');
|
|
76
80
|
const [claudeMdContent, setClaudeMdContent] = useState('');
|
|
77
81
|
const [claudeMdExists, setClaudeMdExists] = useState(false);
|
|
78
82
|
const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
|
|
@@ -115,12 +119,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
115
119
|
setSelectedFile(path);
|
|
116
120
|
setDiffContent(null);
|
|
117
121
|
setDiffFile(null);
|
|
122
|
+
setFileContent(null);
|
|
123
|
+
setFileImageUrl(null);
|
|
124
|
+
setFileBinaryInfo(null);
|
|
118
125
|
setFileLoading(true);
|
|
119
126
|
try {
|
|
120
127
|
const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&file=${encodeURIComponent(path)}`);
|
|
121
128
|
const data = await res.json();
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
if (data.image) {
|
|
130
|
+
setFileImageUrl(data.dataUrl);
|
|
131
|
+
} else if (data.binary) {
|
|
132
|
+
setFileBinaryInfo({ fileType: data.fileType, sizeLabel: data.sizeLabel, message: data.message });
|
|
133
|
+
} else if (data.tooLarge) {
|
|
134
|
+
setFileBinaryInfo({ fileType: '', sizeLabel: data.sizeLabel, message: data.message });
|
|
135
|
+
} else {
|
|
136
|
+
setFileContent(data.content || null);
|
|
137
|
+
setFileLanguage(data.language || '');
|
|
138
|
+
}
|
|
124
139
|
} catch { setFileContent(null); }
|
|
125
140
|
setFileLoading(false);
|
|
126
141
|
}, [projectPath]);
|
|
@@ -527,6 +542,23 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
527
542
|
</>
|
|
528
543
|
) : fileLoading ? (
|
|
529
544
|
<div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
545
|
+
) : selectedFile && fileImageUrl ? (
|
|
546
|
+
<>
|
|
547
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
548
|
+
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
|
549
|
+
<img src={fileImageUrl} alt={selectedFile} className="max-w-full max-h-full object-contain rounded" />
|
|
550
|
+
</div>
|
|
551
|
+
</>
|
|
552
|
+
) : selectedFile && fileBinaryInfo ? (
|
|
553
|
+
<>
|
|
554
|
+
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
555
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
556
|
+
<span className="text-2xl">📄</span>
|
|
557
|
+
<span className="text-xs">{fileBinaryInfo.fileType ? fileBinaryInfo.fileType.toUpperCase() + ' file' : 'File'} — {fileBinaryInfo.sizeLabel}</span>
|
|
558
|
+
{fileBinaryInfo.message && <span className="text-[10px]">{fileBinaryInfo.message}</span>}
|
|
559
|
+
<span className="text-[10px]">Binary file cannot be displayed</span>
|
|
560
|
+
</div>
|
|
561
|
+
</>
|
|
530
562
|
) : selectedFile && fileContent !== null ? (
|
|
531
563
|
<>
|
|
532
564
|
<div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
|
|
@@ -846,10 +878,74 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
846
878
|
}} className="accent-[var(--accent)]" />
|
|
847
879
|
Enabled
|
|
848
880
|
</label>
|
|
849
|
-
<
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
881
|
+
<div className="relative">
|
|
882
|
+
<button
|
|
883
|
+
onClick={() => {
|
|
884
|
+
const isIssueWf = b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review';
|
|
885
|
+
if (!isIssueWf) {
|
|
886
|
+
triggerProjectPipeline(b.workflowName, triggerInput);
|
|
887
|
+
} else {
|
|
888
|
+
setRunMenu(runMenu === b.workflowName ? null : b.workflowName);
|
|
889
|
+
setIssueInput('');
|
|
890
|
+
}
|
|
891
|
+
}}
|
|
892
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
893
|
+
>Run</button>
|
|
894
|
+
{runMenu === b.workflowName && (
|
|
895
|
+
<div className="absolute top-full right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg p-2 space-y-2 w-[200px]">
|
|
896
|
+
<button
|
|
897
|
+
onClick={async () => {
|
|
898
|
+
setRunMenu(null);
|
|
899
|
+
try {
|
|
900
|
+
const res = await fetch('/api/project-pipelines', {
|
|
901
|
+
method: 'POST',
|
|
902
|
+
headers: { 'Content-Type': 'application/json' },
|
|
903
|
+
body: JSON.stringify({ action: 'scan-now', projectPath, projectName, workflowName: b.workflowName }),
|
|
904
|
+
});
|
|
905
|
+
const data = await res.json();
|
|
906
|
+
if (data.error) alert(`Scan error: ${data.error}`);
|
|
907
|
+
else alert(`Scanned ${data.total} issues, triggered ${data.triggered} fix${data.pending > 0 ? ` (${data.pending} more pending)` : ''}`);
|
|
908
|
+
fetchPipelineBindings();
|
|
909
|
+
} catch { alert('Scan failed'); }
|
|
910
|
+
}}
|
|
911
|
+
className="w-full text-[9px] px-2 py-1.5 rounded border border-green-500/50 text-green-400 hover:bg-green-500/10 font-medium"
|
|
912
|
+
>Auto Scan — fix all new issues</button>
|
|
913
|
+
<div className="border-t border-[var(--border)]/50 my-1" />
|
|
914
|
+
<div className="flex items-center gap-1">
|
|
915
|
+
<input
|
|
916
|
+
type="text"
|
|
917
|
+
value={issueInput}
|
|
918
|
+
onChange={e => setIssueInput(e.target.value)}
|
|
919
|
+
placeholder="Issue #"
|
|
920
|
+
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[9px] text-[var(--text-primary)]"
|
|
921
|
+
onKeyDown={e => {
|
|
922
|
+
if (e.key === 'Enter' && issueInput.trim()) {
|
|
923
|
+
setRunMenu(null);
|
|
924
|
+
triggerProjectPipeline(b.workflowName, {
|
|
925
|
+
...triggerInput,
|
|
926
|
+
issue_id: issueInput.trim(),
|
|
927
|
+
base_branch: b.config.baseBranch || 'auto-detect',
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}}
|
|
931
|
+
autoFocus
|
|
932
|
+
/>
|
|
933
|
+
<button
|
|
934
|
+
onClick={() => {
|
|
935
|
+
if (!issueInput.trim()) return;
|
|
936
|
+
setRunMenu(null);
|
|
937
|
+
triggerProjectPipeline(b.workflowName, {
|
|
938
|
+
...triggerInput,
|
|
939
|
+
issue_id: issueInput.trim(),
|
|
940
|
+
base_branch: b.config.baseBranch || 'auto-detect',
|
|
941
|
+
});
|
|
942
|
+
}}
|
|
943
|
+
className="text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-80"
|
|
944
|
+
>Fix</button>
|
|
945
|
+
</div>
|
|
946
|
+
</div>
|
|
947
|
+
)}
|
|
948
|
+
</div>
|
|
853
949
|
<button
|
|
854
950
|
onClick={async () => {
|
|
855
951
|
if (!confirm(`Remove "${b.workflowName}" from this project?`)) return;
|
|
@@ -900,6 +996,51 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
900
996
|
</span>
|
|
901
997
|
)}
|
|
902
998
|
</div>
|
|
999
|
+
{/* Issue scan config (for issue-fix-and-review workflow) */}
|
|
1000
|
+
{(b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review') && (
|
|
1001
|
+
<div className="space-y-1.5 pt-1 border-t border-[var(--border)]/30">
|
|
1002
|
+
{b.config.interval > 0 && (
|
|
1003
|
+
<div className="text-[8px] text-[var(--text-secondary)]">
|
|
1004
|
+
Scheduled mode: auto-scans GitHub issues and fixes new ones
|
|
1005
|
+
</div>
|
|
1006
|
+
)}
|
|
1007
|
+
<div className="flex items-center gap-2 text-[9px]">
|
|
1008
|
+
<label className="text-[var(--text-secondary)]">Labels:</label>
|
|
1009
|
+
<input
|
|
1010
|
+
type="text"
|
|
1011
|
+
defaultValue={(b.config.labels || []).join(', ')}
|
|
1012
|
+
placeholder="bug, autofix (empty = all)"
|
|
1013
|
+
onBlur={async (e) => {
|
|
1014
|
+
const labels = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
1015
|
+
const newConfig = { ...b.config, labels };
|
|
1016
|
+
await fetch('/api/project-pipelines', {
|
|
1017
|
+
method: 'POST',
|
|
1018
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1019
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
1020
|
+
});
|
|
1021
|
+
fetchPipelineBindings();
|
|
1022
|
+
}}
|
|
1023
|
+
className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
1024
|
+
/>
|
|
1025
|
+
<label className="text-[var(--text-secondary)]">Base:</label>
|
|
1026
|
+
<input
|
|
1027
|
+
type="text"
|
|
1028
|
+
defaultValue={b.config.baseBranch || ''}
|
|
1029
|
+
placeholder="auto-detect"
|
|
1030
|
+
onBlur={async (e) => {
|
|
1031
|
+
const newConfig = { ...b.config, baseBranch: e.target.value.trim() || undefined };
|
|
1032
|
+
await fetch('/api/project-pipelines', {
|
|
1033
|
+
method: 'POST',
|
|
1034
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1035
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
1036
|
+
});
|
|
1037
|
+
fetchPipelineBindings();
|
|
1038
|
+
}}
|
|
1039
|
+
className="w-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
1040
|
+
/>
|
|
1041
|
+
</div>
|
|
1042
|
+
</div>
|
|
1043
|
+
)}
|
|
903
1044
|
</div>
|
|
904
1045
|
))
|
|
905
1046
|
)}
|
|
@@ -913,30 +1054,58 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
913
1054
|
{pipelineRuns.map(run => (
|
|
914
1055
|
<div key={run.id} className="flex items-start gap-2 px-3 py-2 border-b border-[var(--border)]/30 last:border-b-0 text-[10px]">
|
|
915
1056
|
<span className={`shrink-0 ${
|
|
916
|
-
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : 'text-yellow-400'
|
|
1057
|
+
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : run.status === 'skipped' ? 'text-gray-400' : 'text-yellow-400'
|
|
917
1058
|
}`}>●</span>
|
|
918
1059
|
<div className="flex-1 min-w-0">
|
|
919
1060
|
<div className="flex items-center gap-2">
|
|
920
1061
|
<span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
|
|
921
|
-
|
|
1062
|
+
{run.dedupKey && (
|
|
1063
|
+
<span className="text-[8px] text-[var(--accent)] font-mono">{run.dedupKey.replace('issue:', '#')}</span>
|
|
1064
|
+
)}
|
|
1065
|
+
<button
|
|
1066
|
+
onClick={() => window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: run.pipelineId } }))}
|
|
1067
|
+
className="text-[8px] text-[var(--accent)] font-mono hover:underline"
|
|
1068
|
+
title="View in Pipelines"
|
|
1069
|
+
>{run.pipelineId.slice(0, 8)}</button>
|
|
922
1070
|
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">{new Date(run.createdAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
923
1071
|
</div>
|
|
924
1072
|
{run.summary && (
|
|
925
1073
|
<pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
|
|
926
1074
|
)}
|
|
927
1075
|
</div>
|
|
928
|
-
<
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1076
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
1077
|
+
{run.status === 'failed' && run.dedupKey && (
|
|
1078
|
+
<button
|
|
1079
|
+
onClick={async () => {
|
|
1080
|
+
await fetch('/api/project-pipelines', {
|
|
1081
|
+
method: 'POST',
|
|
1082
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1083
|
+
body: JSON.stringify({ action: 'reset-dedup', projectPath, workflowName: run.workflowName, dedupKey: run.dedupKey }),
|
|
1084
|
+
});
|
|
1085
|
+
// Delete the failed run then re-scan
|
|
1086
|
+
await fetch('/api/project-pipelines', {
|
|
1087
|
+
method: 'POST',
|
|
1088
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1089
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1090
|
+
});
|
|
1091
|
+
fetchPipelineBindings();
|
|
1092
|
+
}}
|
|
1093
|
+
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
1094
|
+
>Retry</button>
|
|
1095
|
+
)}
|
|
1096
|
+
<button
|
|
1097
|
+
onClick={async () => {
|
|
1098
|
+
if (!confirm('Delete this run?')) return;
|
|
1099
|
+
await fetch('/api/project-pipelines', {
|
|
1100
|
+
method: 'POST',
|
|
1101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1102
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1103
|
+
});
|
|
1104
|
+
fetchPipelineBindings();
|
|
1105
|
+
}}
|
|
1106
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1107
|
+
>×</button>
|
|
1108
|
+
</div>
|
|
940
1109
|
</div>
|
|
941
1110
|
))}
|
|
942
1111
|
</div>
|
package/components/TabBar.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose }: TabBarPr
|
|
|
18
18
|
if (tabs.length === 0) return null;
|
|
19
19
|
|
|
20
20
|
return (
|
|
21
|
-
<div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0">
|
|
21
|
+
<div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0 min-w-0 max-w-full">
|
|
22
22
|
{tabs.map(tab => (
|
|
23
23
|
<div
|
|
24
24
|
key={tab.id}
|
package/lib/cloudflared.ts
CHANGED
|
@@ -171,8 +171,9 @@ function pushLog(line: string) {
|
|
|
171
171
|
|
|
172
172
|
export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
|
|
173
173
|
console.log(`[tunnel] Starting tunnel on port ${localPort}...`);
|
|
174
|
-
//
|
|
175
|
-
|
|
174
|
+
// Prevent concurrent starts: state.process is already spawned, or another call is
|
|
175
|
+
// mid-flight between the guard and spawn (the async download window).
|
|
176
|
+
if (state.process || state.status === 'starting') {
|
|
176
177
|
return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -182,6 +183,13 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
|
|
|
182
183
|
try { process.kill(saved.pid, 0); return { url: saved.url }; } catch {}
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
// Claim 'starting' before any async work so concurrent callers are blocked
|
|
187
|
+
// from this point onward (pgrep kill + download can take seconds).
|
|
188
|
+
state.status = 'starting';
|
|
189
|
+
state.url = null;
|
|
190
|
+
state.error = null;
|
|
191
|
+
state.log = [];
|
|
192
|
+
|
|
185
193
|
// Kill ALL existing cloudflared processes to prevent duplicates
|
|
186
194
|
try {
|
|
187
195
|
const { execSync } = require('node:child_process');
|
|
@@ -191,11 +199,6 @@ export async function startTunnel(localPort: number = parseInt(process.env.PORT
|
|
|
191
199
|
}
|
|
192
200
|
} catch {}
|
|
193
201
|
|
|
194
|
-
state.status = 'starting';
|
|
195
|
-
state.url = null;
|
|
196
|
-
state.error = null;
|
|
197
|
-
state.log = [];
|
|
198
|
-
|
|
199
202
|
// Generate new session code for remote login 2FA
|
|
200
203
|
try {
|
|
201
204
|
const { rotateSessionCode } = require('./password');
|
|
@@ -217,7 +217,7 @@ nodes:
|
|
|
217
217
|
|
|
218
218
|
## Built-in Workflows
|
|
219
219
|
|
|
220
|
-
### issue-
|
|
220
|
+
### issue-fix-and-review
|
|
221
221
|
Complete issue resolution: fetch GitHub issue → fix code on new branch → create PR.
|
|
222
222
|
|
|
223
223
|
**Input**: `issue_id`, `project`, `base_branch` (optional), `extra_context` (optional)
|
|
@@ -265,22 +265,22 @@ curl "http://localhost:3000/api/project-pipelines?project=/path/to/project"
|
|
|
265
265
|
# Add binding
|
|
266
266
|
curl -X POST http://localhost:3000/api/project-pipelines \
|
|
267
267
|
-H 'Content-Type: application/json' \
|
|
268
|
-
-d '{"action":"add","projectPath":"/path","projectName":"my-app","workflowName":"issue-
|
|
268
|
+
-d '{"action":"add","projectPath":"/path","projectName":"my-app","workflowName":"issue-fix-and-review"}'
|
|
269
269
|
|
|
270
270
|
# Update binding (enable/disable, change config/schedule)
|
|
271
271
|
curl -X POST http://localhost:3000/api/project-pipelines \
|
|
272
272
|
-H 'Content-Type: application/json' \
|
|
273
|
-
-d '{"action":"update","projectPath":"/path","workflowName":"issue-
|
|
273
|
+
-d '{"action":"update","projectPath":"/path","workflowName":"issue-fix-and-review","config":{"interval":30}}'
|
|
274
274
|
|
|
275
275
|
# Trigger pipeline manually
|
|
276
276
|
curl -X POST http://localhost:3000/api/project-pipelines \
|
|
277
277
|
-H 'Content-Type: application/json' \
|
|
278
|
-
-d '{"action":"trigger","projectPath":"/path","projectName":"my-app","workflowName":"issue-
|
|
278
|
+
-d '{"action":"trigger","projectPath":"/path","projectName":"my-app","workflowName":"issue-fix-and-review","input":{"issue_id":"42"}}'
|
|
279
279
|
|
|
280
280
|
# Remove binding
|
|
281
281
|
curl -X POST http://localhost:3000/api/project-pipelines \
|
|
282
282
|
-H 'Content-Type: application/json' \
|
|
283
|
-
-d '{"action":"remove","projectPath":"/path","workflowName":"issue-
|
|
283
|
+
-d '{"action":"remove","projectPath":"/path","workflowName":"issue-fix-and-review"}'
|
|
284
284
|
```
|
|
285
285
|
|
|
286
286
|
## CLI
|
|
@@ -2,41 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
Automatically scan GitHub Issues, fix code, create PRs — all hands-free. Uses the built-in `issue-
|
|
5
|
+
Automatically scan GitHub Issues, fix code, create PRs — all hands-free. Uses the built-in `issue-fix-and-review` pipeline workflow with integrated issue scanning.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
9
9
|
- `gh` CLI installed and authenticated: `gh auth login`
|
|
10
10
|
- Project has a GitHub remote
|
|
11
11
|
|
|
12
|
-
## Setup
|
|
12
|
+
## Setup
|
|
13
13
|
|
|
14
14
|
1. Go to **Projects → select project → Pipelines tab**
|
|
15
|
-
2. Click **+ Add** and select `issue-
|
|
15
|
+
2. Click **+ Add** and select `issue-fix-and-review`
|
|
16
16
|
3. Enable the binding
|
|
17
|
-
4.
|
|
18
|
-
5.
|
|
17
|
+
4. Check **Auto-scan GitHub Issues** to enable automatic scanning
|
|
18
|
+
5. Configure:
|
|
19
|
+
- **Schedule**: How often to scan (e.g., Every 30 min)
|
|
20
|
+
- **Labels**: Filter issues by label (comma-separated, empty = all)
|
|
21
|
+
- **Base Branch**: Leave empty for auto-detect (main/master)
|
|
22
|
+
6. Click **Scan** to manually trigger a scan
|
|
19
23
|
|
|
20
24
|
## Flow
|
|
21
25
|
|
|
22
26
|
```
|
|
23
|
-
|
|
27
|
+
Scan Issues → For each new issue:
|
|
28
|
+
Setup → Fetch Issue → Fix Code (new branch) → Push & Create PR → Notify
|
|
24
29
|
```
|
|
25
30
|
|
|
26
|
-
1. **
|
|
27
|
-
2. **
|
|
28
|
-
3. **
|
|
29
|
-
4. **
|
|
30
|
-
5. **
|
|
31
|
+
1. **Scan**: `gh issue list` finds open issues matching labels
|
|
32
|
+
2. **Dedup**: Already-processed issues are skipped (tracked in `pipeline_runs`)
|
|
33
|
+
3. **Setup**: Checks for clean working directory, detects repo and base branch
|
|
34
|
+
4. **Fetch Issue**: `gh issue view` fetches issue data
|
|
35
|
+
5. **Fix Code**: Claude analyzes issue and fixes code on `fix/<id>-<description>` branch
|
|
36
|
+
6. **Push & PR**: Pushes branch and creates Pull Request via `gh pr create`
|
|
37
|
+
7. **Notify**: Switches back to original branch, reports PR URL
|
|
31
38
|
|
|
32
|
-
##
|
|
39
|
+
## Manual Trigger
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
- **Run** button: Triggers the workflow with custom input (requires `issue_id`)
|
|
42
|
+
- **Scan** button: Scans for all open issues and triggers fixes for new ones
|
|
43
|
+
|
|
44
|
+
## Dedup
|
|
45
|
+
|
|
46
|
+
Each processed issue is tracked with a `dedup_key` (e.g., `issue:42`) in the pipeline runs table. Once an issue has been processed, it won't be triggered again even if it's still open. To re-process an issue, delete its run from the execution history.
|
|
40
47
|
|
|
41
48
|
## Safety
|
|
42
49
|
|
|
@@ -45,7 +52,4 @@ Setup → Fetch Issue → Fix Code (new branch) → Push & Create PR → Notify
|
|
|
45
52
|
- Cleans up old fix branches for the same issue
|
|
46
53
|
- Switches back to original branch after completion
|
|
47
54
|
- Uses `--force-with-lease` for safe push
|
|
48
|
-
|
|
49
|
-
## Legacy Issue Scanner
|
|
50
|
-
|
|
51
|
-
The old issue scanner (`Projects → Issues tab`) is still functional for existing configurations. It uses `issue_autofix_config` DB table for per-project scan settings. New projects should use the pipeline binding approach above.
|
|
55
|
+
- Running pipelines are not re-triggered (one fix per issue at a time)
|
package/lib/init.ts
CHANGED
|
@@ -95,18 +95,12 @@ export function ensureInitialized() {
|
|
|
95
95
|
// Session watcher is safe (file-based, idempotent)
|
|
96
96
|
startWatcherLoop();
|
|
97
97
|
|
|
98
|
-
// Pipeline scheduler — periodic execution for project-bound workflows
|
|
98
|
+
// Pipeline scheduler — periodic execution + issue scanning for project-bound workflows
|
|
99
99
|
try {
|
|
100
100
|
const { startScheduler } = require('./pipeline-scheduler');
|
|
101
101
|
startScheduler();
|
|
102
102
|
} catch {}
|
|
103
103
|
|
|
104
|
-
// Legacy issue scanner (still used if issue_autofix_config has entries)
|
|
105
|
-
try {
|
|
106
|
-
const { startScanner } = require('./issue-scanner');
|
|
107
|
-
startScanner();
|
|
108
|
-
} catch {}
|
|
109
|
-
|
|
110
104
|
// If services are managed externally (forge-server), skip
|
|
111
105
|
if (process.env.FORGE_EXTERNAL_SERVICES === '1') {
|
|
112
106
|
// Password display
|
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pipeline Scheduler — manages project-pipeline bindings
|
|
3
|
-
*
|
|
2
|
+
* Pipeline Scheduler — manages project-pipeline bindings, scheduled execution,
|
|
3
|
+
* and issue scanning (replaces issue-scanner.ts).
|
|
4
4
|
*
|
|
5
5
|
* Each project can bind multiple workflows. Each binding has:
|
|
6
|
-
* - config: JSON with workflow-specific settings (
|
|
6
|
+
* - config: JSON with workflow-specific settings (interval, scanType, labels, baseBranch)
|
|
7
7
|
* - enabled: on/off toggle
|
|
8
8
|
* - scheduled execution via config.interval (minutes, 0 = manual only)
|
|
9
|
+
* - config.scanType: 'github-issues' enables automatic issue scanning + dedup
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { getDb } from '@/src/core/db/database';
|
|
12
13
|
import { getDbPath } from '@/src/config';
|
|
13
14
|
import { startPipeline, getPipeline } from './pipeline';
|
|
14
15
|
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
15
17
|
|
|
16
18
|
function db() { return getDb(getDbPath()); }
|
|
17
19
|
|
|
20
|
+
/** Normalize SQLite datetime('now') → ISO 8601 UTC string. */
|
|
21
|
+
function toIsoUTC(s: string | null): string | null {
|
|
22
|
+
if (!s) return null;
|
|
23
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s.replace(' ', 'T') + 'Z';
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export interface ProjectPipelineBinding {
|
|
19
28
|
id: number;
|
|
20
29
|
projectPath: string;
|
|
21
30
|
projectName: string;
|
|
22
31
|
workflowName: string;
|
|
23
32
|
enabled: boolean;
|
|
24
|
-
config: Record<string, any>; // interval
|
|
33
|
+
config: Record<string, any>; // interval, scanType, labels, baseBranch, etc.
|
|
25
34
|
lastRunAt: string | null;
|
|
26
35
|
createdAt: string;
|
|
27
36
|
}
|
|
@@ -33,6 +42,7 @@ export interface PipelineRun {
|
|
|
33
42
|
pipelineId: string;
|
|
34
43
|
status: string;
|
|
35
44
|
summary: string;
|
|
45
|
+
dedupKey: string | null;
|
|
36
46
|
createdAt: string;
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -47,8 +57,8 @@ export function getBindings(projectPath: string): ProjectPipelineBinding[] {
|
|
|
47
57
|
workflowName: r.workflow_name,
|
|
48
58
|
enabled: !!r.enabled,
|
|
49
59
|
config: JSON.parse(r.config || '{}'),
|
|
50
|
-
lastRunAt: r.last_run_at
|
|
51
|
-
createdAt: r.created_at,
|
|
60
|
+
lastRunAt: toIsoUTC(r.last_run_at),
|
|
61
|
+
createdAt: toIsoUTC(r.created_at) ?? r.created_at,
|
|
52
62
|
}));
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -61,8 +71,8 @@ export function getAllScheduledBindings(): ProjectPipelineBinding[] {
|
|
|
61
71
|
workflowName: r.workflow_name,
|
|
62
72
|
enabled: true,
|
|
63
73
|
config: JSON.parse(r.config || '{}'),
|
|
64
|
-
lastRunAt: r.last_run_at
|
|
65
|
-
createdAt: r.created_at,
|
|
74
|
+
lastRunAt: toIsoUTC(r.last_run_at),
|
|
75
|
+
createdAt: toIsoUTC(r.created_at) ?? r.created_at,
|
|
66
76
|
})).filter(b => b.config.interval && b.config.interval > 0);
|
|
67
77
|
}
|
|
68
78
|
|
|
@@ -96,12 +106,12 @@ function updateLastRunAt(projectPath: string, workflowName: string): void {
|
|
|
96
106
|
|
|
97
107
|
// ─── Runs ────────────────────────────────────────────────
|
|
98
108
|
|
|
99
|
-
export function recordRun(projectPath: string, workflowName: string, pipelineId: string): string {
|
|
109
|
+
export function recordRun(projectPath: string, workflowName: string, pipelineId: string, dedupKey?: string): string {
|
|
100
110
|
const id = randomUUID().slice(0, 8);
|
|
101
111
|
db().prepare(`
|
|
102
|
-
INSERT INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status)
|
|
103
|
-
VALUES (?, ?, ?, ?, 'running')
|
|
104
|
-
`).run(id, projectPath, workflowName, pipelineId);
|
|
112
|
+
INSERT INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status, dedup_key)
|
|
113
|
+
VALUES (?, ?, ?, ?, 'running', ?)
|
|
114
|
+
`).run(id, projectPath, workflowName, pipelineId, dedupKey || null);
|
|
105
115
|
return id;
|
|
106
116
|
}
|
|
107
117
|
|
|
@@ -127,7 +137,8 @@ export function getRuns(projectPath: string, workflowName?: string, limit = 20):
|
|
|
127
137
|
pipelineId: r.pipeline_id,
|
|
128
138
|
status: r.status,
|
|
129
139
|
summary: r.summary || '',
|
|
130
|
-
|
|
140
|
+
dedupKey: r.dedup_key || null,
|
|
141
|
+
createdAt: toIsoUTC(r.created_at) ?? r.created_at,
|
|
131
142
|
}));
|
|
132
143
|
}
|
|
133
144
|
|
|
@@ -135,18 +146,36 @@ export function deleteRun(id: string): void {
|
|
|
135
146
|
db().prepare('DELETE FROM pipeline_runs WHERE id = ?').run(id);
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
// ─── Dedup ──────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function isDuplicate(projectPath: string, workflowName: string, dedupKey: string): boolean {
|
|
152
|
+
const row = db().prepare(
|
|
153
|
+
'SELECT 1 FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ?'
|
|
154
|
+
).get(projectPath, workflowName, dedupKey);
|
|
155
|
+
return !!row;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function resetDedup(projectPath: string, workflowName: string, dedupKey: string): void {
|
|
159
|
+
db().prepare(
|
|
160
|
+
'DELETE FROM pipeline_runs WHERE project_path = ? AND workflow_name = ? AND dedup_key = ?'
|
|
161
|
+
).run(projectPath, workflowName, dedupKey);
|
|
162
|
+
}
|
|
163
|
+
|
|
138
164
|
// ─── Trigger ─────────────────────────────────────────────
|
|
139
165
|
|
|
140
|
-
export function triggerPipeline(
|
|
166
|
+
export function triggerPipeline(
|
|
167
|
+
projectPath: string, projectName: string, workflowName: string,
|
|
168
|
+
extraInput?: Record<string, any>, dedupKey?: string
|
|
169
|
+
): { pipelineId: string; runId: string } {
|
|
141
170
|
const input: Record<string, string> = {
|
|
142
171
|
project: projectName,
|
|
143
172
|
...extraInput,
|
|
144
173
|
};
|
|
145
174
|
|
|
146
175
|
const pipeline = startPipeline(workflowName, input);
|
|
147
|
-
const runId = recordRun(projectPath, workflowName, pipeline.id);
|
|
176
|
+
const runId = recordRun(projectPath, workflowName, pipeline.id, dedupKey);
|
|
148
177
|
updateLastRunAt(projectPath, workflowName);
|
|
149
|
-
console.log(`[pipeline-scheduler] Triggered ${workflowName} for ${projectName} (pipeline: ${pipeline.id})`);
|
|
178
|
+
console.log(`[pipeline-scheduler] Triggered ${workflowName} for ${projectName} (pipeline: ${pipeline.id}${dedupKey ? ', dedup: ' + dedupKey : ''})`);
|
|
150
179
|
return { pipelineId: pipeline.id, runId };
|
|
151
180
|
}
|
|
152
181
|
|
|
@@ -171,6 +200,94 @@ export function syncRunStatus(pipelineId: string): void {
|
|
|
171
200
|
updateRun(pipelineId, pipeline.status, summary.trim());
|
|
172
201
|
}
|
|
173
202
|
|
|
203
|
+
// ─── GitHub Issue Scanning ──────────────────────────────
|
|
204
|
+
|
|
205
|
+
function getRepoFromProject(projectPath: string): string | null {
|
|
206
|
+
try {
|
|
207
|
+
return execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
|
|
208
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
209
|
+
}).trim() || null;
|
|
210
|
+
} catch {
|
|
211
|
+
try {
|
|
212
|
+
const url = execSync('git remote get-url origin', {
|
|
213
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
214
|
+
}).trim();
|
|
215
|
+
return url.replace(/.*github\.com[:/]/, '').replace(/\.git$/, '') || null;
|
|
216
|
+
} catch { return null; }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function fetchOpenIssues(projectPath: string, labels: string[]): { number: number; title: string; error?: string }[] {
|
|
221
|
+
const repo = getRepoFromProject(projectPath);
|
|
222
|
+
if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
|
|
223
|
+
try {
|
|
224
|
+
const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
|
|
225
|
+
const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
|
|
226
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
227
|
+
});
|
|
228
|
+
return JSON.parse(out) || [];
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
const msg = e.stderr?.toString() || e.message || 'gh CLI failed';
|
|
231
|
+
return [{ number: -1, title: '', error: msg.includes('auth') ? 'GitHub CLI not authenticated. Run: gh auth login' : msg }];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function scanAndTriggerIssues(binding: ProjectPipelineBinding): { triggered: number; issues: number[]; total: number; pending: number; error?: string } {
|
|
236
|
+
const labels: string[] = binding.config.labels || [];
|
|
237
|
+
const issues = fetchOpenIssues(binding.projectPath, labels);
|
|
238
|
+
|
|
239
|
+
// Check for errors
|
|
240
|
+
if (issues.length === 1 && (issues[0] as any).error) {
|
|
241
|
+
return { triggered: 0, issues: [], total: 0, pending: 0, error: (issues[0] as any).error };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check if there's already a running pipeline for this project+workflow — only one at a time
|
|
245
|
+
// to prevent concurrent git operations on the same repo
|
|
246
|
+
const recentRuns = getRuns(binding.projectPath, binding.workflowName, 5);
|
|
247
|
+
const hasRunning = recentRuns.some(r => r.status === 'running');
|
|
248
|
+
|
|
249
|
+
const newIssues: { number: number; title: string }[] = [];
|
|
250
|
+
for (const issue of issues) {
|
|
251
|
+
if (issue.number < 0) continue;
|
|
252
|
+
const dedupKey = `issue:${issue.number}`;
|
|
253
|
+
if (!isDuplicate(binding.projectPath, binding.workflowName, dedupKey)) {
|
|
254
|
+
newIssues.push(issue);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (newIssues.length === 0) {
|
|
259
|
+
updateLastRunAt(binding.projectPath, binding.workflowName);
|
|
260
|
+
return { triggered: 0, issues: [], total: issues.length, pending: 0 };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Only trigger ONE issue at a time to avoid concurrent git conflicts
|
|
264
|
+
// Next issue will be triggered on the next scan cycle
|
|
265
|
+
if (hasRunning) {
|
|
266
|
+
console.log(`[pipeline-scheduler] Issue scan: ${newIssues.length} new issues for ${binding.projectName}, waiting for current pipeline to finish`);
|
|
267
|
+
return { triggered: 0, issues: [], total: issues.length, pending: newIssues.length };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const issue = newIssues[0];
|
|
271
|
+
const dedupKey = `issue:${issue.number}`;
|
|
272
|
+
try {
|
|
273
|
+
triggerPipeline(
|
|
274
|
+
binding.projectPath, binding.projectName, binding.workflowName,
|
|
275
|
+
{
|
|
276
|
+
issue_id: String(issue.number),
|
|
277
|
+
base_branch: binding.config.baseBranch || 'auto-detect',
|
|
278
|
+
},
|
|
279
|
+
dedupKey
|
|
280
|
+
);
|
|
281
|
+
console.log(`[pipeline-scheduler] Issue scan: triggered #${issue.number} "${issue.title}" for ${binding.projectName} (${newIssues.length - 1} more pending)`);
|
|
282
|
+
} catch (e: any) {
|
|
283
|
+
console.error(`[pipeline-scheduler] Issue scan: failed to trigger #${issue.number}:`, e.message);
|
|
284
|
+
return { triggered: 0, issues: [], total: issues.length, pending: newIssues.length, error: e.message };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
updateLastRunAt(binding.projectPath, binding.workflowName);
|
|
288
|
+
return { triggered: 1, issues: [issue.number], total: issues.length, pending: newIssues.length - 1 };
|
|
289
|
+
}
|
|
290
|
+
|
|
174
291
|
// ─── Periodic Scheduler ─────────────────────────────────
|
|
175
292
|
|
|
176
293
|
const schedulerKey = Symbol.for('forge-pipeline-scheduler');
|
|
@@ -210,19 +327,24 @@ function tickScheduler(): void {
|
|
|
210
327
|
const lastRun = binding.lastRunAt ? new Date(binding.lastRunAt).getTime() : 0;
|
|
211
328
|
const elapsed = now - lastRun;
|
|
212
329
|
|
|
213
|
-
if (elapsed
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
330
|
+
if (elapsed < intervalMs) continue;
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const isIssueWorkflow = binding.workflowName === 'issue-fix-and-review' || binding.workflowName === 'issue-auto-fix' || binding.config.scanType === 'github-issues';
|
|
334
|
+
if (isIssueWorkflow) {
|
|
335
|
+
// Issue scan mode: fetch issues → dedup → trigger per issue
|
|
336
|
+
console.log(`[pipeline-scheduler] Scheduled issue scan: ${binding.workflowName} for ${binding.projectName}`);
|
|
337
|
+
scanAndTriggerIssues(binding);
|
|
338
|
+
} else {
|
|
339
|
+
// Normal mode: single trigger (skip if still running)
|
|
340
|
+
const recentRuns = getRuns(binding.projectPath, binding.workflowName, 1);
|
|
341
|
+
if (recentRuns.length > 0 && recentRuns[0].status === 'running') continue;
|
|
219
342
|
|
|
220
|
-
try {
|
|
221
343
|
console.log(`[pipeline-scheduler] Scheduled trigger: ${binding.workflowName} for ${binding.projectName}`);
|
|
222
344
|
triggerPipeline(binding.projectPath, binding.projectName, binding.workflowName, binding.config.input);
|
|
223
|
-
} catch (e: any) {
|
|
224
|
-
console.error(`[pipeline-scheduler] Scheduled trigger failed for ${binding.workflowName}:`, e.message);
|
|
225
345
|
}
|
|
346
|
+
} catch (e: any) {
|
|
347
|
+
console.error(`[pipeline-scheduler] Scheduled trigger failed for ${binding.workflowName}:`, e.message);
|
|
226
348
|
}
|
|
227
349
|
}
|
|
228
350
|
} catch (e: any) {
|
package/lib/pipeline.ts
CHANGED
|
@@ -92,7 +92,7 @@ nodes:
|
|
|
92
92
|
if [ -n "$(git status --porcelain)" ]; then echo "ERROR: Working directory has uncommitted changes. Please commit or stash first." && exit 1; fi && \
|
|
93
93
|
ORIG_BRANCH=$(git branch --show-current || git rev-parse --short HEAD) && \
|
|
94
94
|
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//') && \
|
|
95
|
-
BASE={{input.base_branch}} && \
|
|
95
|
+
BASE="{{input.base_branch}}" && \
|
|
96
96
|
if [ -z "$BASE" ] || [ "$BASE" = "auto-detect" ]; then BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main); fi && \
|
|
97
97
|
git checkout "$BASE" 2>/dev/null || true && \
|
|
98
98
|
git pull origin "$BASE" 2>/dev/null || true && \
|
|
@@ -109,7 +109,8 @@ nodes:
|
|
|
109
109
|
prompt: |
|
|
110
110
|
ISSUE_ID="{{input.issue_id}}" && \
|
|
111
111
|
if [ -z "$ISSUE_ID" ]; then echo "__SKIP__ No issue_id provided" && exit 0; fi && \
|
|
112
|
-
|
|
112
|
+
SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
|
|
113
|
+
REPO=$(echo "$SETUP_INFO" | grep REPO= | cut -d= -f2) && \
|
|
113
114
|
gh issue view "$ISSUE_ID" --json title,body,labels,number -R "$REPO"
|
|
114
115
|
outputs:
|
|
115
116
|
- name: issue_json
|
|
@@ -140,11 +141,12 @@ nodes:
|
|
|
140
141
|
project: "{{input.project}}"
|
|
141
142
|
depends_on: [fix-code]
|
|
142
143
|
prompt: |
|
|
143
|
-
|
|
144
|
+
SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
|
|
145
|
+
REPO=$(echo "$SETUP_INFO" | grep REPO= | cut -d= -f2) && \
|
|
144
146
|
BRANCH=$(git branch --show-current) && \
|
|
145
147
|
git push -u origin "$BRANCH" --force-with-lease 2>&1 && \
|
|
146
|
-
PR_URL=$(gh pr create --title
|
|
147
|
-
--body
|
|
148
|
+
PR_URL=$(gh pr create --title "Fix #{{input.issue_id}}" \
|
|
149
|
+
--body "Auto-fix by Forge Pipeline for issue #{{input.issue_id}}." -R "$REPO" 2>/dev/null || \
|
|
148
150
|
gh pr view "$BRANCH" --json url -q .url -R "$REPO" 2>/dev/null) && \
|
|
149
151
|
echo "$PR_URL"
|
|
150
152
|
outputs:
|
|
@@ -178,12 +180,14 @@ nodes:
|
|
|
178
180
|
project: "{{input.project}}"
|
|
179
181
|
depends_on: [review]
|
|
180
182
|
prompt: |
|
|
181
|
-
|
|
183
|
+
SETUP_INFO=$'{{nodes.setup.outputs.info}}' && \
|
|
184
|
+
ORIG=$(echo "$SETUP_INFO" | grep ORIG_BRANCH= | cut -d= -f2) && \
|
|
185
|
+
PR_URL=$'{{nodes.push-and-pr.outputs.pr_url}}' && \
|
|
182
186
|
if [ -n "$(git status --porcelain)" ]; then
|
|
183
|
-
echo "Issue #{{input.issue_id}} — PR:
|
|
187
|
+
echo "Issue #{{input.issue_id}} — PR: $PR_URL (staying on $(git branch --show-current))"
|
|
184
188
|
else
|
|
185
189
|
git checkout "$ORIG" 2>/dev/null || true
|
|
186
|
-
echo "Issue #{{input.issue_id}} — PR:
|
|
190
|
+
echo "Issue #{{input.issue_id}} — PR: $PR_URL (switched back to $ORIG)"
|
|
187
191
|
fi
|
|
188
192
|
outputs:
|
|
189
193
|
- name: result
|
|
@@ -306,12 +310,22 @@ export function listPipelines(): Pipeline[] {
|
|
|
306
310
|
|
|
307
311
|
// ─── Template Resolution ──────────────────────────────────
|
|
308
312
|
|
|
309
|
-
/** Escape a string for safe embedding in
|
|
313
|
+
/** Escape a string for safe embedding in single-quoted shell strings */
|
|
310
314
|
function shellEscape(s: string): string {
|
|
311
315
|
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
|
|
312
316
|
return s.replace(/'/g, "'\\''");
|
|
313
317
|
}
|
|
314
318
|
|
|
319
|
+
/** Escape a string for safe embedding in $'...' shell strings (ANSI-C quoting) */
|
|
320
|
+
function shellEscapeAnsiC(s: string): string {
|
|
321
|
+
return s
|
|
322
|
+
.replace(/\\/g, '\\\\')
|
|
323
|
+
.replace(/'/g, "\\'")
|
|
324
|
+
.replace(/\n/g, '\\n')
|
|
325
|
+
.replace(/\r/g, '\\r')
|
|
326
|
+
.replace(/\t/g, '\\t');
|
|
327
|
+
}
|
|
328
|
+
|
|
315
329
|
function resolveTemplate(template: string, ctx: {
|
|
316
330
|
input: Record<string, string>;
|
|
317
331
|
vars: Record<string, string>;
|
|
@@ -336,7 +350,7 @@ function resolveTemplate(template: string, ctx: {
|
|
|
336
350
|
}
|
|
337
351
|
}
|
|
338
352
|
|
|
339
|
-
return shellMode ?
|
|
353
|
+
return shellMode ? shellEscapeAnsiC(value) : value;
|
|
340
354
|
});
|
|
341
355
|
}
|
|
342
356
|
|
package/lib/task-manager.ts
CHANGED
|
@@ -12,6 +12,13 @@ import { loadSettings } from './settings';
|
|
|
12
12
|
import { notifyTaskComplete, notifyTaskFailed } from './notify';
|
|
13
13
|
import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '@/src/types';
|
|
14
14
|
|
|
15
|
+
/** Normalize SQLite datetime('now') → ISO 8601 UTC string. */
|
|
16
|
+
function toIsoUTC(s: string | null | undefined): string | null {
|
|
17
|
+
if (!s) return null;
|
|
18
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s)) return s.replace(' ', 'T') + 'Z';
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
const runnerKey = Symbol.for('mw-task-runner');
|
|
16
23
|
const gRunner = globalThis as any;
|
|
17
24
|
if (!gRunner[runnerKey]) gRunner[runnerKey] = { runner: null, currentTaskId: null };
|
|
@@ -601,10 +608,10 @@ function rowToTask(row: any): Task {
|
|
|
601
608
|
gitBranch: row.git_branch || undefined,
|
|
602
609
|
costUSD: row.cost_usd || undefined,
|
|
603
610
|
error: row.error || undefined,
|
|
604
|
-
createdAt: row.created_at,
|
|
605
|
-
startedAt: row.started_at
|
|
606
|
-
completedAt: row.completed_at
|
|
607
|
-
scheduledAt: row.scheduled_at
|
|
611
|
+
createdAt: toIsoUTC(row.created_at) ?? row.created_at,
|
|
612
|
+
startedAt: toIsoUTC(row.started_at) ?? undefined,
|
|
613
|
+
completedAt: toIsoUTC(row.completed_at) ?? undefined,
|
|
614
|
+
scheduledAt: toIsoUTC(row.scheduled_at) ?? undefined,
|
|
608
615
|
};
|
|
609
616
|
}
|
|
610
617
|
|
package/package.json
CHANGED
package/src/core/db/database.ts
CHANGED
|
@@ -35,6 +35,25 @@ function initSchema(db: Database.Database) {
|
|
|
35
35
|
migrate('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
|
|
36
36
|
migrate('ALTER TABLE skills ADD COLUMN deleted_remotely INTEGER NOT NULL DEFAULT 0');
|
|
37
37
|
migrate('ALTER TABLE project_pipelines ADD COLUMN last_run_at TEXT');
|
|
38
|
+
migrate('ALTER TABLE pipeline_runs ADD COLUMN dedup_key TEXT');
|
|
39
|
+
// Unique index for dedup (only applies when dedup_key is NOT NULL)
|
|
40
|
+
try { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_runs_dedup ON pipeline_runs(project_path, workflow_name, dedup_key)'); } catch {}
|
|
41
|
+
// Migrate old issue_autofix_processed → pipeline_runs
|
|
42
|
+
try {
|
|
43
|
+
const old = db.prepare('SELECT * FROM issue_autofix_processed').all() as any[];
|
|
44
|
+
if (old.length > 0) {
|
|
45
|
+
const ins = db.prepare('INSERT OR IGNORE INTO pipeline_runs (id, project_path, workflow_name, pipeline_id, status, dedup_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
46
|
+
for (const r of old) {
|
|
47
|
+
ins.run(
|
|
48
|
+
r.pipeline_id?.slice(0, 8) || ('mig-' + r.issue_number),
|
|
49
|
+
r.project_path, 'issue-fix-and-review', r.pipeline_id || '',
|
|
50
|
+
r.status === 'processing' ? 'running' : (r.status || 'done'),
|
|
51
|
+
`issue:${r.issue_number}`, r.created_at || new Date().toISOString()
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
console.log(`[db] Migrated ${old.length} issue_autofix_processed records to pipeline_runs`);
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
38
57
|
|
|
39
58
|
db.exec(`
|
|
40
59
|
CREATE TABLE IF NOT EXISTS sessions (
|