@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 CHANGED
@@ -1,12 +1,16 @@
1
- # Forge v0.4.6
1
+ # Forge v0.4.7
2
2
 
3
- Released: 2026-03-21
3
+ Released: 2026-03-22
4
4
 
5
- ## Changes since v0.4.5
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 help features
9
- - imporve help features
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.5...v0.4.6
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
  }
@@ -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 onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }} />
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
- <button
850
- onClick={() => triggerProjectPipeline(b.workflowName, triggerInput)}
851
- className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
852
- >Run</button>
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
- <span className="text-[8px] text-[var(--text-secondary)] font-mono">{run.pipelineId.slice(0, 8)}</span>
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
- <button
929
- onClick={async () => {
930
- if (!confirm('Delete this run?')) return;
931
- await fetch('/api/project-pipelines', {
932
- method: 'POST',
933
- headers: { 'Content-Type': 'application/json' },
934
- body: JSON.stringify({ action: 'delete-run', id: run.id }),
935
- });
936
- fetchPipelineBindings();
937
- }}
938
- className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] shrink-0"
939
- >×</button>
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>
@@ -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
- // Check if this worker already has a process
175
- if (state.process) {
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-auto-fix
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-auto-fix"}'
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-auto-fix","config":{"interval":30}}'
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-auto-fix","input":{"issue_id":"42"}}'
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-auto-fix"}'
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-auto-fix` pipeline workflow.
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 (via Project Pipeline Binding)
12
+ ## Setup
13
13
 
14
14
  1. Go to **Projects → select project → Pipelines tab**
15
- 2. Click **+ Add** and select `issue-auto-fix`
15
+ 2. Click **+ Add** and select `issue-fix-and-review`
16
16
  3. Enable the binding
17
- 4. Set a **Schedule** (e.g., Every 30 min) for automatic scanning, or leave as "Manual only"
18
- 5. Click **Run** to manually trigger with an `issue_id`
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
- Setup Fetch Issue Fix Code (new branch) → Push & Create PR → Notify
27
+ Scan IssuesFor each new issue:
28
+ Setup → Fetch Issue → Fix Code (new branch) → Push & Create PR → Notify
24
29
  ```
25
30
 
26
- 1. **Setup**: Checks for clean working directory, detects repo and base branch
27
- 2. **Fetch Issue**: `gh issue view` fetches issue data (skips if no issue_id)
28
- 3. **Fix Code**: Claude analyzes issue and fixes code on `fix/<id>-<description>` branch
29
- 4. **Push & PR**: Pushes branch and creates Pull Request via `gh pr create`
30
- 5. **Notify**: Switches back to original branch, reports PR URL
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
- ## Input Fields
39
+ ## Manual Trigger
33
40
 
34
- | Input | Description | Required |
35
- |-------|-------------|----------|
36
- | `issue_id` | GitHub issue number | Yes (skips if empty) |
37
- | `project` | Project name | Yes |
38
- | `base_branch` | Base branch for fix | No (auto-detect) |
39
- | `extra_context` | Additional instructions | No |
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 and scheduled execution.
3
- * Replaces issue-scanner with a generic approach.
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 (e.g. interval, labels for issue pipelines)
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 (minutes), labels, baseBranch, etc.
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 || null,
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 || null,
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
- createdAt: r.created_at,
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(projectPath: string, projectName: string, workflowName: string, extraInput?: Record<string, any>): { pipelineId: string; runId: string } {
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 >= intervalMs) {
214
- // Check if there's already a running pipeline for this binding
215
- const recentRuns = getRuns(binding.projectPath, binding.workflowName, 1);
216
- if (recentRuns.length > 0 && recentRuns[0].status === 'running') {
217
- continue; // skip if still running
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
- REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
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
- REPO=$(echo '{{nodes.setup.outputs.info}}' | grep REPO= | cut -d= -f2) && \
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 'Fix #{{input.issue_id}}' \
147
- --body 'Auto-fix by Forge Pipeline for issue #{{input.issue_id}}.' -R "$REPO" 2>/dev/null || \
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
- ORIG=$(echo '{{nodes.setup.outputs.info}}' | grep ORIG_BRANCH= | cut -d= -f2) && \
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: {{nodes.push-and-pr.outputs.pr_url}} | Review: {{nodes.review.outputs.review_result}} (staying on $(git branch --show-current))"
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: {{nodes.push-and-pr.outputs.pr_url}} | Review: {{nodes.review.outputs.review_result}} (switched back to $ORIG)"
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 shell commands (single-quote wrapping) */
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 ? shellEscape(value) : value;
353
+ return shellMode ? shellEscapeAnsiC(value) : value;
340
354
  });
341
355
  }
342
356
 
@@ -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 || undefined,
606
- completedAt: row.completed_at || undefined,
607
- scheduledAt: row.scheduled_at || undefined,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 (