@aion0/forge 0.6.1 → 0.8.0
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/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab Issue Scanner — sibling of issue-scanner.ts for GitHub.
|
|
3
|
+
*
|
|
4
|
+
* Per-project config (table: issue_autofix_gitlab_config):
|
|
5
|
+
* - enabled: boolean
|
|
6
|
+
* - interval: minutes (0 = manual only)
|
|
7
|
+
* - labels: string[] filter by label (empty = all)
|
|
8
|
+
* - baseBranch: '' default '' (auto-resolve from milestone/label/desc/default)
|
|
9
|
+
* - baseBranchRule: 'milestone' | 'label:<prefix>' | 'desc' | 'default'
|
|
10
|
+
* - mrTitleTemplate: string
|
|
11
|
+
* - mrBodyTemplate: string
|
|
12
|
+
*
|
|
13
|
+
* Triggers the `gitlab-issue-fix-and-review` builtin pipeline.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { getDb } from '@/src/core/db/database';
|
|
18
|
+
import { getDbPath } from '@/src/config';
|
|
19
|
+
import { startPipeline } from './pipeline';
|
|
20
|
+
|
|
21
|
+
function db() { return getDb(getDbPath()); }
|
|
22
|
+
|
|
23
|
+
export interface GitLabAutofixConfig {
|
|
24
|
+
projectPath: string;
|
|
25
|
+
projectName: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
interval: number;
|
|
28
|
+
labels: string[];
|
|
29
|
+
baseBranch: string;
|
|
30
|
+
baseBranchRule: string; // 'milestone' | 'label:<prefix>' | 'desc' | 'default'
|
|
31
|
+
mrTitleTemplate: string;
|
|
32
|
+
mrBodyTemplate: string;
|
|
33
|
+
assigneeFilter: string; // 'me' | <username> | '' (no filter)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── DB schema ────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function ensureTable() {
|
|
39
|
+
db().exec(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS issue_autofix_gitlab_config (
|
|
41
|
+
project_path TEXT PRIMARY KEY,
|
|
42
|
+
project_name TEXT NOT NULL,
|
|
43
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
interval_min INTEGER NOT NULL DEFAULT 30,
|
|
45
|
+
labels TEXT NOT NULL DEFAULT '[]',
|
|
46
|
+
base_branch TEXT NOT NULL DEFAULT '',
|
|
47
|
+
base_branch_rule TEXT NOT NULL DEFAULT 'milestone',
|
|
48
|
+
mr_title_template TEXT NOT NULL DEFAULT '',
|
|
49
|
+
mr_body_template TEXT NOT NULL DEFAULT '',
|
|
50
|
+
assignee_filter TEXT NOT NULL DEFAULT 'me',
|
|
51
|
+
last_scan_at TEXT
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS issue_autofix_gitlab_processed (
|
|
54
|
+
project_path TEXT NOT NULL,
|
|
55
|
+
issue_iid INTEGER NOT NULL,
|
|
56
|
+
pipeline_id TEXT,
|
|
57
|
+
mr_iid INTEGER,
|
|
58
|
+
status TEXT NOT NULL DEFAULT 'processing',
|
|
59
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
60
|
+
PRIMARY KEY (project_path, issue_iid)
|
|
61
|
+
);
|
|
62
|
+
`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── CRUD ────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export function getConfig(projectPath: string): GitLabAutofixConfig | null {
|
|
68
|
+
ensureTable();
|
|
69
|
+
const row = db().prepare('SELECT * FROM issue_autofix_gitlab_config WHERE project_path = ?').get(projectPath) as any;
|
|
70
|
+
if (!row) return null;
|
|
71
|
+
return {
|
|
72
|
+
projectPath: row.project_path,
|
|
73
|
+
projectName: row.project_name,
|
|
74
|
+
enabled: !!row.enabled,
|
|
75
|
+
interval: row.interval_min,
|
|
76
|
+
labels: JSON.parse(row.labels || '[]'),
|
|
77
|
+
baseBranch: row.base_branch || '',
|
|
78
|
+
baseBranchRule: row.base_branch_rule || 'milestone',
|
|
79
|
+
mrTitleTemplate: row.mr_title_template || '',
|
|
80
|
+
mrBodyTemplate: row.mr_body_template || '',
|
|
81
|
+
assigneeFilter: row.assignee_filter || 'me',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function saveConfig(c: GitLabAutofixConfig): void {
|
|
86
|
+
ensureTable();
|
|
87
|
+
db().prepare(`
|
|
88
|
+
INSERT OR REPLACE INTO issue_autofix_gitlab_config
|
|
89
|
+
(project_path, project_name, enabled, interval_min, labels, base_branch, base_branch_rule, mr_title_template, mr_body_template, assignee_filter)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
91
|
+
`).run(
|
|
92
|
+
c.projectPath, c.projectName, c.enabled ? 1 : 0, c.interval,
|
|
93
|
+
JSON.stringify(c.labels || []), c.baseBranch || '', c.baseBranchRule || 'milestone',
|
|
94
|
+
c.mrTitleTemplate || '', c.mrBodyTemplate || '', c.assigneeFilter || 'me',
|
|
95
|
+
);
|
|
96
|
+
refreshTimer(c);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function listConfigs(): GitLabAutofixConfig[] {
|
|
100
|
+
ensureTable();
|
|
101
|
+
return (db().prepare('SELECT * FROM issue_autofix_gitlab_config').all() as any[]).map(r => ({
|
|
102
|
+
projectPath: r.project_path, projectName: r.project_name,
|
|
103
|
+
enabled: !!r.enabled, interval: r.interval_min,
|
|
104
|
+
labels: JSON.parse(r.labels || '[]'),
|
|
105
|
+
baseBranch: r.base_branch || '', baseBranchRule: r.base_branch_rule || 'milestone',
|
|
106
|
+
mrTitleTemplate: r.mr_title_template || '', mrBodyTemplate: r.mr_body_template || '',
|
|
107
|
+
assigneeFilter: r.assignee_filter || 'me',
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── GitLab remote parsing ──────────────────────────────
|
|
112
|
+
|
|
113
|
+
function gitRemote(projectPath: string): string | null {
|
|
114
|
+
try {
|
|
115
|
+
return execSync(`git -C "${projectPath}" remote get-url origin`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
116
|
+
} catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolve <namespace>/<project> path from origin URL. Returns null if not a GitLab remote we can talk to. */
|
|
120
|
+
function projectPathFromRemote(remote: string): string | null {
|
|
121
|
+
// git@host:group/sub/project.git OR https://host/group/sub/project(.git)
|
|
122
|
+
const cleaned = remote
|
|
123
|
+
.replace(/^https?:\/\/[^/]+\//, '')
|
|
124
|
+
.replace(/^git@[^:]+:/, '')
|
|
125
|
+
.replace(/\.git$/, '');
|
|
126
|
+
if (!cleaned.includes('/')) return null;
|
|
127
|
+
return cleaned;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Fetch issues via glab ──────────────────────────────
|
|
131
|
+
|
|
132
|
+
function fetchOpenIssues(projectPath: string, c: GitLabAutofixConfig): { iid: number; title: string; error?: string }[] {
|
|
133
|
+
const remote = gitRemote(projectPath);
|
|
134
|
+
if (!remote) return [{ iid: -1, title: '', error: 'No git remote configured' }];
|
|
135
|
+
const projPath = projectPathFromRemote(remote);
|
|
136
|
+
if (!projPath) return [{ iid: -1, title: '', error: `Unrecognized git remote: ${remote}` }];
|
|
137
|
+
|
|
138
|
+
const labelFilter = c.labels.length > 0 ? ` --label "${c.labels.join(',')}"` : '';
|
|
139
|
+
const assigneeFilter = c.assigneeFilter === 'me' ? ' --assignee="@me"'
|
|
140
|
+
: c.assigneeFilter ? ` --assignee="${c.assigneeFilter}"` : '';
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const out = execSync(
|
|
144
|
+
`glab issue list --state opened -R "${projPath}"${labelFilter}${assigneeFilter} --output json`,
|
|
145
|
+
{ encoding: 'utf8', timeout: 30000, cwd: projectPath },
|
|
146
|
+
);
|
|
147
|
+
const parsed = JSON.parse(out);
|
|
148
|
+
return (Array.isArray(parsed) ? parsed : []).map((it: any) => ({
|
|
149
|
+
iid: Number(it.iid ?? it.id ?? -1),
|
|
150
|
+
title: it.title || '',
|
|
151
|
+
}));
|
|
152
|
+
} catch (e: any) {
|
|
153
|
+
const msg = e.stderr?.toString() || e.message || 'glab CLI failed';
|
|
154
|
+
if (/not authenticated|auth/i.test(msg)) {
|
|
155
|
+
return [{ iid: -1, title: '', error: 'glab CLI not authenticated. Run: glab auth login' }];
|
|
156
|
+
}
|
|
157
|
+
if (/glab.*not found|command not found/i.test(msg)) {
|
|
158
|
+
return [{ iid: -1, title: '', error: 'glab CLI not installed. brew install glab && glab auth login' }];
|
|
159
|
+
}
|
|
160
|
+
return [{ iid: -1, title: '', error: msg }];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Dedup state ────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function isProcessed(projectPath: string, iid: number): boolean {
|
|
167
|
+
return !!db().prepare('SELECT 1 FROM issue_autofix_gitlab_processed WHERE project_path = ? AND issue_iid = ?').get(projectPath, iid);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function markProcessed(projectPath: string, iid: number, pipelineId: string): void {
|
|
171
|
+
db().prepare(`
|
|
172
|
+
INSERT OR REPLACE INTO issue_autofix_gitlab_processed (project_path, issue_iid, pipeline_id, status)
|
|
173
|
+
VALUES (?, ?, ?, 'processing')
|
|
174
|
+
`).run(projectPath, iid, pipelineId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function updateProcessedStatus(projectPath: string, iid: number, status: string, mrIid?: number): void {
|
|
178
|
+
if (mrIid != null) {
|
|
179
|
+
db().prepare('UPDATE issue_autofix_gitlab_processed SET status = ?, mr_iid = ? WHERE project_path = ? AND issue_iid = ?')
|
|
180
|
+
.run(status, mrIid, projectPath, iid);
|
|
181
|
+
} else {
|
|
182
|
+
db().prepare('UPDATE issue_autofix_gitlab_processed SET status = ? WHERE project_path = ? AND issue_iid = ?')
|
|
183
|
+
.run(status, projectPath, iid);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resetProcessedIssue(projectPath: string, iid: number): void {
|
|
188
|
+
db().prepare('DELETE FROM issue_autofix_gitlab_processed WHERE project_path = ? AND issue_iid = ?').run(projectPath, iid);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getProcessedIssues(projectPath: string): { issueIid: number; pipelineId: string; mrIid: number | null; status: string; createdAt: string }[] {
|
|
192
|
+
ensureTable();
|
|
193
|
+
return (db().prepare('SELECT * FROM issue_autofix_gitlab_processed WHERE project_path = ? ORDER BY created_at DESC').all(projectPath) as any[]).map(r => ({
|
|
194
|
+
issueIid: r.issue_iid, pipelineId: r.pipeline_id,
|
|
195
|
+
mrIid: r.mr_iid, status: r.status, createdAt: r.created_at,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Scan + trigger ─────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function saveLastScan(projectPath: string) {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
scannerState.lastScan.set(projectPath, now);
|
|
204
|
+
try { db().prepare('UPDATE issue_autofix_gitlab_config SET last_scan_at = ? WHERE project_path = ?').run(new Date(now).toISOString(), projectPath); } catch {}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadLastScan(projectPath: string): number | null {
|
|
208
|
+
try {
|
|
209
|
+
const row = db().prepare('SELECT last_scan_at FROM issue_autofix_gitlab_config WHERE project_path = ?').get(projectPath) as any;
|
|
210
|
+
return row?.last_scan_at ? new Date(row.last_scan_at).getTime() : null;
|
|
211
|
+
} catch { return null; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function scanAndTrigger(c: GitLabAutofixConfig): { triggered: number; issues: number[]; total: number; error?: string } {
|
|
215
|
+
saveLastScan(c.projectPath);
|
|
216
|
+
const issues = fetchOpenIssues(c.projectPath, c);
|
|
217
|
+
if (issues.length === 1 && (issues[0] as any).error) {
|
|
218
|
+
return { triggered: 0, issues: [], total: 0, error: (issues[0] as any).error };
|
|
219
|
+
}
|
|
220
|
+
const triggered: number[] = [];
|
|
221
|
+
for (const it of issues) {
|
|
222
|
+
if (it.iid < 0 || isProcessed(c.projectPath, it.iid)) continue;
|
|
223
|
+
try {
|
|
224
|
+
const pipeline = startPipeline('gitlab-issue-fix-and-review', {
|
|
225
|
+
issue_id: String(it.iid),
|
|
226
|
+
project: c.projectName,
|
|
227
|
+
base_branch: c.baseBranch || 'auto',
|
|
228
|
+
base_branch_rule: c.baseBranchRule || 'milestone',
|
|
229
|
+
mr_title_template: c.mrTitleTemplate || '',
|
|
230
|
+
mr_body_template: c.mrBodyTemplate || '',
|
|
231
|
+
});
|
|
232
|
+
markProcessed(c.projectPath, it.iid, pipeline.id);
|
|
233
|
+
triggered.push(it.iid);
|
|
234
|
+
console.log(`[gl-issue-scanner] Triggered fix for !${it.iid} "${it.title}" in ${c.projectName} (pipeline: ${pipeline.id})`);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error(`[gl-issue-scanner] Failed to trigger for !${it.iid}:`, e);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { triggered: triggered.length, issues: triggered, total: issues.length };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Periodic scanner ───────────────────────────────────
|
|
243
|
+
|
|
244
|
+
const scannerKey = Symbol.for('forge-issue-scanner-gitlab');
|
|
245
|
+
const gAny = globalThis as any;
|
|
246
|
+
if (!gAny[scannerKey]) gAny[scannerKey] = { timers: new Map<string, NodeJS.Timeout>(), started: false, lastScan: new Map<string, number>() };
|
|
247
|
+
const scannerState = gAny[scannerKey] as { timers: Map<string, NodeJS.Timeout>; started: boolean; lastScan: Map<string, number> };
|
|
248
|
+
|
|
249
|
+
function refreshTimer(c: GitLabAutofixConfig) {
|
|
250
|
+
const existing = scannerState.timers.get(c.projectPath);
|
|
251
|
+
if (existing) { clearInterval(existing); scannerState.timers.delete(c.projectPath); }
|
|
252
|
+
if (!c.enabled || c.interval <= 0) return;
|
|
253
|
+
const intervalMs = c.interval * 60 * 1000;
|
|
254
|
+
const dbLast = loadLastScan(c.projectPath);
|
|
255
|
+
if (dbLast) scannerState.lastScan.set(c.projectPath, dbLast);
|
|
256
|
+
else scannerState.lastScan.set(c.projectPath, Date.now());
|
|
257
|
+
const elapsed = Date.now() - (scannerState.lastScan.get(c.projectPath) || 0);
|
|
258
|
+
const firstDelay = Math.max(0, intervalMs - elapsed);
|
|
259
|
+
setTimeout(() => {
|
|
260
|
+
try { const cfg = getConfig(c.projectPath); if (cfg?.enabled) scanAndTrigger(cfg); } catch {}
|
|
261
|
+
const timer = setInterval(() => {
|
|
262
|
+
const cfg = getConfig(c.projectPath);
|
|
263
|
+
if (!cfg || !cfg.enabled) return;
|
|
264
|
+
try { scanAndTrigger(cfg); } catch {}
|
|
265
|
+
}, intervalMs);
|
|
266
|
+
scannerState.timers.set(c.projectPath, timer);
|
|
267
|
+
}, firstDelay);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function startScanner() {
|
|
271
|
+
if (scannerState.started) return;
|
|
272
|
+
scannerState.started = true;
|
|
273
|
+
ensureTable();
|
|
274
|
+
for (const c of listConfigs()) refreshTimer(c);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function stopScanner() {
|
|
278
|
+
for (const t of scannerState.timers.values()) clearInterval(t);
|
|
279
|
+
scannerState.timers.clear();
|
|
280
|
+
scannerState.started = false;
|
|
281
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-item dispatch: take one new item from a Job's connector result and
|
|
3
|
+
* either trigger a Pipeline run or post a message to a Chat session.
|
|
4
|
+
*
|
|
5
|
+
* Template syntax: `{{item.<dotted.path>}}` — see docs/Jobs-Design.md §
|
|
6
|
+
* Template syntax.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PipelineDispatchParams, ChatDispatchParams } from './types';
|
|
10
|
+
import { triggerPipeline } from '../pipeline-scheduler';
|
|
11
|
+
|
|
12
|
+
const CHAT_PORT = Number(process.env.CHAT_PORT) || 8408;
|
|
13
|
+
|
|
14
|
+
// ─── Template rendering ───────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function pickPath(obj: unknown, path: string): unknown {
|
|
17
|
+
if (!path) return obj;
|
|
18
|
+
const parts = path.split('.');
|
|
19
|
+
let cur: any = obj;
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
22
|
+
cur = cur[p];
|
|
23
|
+
}
|
|
24
|
+
return cur;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderValue(v: unknown): string {
|
|
28
|
+
if (v == null) return '';
|
|
29
|
+
if (typeof v === 'string') return v;
|
|
30
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
31
|
+
try { return JSON.stringify(v); } catch { return ''; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderTemplate(template: string, item: unknown): string {
|
|
35
|
+
return template.replace(/\{\{\s*item(?:\.([^}\s]+))?\s*\}\}/g, (_, path) => {
|
|
36
|
+
const v = path ? pickPath(item, path) : item;
|
|
37
|
+
return renderValue(v);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderTemplateMap(map: Record<string, string>, item: unknown): Record<string, string> {
|
|
42
|
+
const out: Record<string, string> = {};
|
|
43
|
+
for (const [k, v] of Object.entries(map)) {
|
|
44
|
+
out[k] = renderTemplate(v, item);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Pipeline dispatch ────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Trigger a pipeline run for one new item. `dedupKey` is the Job's per-item
|
|
53
|
+
* key — purely for the audit row in job_dispatches. We DO NOT forward it to
|
|
54
|
+
* triggerPipeline's own dedup column: Jobs already enforce de-duplication
|
|
55
|
+
* at job_seen, and pipeline_runs has a UNIQUE constraint on
|
|
56
|
+
* (project_path, workflow_name, dedup_key) that would block any 'Reset
|
|
57
|
+
* dedup → Run now' replay (the row from the old run still occupies the
|
|
58
|
+
* slot). Letting pipeline_runs.dedup_key be NULL means each dispatch
|
|
59
|
+
* gets its own row.
|
|
60
|
+
*/
|
|
61
|
+
export function dispatchToPipeline(params: PipelineDispatchParams, item: unknown, _dedupKey: string): { target_id: string; rendered_input: Record<string, string>; empty_keys: string[] } {
|
|
62
|
+
const renderedInput = renderTemplateMap(params.input_template || {}, item);
|
|
63
|
+
// Heads-up if a template key rendered to empty — almost always means the
|
|
64
|
+
// user wrote {{item.foo}} but the item has no 'foo' field. The pipeline
|
|
65
|
+
// node usually exits 1 with "ERROR: <key> is required" which is hard to
|
|
66
|
+
// act on without knowing where the empty came from.
|
|
67
|
+
const emptyKeys: string[] = [];
|
|
68
|
+
for (const [k, v] of Object.entries(renderedInput)) {
|
|
69
|
+
if (!String(v ?? '').trim()) emptyKeys.push(k);
|
|
70
|
+
}
|
|
71
|
+
if (emptyKeys.length > 0) {
|
|
72
|
+
console.warn(
|
|
73
|
+
`[jobs] dispatchToPipeline: ${emptyKeys.length} key(s) rendered to empty — ` +
|
|
74
|
+
`pipeline will likely fail. keys=${emptyKeys.join(', ')}. ` +
|
|
75
|
+
`Check the job's input_template — '{{item.X}}' values resolve to empty if X isn't on the item.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const result = triggerPipeline(
|
|
79
|
+
params.project_path,
|
|
80
|
+
params.project_name,
|
|
81
|
+
params.workflow_name,
|
|
82
|
+
renderedInput,
|
|
83
|
+
// intentionally no dedup_key — see comment above
|
|
84
|
+
);
|
|
85
|
+
return { target_id: result.pipelineId, rendered_input: renderedInput, empty_keys: emptyKeys };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Chat dispatch ────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
async function chatHttp(path: string, body?: unknown): Promise<any> {
|
|
91
|
+
const res = await fetch(`http://127.0.0.1:${CHAT_PORT}${path}`, {
|
|
92
|
+
method: body ? 'POST' : 'GET',
|
|
93
|
+
headers: { 'content-type': 'application/json' },
|
|
94
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok && res.status !== 202) {
|
|
97
|
+
throw new Error(`chat ${path}: HTTP ${res.status} ${(await res.text().catch(() => '')).slice(0, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 204) return null;
|
|
100
|
+
return res.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve the user's persistent main session id (creates if missing). */
|
|
104
|
+
async function getMainSessionId(): Promise<string> {
|
|
105
|
+
const r = await chatHttp('/api/sessions/main');
|
|
106
|
+
const id = String(r?.session?.id || '');
|
|
107
|
+
if (!id) throw new Error('main session lookup returned no id');
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Per-job in-memory map of reused chat sessions, so all items in a `reuse_session: true`
|
|
113
|
+
* job append to one session. Lives in this module (one per Next.js worker).
|
|
114
|
+
*/
|
|
115
|
+
const reusedSessionByJob = new Map<string, string>();
|
|
116
|
+
|
|
117
|
+
export async function dispatchToChat(params: ChatDispatchParams, item: unknown, jobId: string): Promise<{ target_id: string }> {
|
|
118
|
+
let sessionId: string | undefined;
|
|
119
|
+
if (params.target === 'main') {
|
|
120
|
+
sessionId = await getMainSessionId();
|
|
121
|
+
} else if (params.reuse_session) {
|
|
122
|
+
sessionId = reusedSessionByJob.get(jobId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!sessionId) {
|
|
126
|
+
const title = params.session_title_template
|
|
127
|
+
? renderTemplate(params.session_title_template, item)
|
|
128
|
+
: 'Job dispatch';
|
|
129
|
+
const created = await chatHttp('/api/sessions', {
|
|
130
|
+
title,
|
|
131
|
+
provider: params.agent_profile,
|
|
132
|
+
meta: { kind: 'temp', source: `job:${jobId}` },
|
|
133
|
+
});
|
|
134
|
+
sessionId = String(created?.session?.id || '');
|
|
135
|
+
if (!sessionId) throw new Error('chat session create returned no id');
|
|
136
|
+
if (params.reuse_session) reusedSessionByJob.set(jobId, sessionId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const text = renderTemplate(params.message_template, item);
|
|
140
|
+
if (!text.trim()) throw new Error('rendered chat message is empty');
|
|
141
|
+
await chatHttp(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { text });
|
|
142
|
+
return { target_id: sessionId };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Summary-mode dispatch — fold ALL new items in this tick into ONE message
|
|
147
|
+
* sent to a single chat session. Lets the LLM analyze + summarize whatever
|
|
148
|
+
* came back from the connector instead of getting only the first item.
|
|
149
|
+
*
|
|
150
|
+
* message_template placeholders specific to this mode:
|
|
151
|
+
* {{items}} — JSON.stringify of the full items array
|
|
152
|
+
* {{items_text}} — one line per item ("id. title")
|
|
153
|
+
* {{count}} — items.length
|
|
154
|
+
* {{item.X}} also still works against items[0] for header lines like
|
|
155
|
+
* "Today's report for {{item.project}}".
|
|
156
|
+
*/
|
|
157
|
+
export async function dispatchToChatSummary(
|
|
158
|
+
params: ChatDispatchParams,
|
|
159
|
+
items: unknown[],
|
|
160
|
+
jobId: string,
|
|
161
|
+
opts: { totalMatching?: number | string | null } = {},
|
|
162
|
+
): Promise<{ target_id: string; count: number }> {
|
|
163
|
+
let sessionId: string | undefined;
|
|
164
|
+
// Default target for summary mode is the main chat — that's where the
|
|
165
|
+
// user's agent already lives. They don't want a new chat tab per tick.
|
|
166
|
+
const target = params.target ?? 'main';
|
|
167
|
+
if (target === 'main') {
|
|
168
|
+
sessionId = await getMainSessionId();
|
|
169
|
+
} else if (params.reuse_session) {
|
|
170
|
+
sessionId = reusedSessionByJob.get(jobId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!sessionId) {
|
|
174
|
+
const title = params.session_title_template
|
|
175
|
+
? renderTemplate(params.session_title_template, items[0] ?? {})
|
|
176
|
+
: `Job summary (${items.length})`;
|
|
177
|
+
const created = await chatHttp('/api/sessions', {
|
|
178
|
+
title, provider: params.agent_profile,
|
|
179
|
+
meta: { kind: 'temp', source: `job:${jobId}` },
|
|
180
|
+
});
|
|
181
|
+
sessionId = String(created?.session?.id || '');
|
|
182
|
+
if (!sessionId) throw new Error('chat session create returned no id');
|
|
183
|
+
if (params.reuse_session) reusedSessionByJob.set(jobId, sessionId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const itemsJson = JSON.stringify(items, null, 2);
|
|
187
|
+
const itemsText = items.map((it, i) => {
|
|
188
|
+
if (it && typeof it === 'object') {
|
|
189
|
+
const o = it as Record<string, unknown>;
|
|
190
|
+
const id = o.id ?? o.iid ?? o.number ?? (i + 1);
|
|
191
|
+
const title = o.title || o.summary || o.name || '';
|
|
192
|
+
return title ? `${id}. ${title}` : `${id}.`;
|
|
193
|
+
}
|
|
194
|
+
return `${i + 1}. ${String(it)}`;
|
|
195
|
+
}).join('\n');
|
|
196
|
+
|
|
197
|
+
let text = String(params.message_template || '');
|
|
198
|
+
text = text.replace(/\{\{\s*items\s*\}\}/g, itemsJson);
|
|
199
|
+
text = text.replace(/\{\{\s*items_text\s*\}\}/g, itemsText);
|
|
200
|
+
// {{count}} = items shown in this dispatch (after dedup + limit).
|
|
201
|
+
// {{total_matching}} = how many matched the filter server-side. Useful
|
|
202
|
+
// when the connector returned only first N (e.g. mantis limit=20 but
|
|
203
|
+
// 151 bugs match — caller wants "20 of 151 …").
|
|
204
|
+
text = text.replace(/\{\{\s*count\s*\}\}/g, String(items.length));
|
|
205
|
+
const tm = opts.totalMatching;
|
|
206
|
+
text = text.replace(/\{\{\s*total_matching\s*\}\}/g, tm != null ? String(tm) : String(items.length));
|
|
207
|
+
// {{item.X}} → first item, so "header" lines work the same way as per-item mode
|
|
208
|
+
text = renderTemplate(text, items[0] ?? {});
|
|
209
|
+
|
|
210
|
+
if (!text.trim()) throw new Error('rendered chat summary message is empty');
|
|
211
|
+
await chatHttp(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { text });
|
|
212
|
+
return { target_id: sessionId, count: items.length };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function clearReusedSession(jobId: string): void {
|
|
216
|
+
reusedSessionByJob.delete(jobId);
|
|
217
|
+
}
|