@aion0/forge 0.4.2 → 0.4.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/README.md +1 -1
- package/RELEASE_NOTES.md +19 -6
- package/app/api/issue-scanner/route.ts +2 -2
- package/app/api/pipelines/route.ts +14 -0
- package/app/api/project-pipelines/route.ts +68 -0
- package/app/icon.png +0 -0
- package/app/login/page.tsx +1 -0
- package/components/Dashboard.tsx +11 -12
- package/components/PipelineEditor.tsx +3 -1
- package/components/PipelineView.tsx +253 -128
- package/components/ProjectDetail.tsx +163 -230
- package/forge-logo.png +0 -0
- package/lib/help-docs/05-pipelines.md +22 -7
- package/lib/help-docs/09-issue-autofix.md +1 -1
- package/lib/init.ts +7 -1
- package/lib/issue-scanner.ts +2 -2
- package/lib/pipeline-scheduler.ts +239 -0
- package/lib/pipeline.ts +43 -87
- package/middleware.ts +2 -1
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +24 -0
- package/app/icon.svg +0 -26
|
@@ -64,15 +64,13 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
64
64
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
65
65
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
66
66
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
67
|
-
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | '
|
|
68
|
-
//
|
|
69
|
-
const [
|
|
70
|
-
const [
|
|
71
|
-
const [
|
|
72
|
-
const [
|
|
73
|
-
const [
|
|
74
|
-
const [issueLastScan, setIssueLastScan] = useState<string | null>(null);
|
|
75
|
-
const [retryModal, setRetryModal] = useState<{ issueNumber: number; context: string } | null>(null);
|
|
67
|
+
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
68
|
+
// Pipeline bindings state
|
|
69
|
+
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
70
|
+
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; createdAt: string }[]>([]);
|
|
71
|
+
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean }[]>([]);
|
|
72
|
+
const [showAddPipeline, setShowAddPipeline] = useState(false);
|
|
73
|
+
const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
|
|
76
74
|
const [claudeMdContent, setClaudeMdContent] = useState('');
|
|
77
75
|
const [claudeMdExists, setClaudeMdExists] = useState(false);
|
|
78
76
|
const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
|
|
@@ -220,57 +218,28 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
220
218
|
fetchProjectSkills();
|
|
221
219
|
};
|
|
222
220
|
|
|
223
|
-
const
|
|
221
|
+
const fetchPipelineBindings = useCallback(async () => {
|
|
224
222
|
try {
|
|
225
|
-
const res = await fetch(`/api/
|
|
223
|
+
const res = await fetch(`/api/project-pipelines?project=${encodeURIComponent(projectPath)}`);
|
|
224
|
+
if (!res.ok) return;
|
|
226
225
|
const data = await res.json();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
setIssueNextScan(data.nextScan || null);
|
|
226
|
+
setPipelineBindings(data.bindings || []);
|
|
227
|
+
setPipelineRuns(data.runs || []);
|
|
228
|
+
setAvailableWorkflows(data.workflows || []);
|
|
231
229
|
} catch {}
|
|
232
230
|
}, [projectPath]);
|
|
233
231
|
|
|
234
|
-
const
|
|
235
|
-
await fetch('/api/issue-scanner', {
|
|
236
|
-
method: 'POST',
|
|
237
|
-
headers: { 'Content-Type': 'application/json' },
|
|
238
|
-
body: JSON.stringify({ action: 'save-config', projectPath, projectName, ...config }),
|
|
239
|
-
});
|
|
240
|
-
fetchIssueConfig();
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
const scanNow = async () => {
|
|
244
|
-
setIssueScanning(true);
|
|
232
|
+
const triggerProjectPipeline = async (workflowName: string, input?: Record<string, string>) => {
|
|
245
233
|
try {
|
|
246
|
-
const res = await fetch('/api/
|
|
234
|
+
const res = await fetch('/api/project-pipelines', {
|
|
247
235
|
method: 'POST',
|
|
248
236
|
headers: { 'Content-Type': 'application/json' },
|
|
249
|
-
body: JSON.stringify({ action: '
|
|
237
|
+
body: JSON.stringify({ action: 'trigger', projectPath, projectName, workflowName, input }),
|
|
250
238
|
});
|
|
251
239
|
const data = await res.json();
|
|
252
|
-
if (data.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
alert(`Triggered ${data.triggered} issue fix(es): #${data.issues.join(', #')}`);
|
|
256
|
-
} else {
|
|
257
|
-
alert(`Scanned ${data.total} open issues — no new issues to process`);
|
|
258
|
-
}
|
|
259
|
-
await fetchIssueConfig();
|
|
260
|
-
} catch (e) {
|
|
261
|
-
alert('Scan failed');
|
|
262
|
-
}
|
|
263
|
-
setIssueScanning(false);
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const triggerIssue = async (issueId: string) => {
|
|
267
|
-
await fetch('/api/issue-scanner', {
|
|
268
|
-
method: 'POST',
|
|
269
|
-
headers: { 'Content-Type': 'application/json' },
|
|
270
|
-
body: JSON.stringify({ action: 'trigger', projectPath, issueId, projectName }),
|
|
271
|
-
});
|
|
272
|
-
setIssueManualId('');
|
|
273
|
-
fetchIssueConfig();
|
|
240
|
+
if (data.ok) { fetchPipelineBindings(); }
|
|
241
|
+
else { alert(data.error || 'Failed'); }
|
|
242
|
+
} catch { alert('Failed to trigger pipeline'); }
|
|
274
243
|
};
|
|
275
244
|
|
|
276
245
|
const fetchClaudeMd = useCallback(async () => {
|
|
@@ -409,9 +378,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
409
378
|
// Lazy load tab-specific data only when switching to that tab
|
|
410
379
|
useEffect(() => {
|
|
411
380
|
if (projectTab === 'skills') fetchProjectSkills();
|
|
412
|
-
if (projectTab === '
|
|
381
|
+
if (projectTab === 'pipelines') fetchPipelineBindings();
|
|
413
382
|
if (projectTab === 'claudemd') fetchClaudeMd();
|
|
414
|
-
}, [projectTab, fetchProjectSkills,
|
|
383
|
+
}, [projectTab, fetchProjectSkills, fetchPipelineBindings, fetchClaudeMd]);
|
|
415
384
|
|
|
416
385
|
return (
|
|
417
386
|
<>
|
|
@@ -481,13 +450,13 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
481
450
|
{claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
|
|
482
451
|
</button>
|
|
483
452
|
<button
|
|
484
|
-
onClick={() => setProjectTab('
|
|
453
|
+
onClick={() => setProjectTab('pipelines')}
|
|
485
454
|
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
486
|
-
projectTab === '
|
|
455
|
+
projectTab === 'pipelines' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
487
456
|
}`}
|
|
488
457
|
>
|
|
489
|
-
|
|
490
|
-
{
|
|
458
|
+
Pipelines
|
|
459
|
+
{pipelineBindings.length > 0 && <span className="ml-1 text-[8px] text-[var(--text-secondary)]">({pipelineBindings.length})</span>}
|
|
491
460
|
</button>
|
|
492
461
|
</div>
|
|
493
462
|
</div>
|
|
@@ -797,149 +766,157 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
797
766
|
</div>
|
|
798
767
|
)}
|
|
799
768
|
|
|
800
|
-
{/*
|
|
801
|
-
{projectTab === '
|
|
769
|
+
{/* Pipelines tab */}
|
|
770
|
+
{projectTab === 'pipelines' && (
|
|
802
771
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
803
|
-
{/*
|
|
772
|
+
{/* Bound workflows */}
|
|
804
773
|
<div className="space-y-3">
|
|
805
|
-
<div className="flex items-center gap-
|
|
806
|
-
<
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
className="accent-[var(--accent)]"
|
|
812
|
-
/>
|
|
813
|
-
<span className="text-[11px] text-[var(--text-primary)] font-semibold">Enable Issue Auto-fix</span>
|
|
814
|
-
</label>
|
|
815
|
-
{issueConfig.enabled && (<>
|
|
816
|
-
<button
|
|
817
|
-
onClick={() => scanNow()}
|
|
818
|
-
disabled={issueScanning}
|
|
819
|
-
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
820
|
-
>
|
|
821
|
-
{issueScanning ? 'Scanning...' : 'Scan Now'}
|
|
822
|
-
</button>
|
|
823
|
-
{issueLastScan && (
|
|
824
|
-
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
825
|
-
Last: {new Date(issueLastScan).toLocaleTimeString()}
|
|
826
|
-
</span>
|
|
827
|
-
)}
|
|
828
|
-
{issueNextScan && (
|
|
829
|
-
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
830
|
-
Next: {new Date(issueNextScan).toLocaleTimeString()}
|
|
831
|
-
</span>
|
|
832
|
-
)}
|
|
833
|
-
</> )}
|
|
774
|
+
<div className="flex items-center gap-2">
|
|
775
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Bound Pipelines</span>
|
|
776
|
+
<button
|
|
777
|
+
onClick={() => setShowAddPipeline(v => !v)}
|
|
778
|
+
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 ml-auto"
|
|
779
|
+
>+ Add</button>
|
|
834
780
|
</div>
|
|
835
781
|
|
|
836
|
-
{
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
<
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
className="
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
<
|
|
860
|
-
|
|
861
|
-
value={issueConfig.labels.join(', ')}
|
|
862
|
-
onChange={e => setIssueConfig({ ...issueConfig, labels: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
|
|
863
|
-
placeholder="bug, fix"
|
|
864
|
-
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
865
|
-
/>
|
|
866
|
-
</div>
|
|
782
|
+
{/* Add pipeline form */}
|
|
783
|
+
{showAddPipeline && (
|
|
784
|
+
<div className="border border-[var(--border)] rounded p-2 space-y-2">
|
|
785
|
+
{availableWorkflows.filter(w => !pipelineBindings.find(b => b.workflowName === w.name)).map(w => (
|
|
786
|
+
<button
|
|
787
|
+
key={w.name}
|
|
788
|
+
onClick={async () => {
|
|
789
|
+
await fetch('/api/project-pipelines', {
|
|
790
|
+
method: 'POST',
|
|
791
|
+
headers: { 'Content-Type': 'application/json' },
|
|
792
|
+
body: JSON.stringify({ action: 'add', projectPath, projectName, workflowName: w.name }),
|
|
793
|
+
});
|
|
794
|
+
setShowAddPipeline(false);
|
|
795
|
+
fetchPipelineBindings();
|
|
796
|
+
}}
|
|
797
|
+
className="w-full text-left px-2 py-1.5 rounded hover:bg-[var(--bg-tertiary)] text-[10px] flex items-center gap-2"
|
|
798
|
+
>
|
|
799
|
+
{w.builtin && <span className="text-[7px] text-[var(--text-secondary)]">⚙</span>}
|
|
800
|
+
<span className="text-[var(--text-primary)]">{w.name}</span>
|
|
801
|
+
{w.description && <span className="text-[var(--text-secondary)] truncate ml-auto text-[8px]">{w.description}</span>}
|
|
802
|
+
</button>
|
|
803
|
+
))}
|
|
804
|
+
{availableWorkflows.filter(w => !pipelineBindings.find(b => b.workflowName === w.name)).length === 0 && (
|
|
805
|
+
<p className="text-[9px] text-[var(--text-secondary)] p-2">All workflows already bound</p>
|
|
806
|
+
)}
|
|
867
807
|
</div>
|
|
868
808
|
)}
|
|
869
|
-
<div className="mt-3">
|
|
870
|
-
<button
|
|
871
|
-
onClick={() => saveIssueConfig(issueConfig)}
|
|
872
|
-
className="text-[10px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
873
|
-
>Save Configuration</button>
|
|
874
|
-
</div>
|
|
875
|
-
</div>
|
|
876
809
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
810
|
+
{/* Bound pipeline list */}
|
|
811
|
+
{pipelineBindings.length === 0 ? (
|
|
812
|
+
<p className="text-[10px] text-[var(--text-secondary)]">No pipelines bound. Click + Add to attach a workflow.</p>
|
|
813
|
+
) : (
|
|
814
|
+
pipelineBindings.map(b => (
|
|
815
|
+
<div key={b.workflowName} className="border border-[var(--border)] rounded p-3 space-y-2">
|
|
816
|
+
<div className="flex items-center gap-2">
|
|
817
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)]">{b.workflowName}</span>
|
|
818
|
+
<label className="flex items-center gap-1 text-[9px] text-[var(--text-secondary)] cursor-pointer ml-auto">
|
|
819
|
+
<input type="checkbox" checked={b.enabled} onChange={async (e) => {
|
|
820
|
+
await fetch('/api/project-pipelines', {
|
|
821
|
+
method: 'POST',
|
|
822
|
+
headers: { 'Content-Type': 'application/json' },
|
|
823
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, enabled: e.target.checked }),
|
|
824
|
+
});
|
|
825
|
+
fetchPipelineBindings();
|
|
826
|
+
}} className="accent-[var(--accent)]" />
|
|
827
|
+
Enabled
|
|
828
|
+
</label>
|
|
829
|
+
<button
|
|
830
|
+
onClick={() => triggerProjectPipeline(b.workflowName, triggerInput)}
|
|
831
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
832
|
+
>Run</button>
|
|
833
|
+
<button
|
|
834
|
+
onClick={async () => {
|
|
835
|
+
if (!confirm(`Remove "${b.workflowName}" from this project?`)) return;
|
|
836
|
+
await fetch('/api/project-pipelines', {
|
|
837
|
+
method: 'POST',
|
|
838
|
+
headers: { 'Content-Type': 'application/json' },
|
|
839
|
+
body: JSON.stringify({ action: 'remove', projectPath, workflowName: b.workflowName }),
|
|
840
|
+
});
|
|
841
|
+
fetchPipelineBindings();
|
|
842
|
+
}}
|
|
843
|
+
className="text-[9px] text-[var(--red)] hover:underline"
|
|
844
|
+
>Remove</button>
|
|
845
|
+
</div>
|
|
846
|
+
{/* Schedule config */}
|
|
847
|
+
<div className="flex items-center gap-2 text-[9px]">
|
|
848
|
+
<span className="text-[var(--text-secondary)]">Schedule:</span>
|
|
849
|
+
<select
|
|
850
|
+
value={b.config.interval || 0}
|
|
851
|
+
onChange={async (e) => {
|
|
852
|
+
const interval = Number(e.target.value);
|
|
853
|
+
const newConfig = { ...b.config, interval };
|
|
854
|
+
await fetch('/api/project-pipelines', {
|
|
855
|
+
method: 'POST',
|
|
856
|
+
headers: { 'Content-Type': 'application/json' },
|
|
857
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
858
|
+
});
|
|
859
|
+
fetchPipelineBindings();
|
|
860
|
+
}}
|
|
861
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
862
|
+
>
|
|
863
|
+
<option value={0}>Manual only</option>
|
|
864
|
+
<option value={15}>Every 15 min</option>
|
|
865
|
+
<option value={30}>Every 30 min</option>
|
|
866
|
+
<option value={60}>Every 1 hour</option>
|
|
867
|
+
<option value={120}>Every 2 hours</option>
|
|
868
|
+
<option value={360}>Every 6 hours</option>
|
|
869
|
+
<option value={720}>Every 12 hours</option>
|
|
870
|
+
<option value={1440}>Every 24 hours</option>
|
|
871
|
+
</select>
|
|
872
|
+
{b.config.interval > 0 && b.nextRunAt && (
|
|
873
|
+
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
874
|
+
Next: {new Date(b.nextRunAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
875
|
+
</span>
|
|
876
|
+
)}
|
|
877
|
+
{b.lastRunAt && (
|
|
878
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">
|
|
879
|
+
Last: {new Date(b.lastRunAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
880
|
+
</span>
|
|
881
|
+
)}
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
))
|
|
885
|
+
)}
|
|
894
886
|
</div>
|
|
895
887
|
|
|
896
|
-
{/*
|
|
897
|
-
{
|
|
888
|
+
{/* Execution history */}
|
|
889
|
+
{pipelineRuns.length > 0 && (
|
|
898
890
|
<div className="border-t border-[var(--border)] pt-3">
|
|
899
|
-
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">
|
|
891
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Execution History</div>
|
|
900
892
|
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
901
|
-
{
|
|
902
|
-
<div key={
|
|
903
|
-
<
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
{p.pipelineId && (
|
|
912
|
-
<button
|
|
913
|
-
onClick={() => {
|
|
914
|
-
const event = new CustomEvent('forge:view-pipeline', { detail: { pipelineId: p.pipelineId } });
|
|
915
|
-
window.dispatchEvent(event);
|
|
916
|
-
}}
|
|
917
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] font-mono"
|
|
918
|
-
title="View pipeline"
|
|
919
|
-
>{p.pipelineId.slice(0, 8)}</button>
|
|
920
|
-
)}
|
|
921
|
-
<span className="text-[var(--text-secondary)] text-[8px]">{p.createdAt}</span>
|
|
922
|
-
<div className="ml-auto flex gap-1">
|
|
923
|
-
{(p.status === 'failed' || p.status === 'done' || p.status === 'processing') && (
|
|
924
|
-
<button
|
|
925
|
-
onClick={() => setRetryModal({ issueNumber: p.issueNumber, context: '' })}
|
|
926
|
-
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
927
|
-
>Retry</button>
|
|
928
|
-
)}
|
|
929
|
-
<button
|
|
930
|
-
onClick={async () => {
|
|
931
|
-
if (!confirm(`Delete record for issue #${p.issueNumber}?`)) return;
|
|
932
|
-
await fetch('/api/issue-scanner', {
|
|
933
|
-
method: 'POST',
|
|
934
|
-
headers: { 'Content-Type': 'application/json' },
|
|
935
|
-
body: JSON.stringify({ action: 'reset', projectPath, issueId: p.issueNumber }),
|
|
936
|
-
});
|
|
937
|
-
fetchIssueConfig();
|
|
938
|
-
}}
|
|
939
|
-
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
940
|
-
>Delete</button>
|
|
893
|
+
{pipelineRuns.map(run => (
|
|
894
|
+
<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]">
|
|
895
|
+
<span className={`shrink-0 ${
|
|
896
|
+
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : 'text-yellow-400'
|
|
897
|
+
}`}>●</span>
|
|
898
|
+
<div className="flex-1 min-w-0">
|
|
899
|
+
<div className="flex items-center gap-2">
|
|
900
|
+
<span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
|
|
901
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono">{run.pipelineId.slice(0, 8)}</span>
|
|
902
|
+
<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>
|
|
941
903
|
</div>
|
|
904
|
+
{run.summary && (
|
|
905
|
+
<pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
|
|
906
|
+
)}
|
|
942
907
|
</div>
|
|
908
|
+
<button
|
|
909
|
+
onClick={async () => {
|
|
910
|
+
if (!confirm('Delete this run?')) return;
|
|
911
|
+
await fetch('/api/project-pipelines', {
|
|
912
|
+
method: 'POST',
|
|
913
|
+
headers: { 'Content-Type': 'application/json' },
|
|
914
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
915
|
+
});
|
|
916
|
+
fetchPipelineBindings();
|
|
917
|
+
}}
|
|
918
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0"
|
|
919
|
+
>×</button>
|
|
943
920
|
</div>
|
|
944
921
|
))}
|
|
945
922
|
</div>
|
|
@@ -1026,50 +1003,6 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
1026
1003
|
</div>
|
|
1027
1004
|
)}
|
|
1028
1005
|
|
|
1029
|
-
{/* Retry modal */}
|
|
1030
|
-
{retryModal && (
|
|
1031
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRetryModal(null)}>
|
|
1032
|
-
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl w-[420px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
1033
|
-
<div className="px-4 py-3 border-b border-[var(--border)]">
|
|
1034
|
-
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Retry Issue #{retryModal.issueNumber}</h3>
|
|
1035
|
-
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">Add context to help the AI fix the issue better this time.</p>
|
|
1036
|
-
</div>
|
|
1037
|
-
<div className="p-4">
|
|
1038
|
-
<textarea
|
|
1039
|
-
value={retryModal.context}
|
|
1040
|
-
onChange={e => setRetryModal({ ...retryModal, context: e.target.value })}
|
|
1041
|
-
placeholder="e.g. The previous fix caused a merge conflict. Rebase from main first, then fix only the validation logic in src/utils.ts..."
|
|
1042
|
-
className="w-full h-32 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50 resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
1043
|
-
autoFocus
|
|
1044
|
-
/>
|
|
1045
|
-
</div>
|
|
1046
|
-
<div className="px-4 py-3 border-t border-[var(--border)] flex justify-end gap-2">
|
|
1047
|
-
<button
|
|
1048
|
-
onClick={() => setRetryModal(null)}
|
|
1049
|
-
className="text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
1050
|
-
>Cancel</button>
|
|
1051
|
-
<button
|
|
1052
|
-
onClick={async () => {
|
|
1053
|
-
await fetch('/api/issue-scanner', {
|
|
1054
|
-
method: 'POST',
|
|
1055
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1056
|
-
body: JSON.stringify({
|
|
1057
|
-
action: 'retry',
|
|
1058
|
-
projectPath,
|
|
1059
|
-
projectName,
|
|
1060
|
-
issueId: retryModal.issueNumber,
|
|
1061
|
-
context: retryModal.context,
|
|
1062
|
-
}),
|
|
1063
|
-
});
|
|
1064
|
-
setRetryModal(null);
|
|
1065
|
-
fetchIssueConfig();
|
|
1066
|
-
}}
|
|
1067
|
-
className="text-[11px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
1068
|
-
>Retry</button>
|
|
1069
|
-
</div>
|
|
1070
|
-
</div>
|
|
1071
|
-
</div>
|
|
1072
|
-
)}
|
|
1073
1006
|
</>
|
|
1074
1007
|
);
|
|
1075
1008
|
});
|
package/forge-logo.png
ADDED
|
Binary file
|
|
@@ -50,15 +50,12 @@ nodes:
|
|
|
50
50
|
|
|
51
51
|
## Built-in Workflows
|
|
52
52
|
|
|
53
|
-
### issue-
|
|
54
|
-
|
|
53
|
+
### issue-fix-and-review
|
|
54
|
+
Complete issue resolution pipeline: fetch issue → fix code → create PR → review code → notify.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
Steps: setup → fetch-issue → fix-code → push-and-pr → review → cleanup
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
Fetches PR diff → AI code review → posts result.
|
|
60
|
-
|
|
61
|
-
Input: `pr_number`, `project`
|
|
58
|
+
Input: `issue_id`, `project`, `base_branch` (optional), `extra_context` (optional)
|
|
62
59
|
|
|
63
60
|
## CLI
|
|
64
61
|
|
|
@@ -67,6 +64,24 @@ forge flows # list available workflows
|
|
|
67
64
|
forge run my-workflow # execute a workflow
|
|
68
65
|
```
|
|
69
66
|
|
|
67
|
+
## Import a Workflow
|
|
68
|
+
|
|
69
|
+
1. In Pipelines tab, click **Import**
|
|
70
|
+
2. Paste YAML workflow content
|
|
71
|
+
3. Click **Save Workflow**
|
|
72
|
+
|
|
73
|
+
Or save YAML directly to `~/.forge/data/flows/<name>.yaml`.
|
|
74
|
+
|
|
75
|
+
To create a workflow via Help AI: ask "Create a pipeline that does X" — the AI will generate the YAML for you to import.
|
|
76
|
+
|
|
77
|
+
## Creating Workflows via API
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
curl -X POST http://localhost:3000/api/pipelines \
|
|
81
|
+
-H 'Content-Type: application/json' \
|
|
82
|
+
-d '{"action": "save-workflow", "yaml": "name: my-flow\nnodes:\n step1:\n project: my-project\n prompt: do something"}'
|
|
83
|
+
```
|
|
84
|
+
|
|
70
85
|
## Storage
|
|
71
86
|
|
|
72
87
|
- Workflow YAML: `~/.forge/data/flows/`
|
|
@@ -28,7 +28,7 @@ Scan → Fetch Issue → Fix Code (new branch) → Push → Create PR → Auto R
|
|
|
28
28
|
1. **Scan**: `gh issue list` finds open issues matching labels
|
|
29
29
|
2. **Fix**: Claude Code analyzes issue and fixes code on `fix/<id>-<description>` branch
|
|
30
30
|
3. **PR**: Pushes branch and creates Pull Request
|
|
31
|
-
4. **Review**:
|
|
31
|
+
4. **Review**: AI reviews the code changes in the same pipeline
|
|
32
32
|
5. **Notify**: Results sent via Telegram (if configured)
|
|
33
33
|
|
|
34
34
|
## Manual Trigger
|
package/lib/init.ts
CHANGED
|
@@ -95,7 +95,13 @@ export function ensureInitialized() {
|
|
|
95
95
|
// Session watcher is safe (file-based, idempotent)
|
|
96
96
|
startWatcherLoop();
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// Pipeline scheduler — periodic execution for project-bound workflows
|
|
99
|
+
try {
|
|
100
|
+
const { startScheduler } = require('./pipeline-scheduler');
|
|
101
|
+
startScheduler();
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
// Legacy issue scanner (still used if issue_autofix_config has entries)
|
|
99
105
|
try {
|
|
100
106
|
const { startScanner } = require('./issue-scanner');
|
|
101
107
|
startScanner();
|
package/lib/issue-scanner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Issue Scanner — periodically scans GitHub issues for configured projects
|
|
3
|
-
* and triggers issue-
|
|
3
|
+
* and triggers issue-fix-and-review pipeline for new issues.
|
|
4
4
|
*
|
|
5
5
|
* Per-project config stored in DB:
|
|
6
6
|
* - enabled: boolean
|
|
@@ -196,7 +196,7 @@ export function scanAndTrigger(config: IssueAutofixConfig): { triggered: number;
|
|
|
196
196
|
if (isProcessed(config.projectPath, issue.number)) continue;
|
|
197
197
|
|
|
198
198
|
try {
|
|
199
|
-
const pipeline = startPipeline('issue-
|
|
199
|
+
const pipeline = startPipeline('issue-fix-and-review', {
|
|
200
200
|
issue_id: String(issue.number),
|
|
201
201
|
project: config.projectName,
|
|
202
202
|
base_branch: config.baseBranch || 'auto-detect',
|