@aion0/forge 0.4.6 → 0.4.7
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 +10 -6
- package/app/api/project-pipelines/route.ts +23 -0
- package/components/Dashboard.tsx +17 -1
- package/components/PipelineView.tsx +12 -1
- package/components/ProjectDetail.tsx +158 -19
- 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,16 @@
|
|
|
1
|
-
# Forge v0.4.
|
|
1
|
+
# Forge v0.4.7
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-22
|
|
4
4
|
|
|
5
|
-
## Changes since v0.4.
|
|
5
|
+
## Changes since v0.4.6
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
- fix: serial issue scanning, shell ANSI-C escaping, pipeline navigation
|
|
9
|
+
- fix: prevent concurrent startTunnel calls from killing each other (#16)
|
|
6
10
|
|
|
7
11
|
### Other
|
|
8
|
-
- improve
|
|
9
|
-
-
|
|
12
|
+
- improve pipeline
|
|
13
|
+
- fix(#17): normalize SQLite datetime strings to ISO 8601 UTC
|
|
10
14
|
|
|
11
15
|
|
|
12
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.
|
|
16
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.4.6...v0.4.7
|
|
@@ -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
|
|
|
@@ -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;
|
|
@@ -69,10 +69,12 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
69
69
|
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
70
70
|
// Pipeline bindings state
|
|
71
71
|
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 }[]>([]);
|
|
72
|
+
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
|
|
73
73
|
const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean }[]>([]);
|
|
74
74
|
const [showAddPipeline, setShowAddPipeline] = useState(false);
|
|
75
75
|
const [triggerInput, setTriggerInput] = useState<Record<string, string>>({});
|
|
76
|
+
const [runMenu, setRunMenu] = useState<string | null>(null); // workflowName of open run menu
|
|
77
|
+
const [issueInput, setIssueInput] = useState('');
|
|
76
78
|
const [claudeMdContent, setClaudeMdContent] = useState('');
|
|
77
79
|
const [claudeMdExists, setClaudeMdExists] = useState(false);
|
|
78
80
|
const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
|
|
@@ -846,10 +848,74 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
846
848
|
}} className="accent-[var(--accent)]" />
|
|
847
849
|
Enabled
|
|
848
850
|
</label>
|
|
849
|
-
<
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
851
|
+
<div className="relative">
|
|
852
|
+
<button
|
|
853
|
+
onClick={() => {
|
|
854
|
+
const isIssueWf = b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review';
|
|
855
|
+
if (!isIssueWf) {
|
|
856
|
+
triggerProjectPipeline(b.workflowName, triggerInput);
|
|
857
|
+
} else {
|
|
858
|
+
setRunMenu(runMenu === b.workflowName ? null : b.workflowName);
|
|
859
|
+
setIssueInput('');
|
|
860
|
+
}
|
|
861
|
+
}}
|
|
862
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
|
|
863
|
+
>Run</button>
|
|
864
|
+
{runMenu === b.workflowName && (
|
|
865
|
+
<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]">
|
|
866
|
+
<button
|
|
867
|
+
onClick={async () => {
|
|
868
|
+
setRunMenu(null);
|
|
869
|
+
try {
|
|
870
|
+
const res = await fetch('/api/project-pipelines', {
|
|
871
|
+
method: 'POST',
|
|
872
|
+
headers: { 'Content-Type': 'application/json' },
|
|
873
|
+
body: JSON.stringify({ action: 'scan-now', projectPath, projectName, workflowName: b.workflowName }),
|
|
874
|
+
});
|
|
875
|
+
const data = await res.json();
|
|
876
|
+
if (data.error) alert(`Scan error: ${data.error}`);
|
|
877
|
+
else alert(`Scanned ${data.total} issues, triggered ${data.triggered} fix${data.pending > 0 ? ` (${data.pending} more pending)` : ''}`);
|
|
878
|
+
fetchPipelineBindings();
|
|
879
|
+
} catch { alert('Scan failed'); }
|
|
880
|
+
}}
|
|
881
|
+
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"
|
|
882
|
+
>Auto Scan — fix all new issues</button>
|
|
883
|
+
<div className="border-t border-[var(--border)]/50 my-1" />
|
|
884
|
+
<div className="flex items-center gap-1">
|
|
885
|
+
<input
|
|
886
|
+
type="text"
|
|
887
|
+
value={issueInput}
|
|
888
|
+
onChange={e => setIssueInput(e.target.value)}
|
|
889
|
+
placeholder="Issue #"
|
|
890
|
+
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[9px] text-[var(--text-primary)]"
|
|
891
|
+
onKeyDown={e => {
|
|
892
|
+
if (e.key === 'Enter' && issueInput.trim()) {
|
|
893
|
+
setRunMenu(null);
|
|
894
|
+
triggerProjectPipeline(b.workflowName, {
|
|
895
|
+
...triggerInput,
|
|
896
|
+
issue_id: issueInput.trim(),
|
|
897
|
+
base_branch: b.config.baseBranch || 'auto-detect',
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}}
|
|
901
|
+
autoFocus
|
|
902
|
+
/>
|
|
903
|
+
<button
|
|
904
|
+
onClick={() => {
|
|
905
|
+
if (!issueInput.trim()) return;
|
|
906
|
+
setRunMenu(null);
|
|
907
|
+
triggerProjectPipeline(b.workflowName, {
|
|
908
|
+
...triggerInput,
|
|
909
|
+
issue_id: issueInput.trim(),
|
|
910
|
+
base_branch: b.config.baseBranch || 'auto-detect',
|
|
911
|
+
});
|
|
912
|
+
}}
|
|
913
|
+
className="text-[9px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-80"
|
|
914
|
+
>Fix</button>
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
917
|
+
)}
|
|
918
|
+
</div>
|
|
853
919
|
<button
|
|
854
920
|
onClick={async () => {
|
|
855
921
|
if (!confirm(`Remove "${b.workflowName}" from this project?`)) return;
|
|
@@ -900,6 +966,51 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
900
966
|
</span>
|
|
901
967
|
)}
|
|
902
968
|
</div>
|
|
969
|
+
{/* Issue scan config (for issue-fix-and-review workflow) */}
|
|
970
|
+
{(b.workflowName === 'issue-auto-fix' || b.workflowName === 'issue-fix-and-review') && (
|
|
971
|
+
<div className="space-y-1.5 pt-1 border-t border-[var(--border)]/30">
|
|
972
|
+
{b.config.interval > 0 && (
|
|
973
|
+
<div className="text-[8px] text-[var(--text-secondary)]">
|
|
974
|
+
Scheduled mode: auto-scans GitHub issues and fixes new ones
|
|
975
|
+
</div>
|
|
976
|
+
)}
|
|
977
|
+
<div className="flex items-center gap-2 text-[9px]">
|
|
978
|
+
<label className="text-[var(--text-secondary)]">Labels:</label>
|
|
979
|
+
<input
|
|
980
|
+
type="text"
|
|
981
|
+
defaultValue={(b.config.labels || []).join(', ')}
|
|
982
|
+
placeholder="bug, autofix (empty = all)"
|
|
983
|
+
onBlur={async (e) => {
|
|
984
|
+
const labels = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
985
|
+
const newConfig = { ...b.config, labels };
|
|
986
|
+
await fetch('/api/project-pipelines', {
|
|
987
|
+
method: 'POST',
|
|
988
|
+
headers: { 'Content-Type': 'application/json' },
|
|
989
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
990
|
+
});
|
|
991
|
+
fetchPipelineBindings();
|
|
992
|
+
}}
|
|
993
|
+
className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
994
|
+
/>
|
|
995
|
+
<label className="text-[var(--text-secondary)]">Base:</label>
|
|
996
|
+
<input
|
|
997
|
+
type="text"
|
|
998
|
+
defaultValue={b.config.baseBranch || ''}
|
|
999
|
+
placeholder="auto-detect"
|
|
1000
|
+
onBlur={async (e) => {
|
|
1001
|
+
const newConfig = { ...b.config, baseBranch: e.target.value.trim() || undefined };
|
|
1002
|
+
await fetch('/api/project-pipelines', {
|
|
1003
|
+
method: 'POST',
|
|
1004
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1005
|
+
body: JSON.stringify({ action: 'update', projectPath, workflowName: b.workflowName, config: newConfig }),
|
|
1006
|
+
});
|
|
1007
|
+
fetchPipelineBindings();
|
|
1008
|
+
}}
|
|
1009
|
+
className="w-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[9px] text-[var(--text-primary)]"
|
|
1010
|
+
/>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
)}
|
|
903
1014
|
</div>
|
|
904
1015
|
))
|
|
905
1016
|
)}
|
|
@@ -913,30 +1024,58 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
913
1024
|
{pipelineRuns.map(run => (
|
|
914
1025
|
<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
1026
|
<span className={`shrink-0 ${
|
|
916
|
-
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : 'text-yellow-400'
|
|
1027
|
+
run.status === 'done' ? 'text-green-400' : run.status === 'failed' ? 'text-red-400' : run.status === 'skipped' ? 'text-gray-400' : 'text-yellow-400'
|
|
917
1028
|
}`}>●</span>
|
|
918
1029
|
<div className="flex-1 min-w-0">
|
|
919
1030
|
<div className="flex items-center gap-2">
|
|
920
1031
|
<span className="text-[var(--text-primary)] font-medium">{run.workflowName}</span>
|
|
921
|
-
|
|
1032
|
+
{run.dedupKey && (
|
|
1033
|
+
<span className="text-[8px] text-[var(--accent)] font-mono">{run.dedupKey.replace('issue:', '#')}</span>
|
|
1034
|
+
)}
|
|
1035
|
+
<button
|
|
1036
|
+
onClick={() => window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: run.pipelineId } }))}
|
|
1037
|
+
className="text-[8px] text-[var(--accent)] font-mono hover:underline"
|
|
1038
|
+
title="View in Pipelines"
|
|
1039
|
+
>{run.pipelineId.slice(0, 8)}</button>
|
|
922
1040
|
<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
1041
|
</div>
|
|
924
1042
|
{run.summary && (
|
|
925
1043
|
<pre className="text-[9px] text-[var(--text-secondary)] mt-1 whitespace-pre-wrap break-words line-clamp-3">{run.summary}</pre>
|
|
926
1044
|
)}
|
|
927
1045
|
</div>
|
|
928
|
-
<
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1046
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
1047
|
+
{run.status === 'failed' && run.dedupKey && (
|
|
1048
|
+
<button
|
|
1049
|
+
onClick={async () => {
|
|
1050
|
+
await fetch('/api/project-pipelines', {
|
|
1051
|
+
method: 'POST',
|
|
1052
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1053
|
+
body: JSON.stringify({ action: 'reset-dedup', projectPath, workflowName: run.workflowName, dedupKey: run.dedupKey }),
|
|
1054
|
+
});
|
|
1055
|
+
// Delete the failed run then re-scan
|
|
1056
|
+
await fetch('/api/project-pipelines', {
|
|
1057
|
+
method: 'POST',
|
|
1058
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1059
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1060
|
+
});
|
|
1061
|
+
fetchPipelineBindings();
|
|
1062
|
+
}}
|
|
1063
|
+
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
1064
|
+
>Retry</button>
|
|
1065
|
+
)}
|
|
1066
|
+
<button
|
|
1067
|
+
onClick={async () => {
|
|
1068
|
+
if (!confirm('Delete this run?')) return;
|
|
1069
|
+
await fetch('/api/project-pipelines', {
|
|
1070
|
+
method: 'POST',
|
|
1071
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1072
|
+
body: JSON.stringify({ action: 'delete-run', id: run.id }),
|
|
1073
|
+
});
|
|
1074
|
+
fetchPipelineBindings();
|
|
1075
|
+
}}
|
|
1076
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1077
|
+
>×</button>
|
|
1078
|
+
</div>
|
|
940
1079
|
</div>
|
|
941
1080
|
))}
|
|
942
1081
|
</div>
|
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 (
|