@aion0/forge 0.10.67 → 0.10.68
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 +3 -3
- package/components/ScheduleCreateModal.tsx +22 -1
- package/lib/help-docs/07-projects.md +31 -0
- package/lib/init.ts +6 -1
- package/lib/pipeline-gc.ts +62 -0
- package/lib/pipeline.ts +29 -8
- package/lib/projects.ts +76 -0
- package/lib/schedules/action-runner.ts +24 -3
- package/lib/settings.ts +5 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.68
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.67
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.67...v0.10.68
|
|
@@ -148,6 +148,9 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
|
|
|
148
148
|
? String(existing.action_config?.body_template ?? '{body_output}')
|
|
149
149
|
: '{body_output}'
|
|
150
150
|
);
|
|
151
|
+
const [emailTransport, setEmailTransport] = useState<'smtp' | 'owa'>(
|
|
152
|
+
existing?.action_kind === 'email' && existing.action_config?.transport === 'owa' ? 'owa' : 'smtp'
|
|
153
|
+
);
|
|
151
154
|
const [telegramChatId, setTelegramChatId] = useState(
|
|
152
155
|
existing?.action_kind === 'telegram' ? String(existing.action_config?.chat_id ?? '') : ''
|
|
153
156
|
);
|
|
@@ -340,6 +343,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
|
|
|
340
343
|
action_config.to = to.length > 1 ? to : (to[0] || '');
|
|
341
344
|
if (emailSubjectTpl) action_config.subject_template = emailSubjectTpl;
|
|
342
345
|
if (emailBodyTpl) action_config.body_template = emailBodyTpl;
|
|
346
|
+
action_config.transport = emailTransport;
|
|
343
347
|
} else if (actionKind === 'telegram') {
|
|
344
348
|
if (telegramChatId.trim()) action_config.chat_id = telegramChatId.trim();
|
|
345
349
|
if (telegramPrefix) action_config.prefix = telegramPrefix;
|
|
@@ -437,6 +441,7 @@ export default function ScheduleCreateModal({ onClose, onCreated, existing }: Pr
|
|
|
437
441
|
emailTo={emailTo} onEmailTo={setEmailTo}
|
|
438
442
|
emailSubjectTpl={emailSubjectTpl} onEmailSubjectTpl={setEmailSubjectTpl}
|
|
439
443
|
emailBodyTpl={emailBodyTpl} onEmailBodyTpl={setEmailBodyTpl}
|
|
444
|
+
emailTransport={emailTransport} onEmailTransport={setEmailTransport}
|
|
440
445
|
telegramChatId={telegramChatId} onTelegramChatId={setTelegramChatId}
|
|
441
446
|
telegramPrefix={telegramPrefix} onTelegramPrefix={setTelegramPrefix}
|
|
442
447
|
/>
|
|
@@ -855,6 +860,7 @@ function Step3({
|
|
|
855
860
|
emailTo, onEmailTo,
|
|
856
861
|
emailSubjectTpl, onEmailSubjectTpl,
|
|
857
862
|
emailBodyTpl, onEmailBodyTpl,
|
|
863
|
+
emailTransport, onEmailTransport,
|
|
858
864
|
telegramChatId, onTelegramChatId,
|
|
859
865
|
telegramPrefix, onTelegramPrefix,
|
|
860
866
|
}: {
|
|
@@ -871,6 +877,7 @@ function Step3({
|
|
|
871
877
|
emailTo: string; onEmailTo: (v: string) => void;
|
|
872
878
|
emailSubjectTpl: string; onEmailSubjectTpl: (v: string) => void;
|
|
873
879
|
emailBodyTpl: string; onEmailBodyTpl: (v: string) => void;
|
|
880
|
+
emailTransport: 'smtp' | 'owa'; onEmailTransport: (v: 'smtp' | 'owa') => void;
|
|
874
881
|
telegramChatId: string; onTelegramChatId: (v: string) => void;
|
|
875
882
|
telegramPrefix: string; onTelegramPrefix: (v: string) => void;
|
|
876
883
|
}) {
|
|
@@ -962,7 +969,7 @@ function Step3({
|
|
|
962
969
|
)}
|
|
963
970
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
964
971
|
<input type="radio" name="action" checked={actionKind === 'email'} onChange={() => onActionKind('email')} />
|
|
965
|
-
<span className="text-[11px] w-44">Send Email
|
|
972
|
+
<span className="text-[11px] w-44">Send Email</span>
|
|
966
973
|
<input
|
|
967
974
|
type="text"
|
|
968
975
|
value={emailTo}
|
|
@@ -974,6 +981,20 @@ function Step3({
|
|
|
974
981
|
</label>
|
|
975
982
|
{actionKind === 'email' && (
|
|
976
983
|
<>
|
|
984
|
+
<label className="flex items-center gap-2">
|
|
985
|
+
<span className="text-[11px] w-44 ml-6">transport</span>
|
|
986
|
+
<select
|
|
987
|
+
value={emailTransport}
|
|
988
|
+
onChange={(e) => onEmailTransport(e.target.value as 'smtp' | 'owa')}
|
|
989
|
+
className="text-[11px] px-2 py-0.5 border border-[var(--border)] rounded bg-[var(--bg-secondary)]"
|
|
990
|
+
>
|
|
991
|
+
<option value="smtp">SMTP (Settings → SMTP)</option>
|
|
992
|
+
<option value="owa">OWA connector (your mailbox, no SMTP)</option>
|
|
993
|
+
</select>
|
|
994
|
+
{emailTransport === 'owa' && (
|
|
995
|
+
<span className="text-[10px] text-[var(--text-secondary)]">needs the browser extension connected</span>
|
|
996
|
+
)}
|
|
997
|
+
</label>
|
|
977
998
|
<label className="flex items-center gap-2">
|
|
978
999
|
<span className="text-[11px] w-44 ml-6">subject template</span>
|
|
979
1000
|
<input
|
|
@@ -4,6 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
Add project directories in Settings → **Project Roots** (e.g. `~/Projects`). Forge scans subdirectories automatically.
|
|
6
6
|
|
|
7
|
+
## How a project is resolved (pipelines / MR review / bug fix)
|
|
8
|
+
|
|
9
|
+
When a pipeline (or chat-dispatched MR review / bug fix) needs a project, Forge
|
|
10
|
+
resolves the given name in this order — first hit wins:
|
|
11
|
+
|
|
12
|
+
1. **Exact project name** — a scanned project whose directory name equals the
|
|
13
|
+
value passed in.
|
|
14
|
+
2. **Git repo match** — when the value looks like an `owner/repo` path (e.g.
|
|
15
|
+
derived from a GitLab MR URL), Forge compares it against each project's
|
|
16
|
+
**git `origin` remote**, which it auto-detects from `.git/config` during the
|
|
17
|
+
scan (no subprocess). Full path first, then the repo basename. This is how
|
|
18
|
+
"review this MR" finds the right local checkout even when the directory is
|
|
19
|
+
named differently — you don't need to type the project. Only an
|
|
20
|
+
**unambiguous single match** is accepted; if two local clones share the same
|
|
21
|
+
origin, Forge skips this step rather than guess.
|
|
22
|
+
3. **Fuzzy name** — basename / `owner-repo` variants of the value.
|
|
23
|
+
4. **Auto-clone** — if a GitLab connector is configured, clone the repo into
|
|
24
|
+
`<dataDir>/cloned-projects/<owner>-<repo>/` and reuse it next time.
|
|
25
|
+
5. **Scratch** — the always-writable `<dataDir>/scratch` fallback for flows
|
|
26
|
+
that don't need a real checkout.
|
|
27
|
+
|
|
28
|
+
Notes:
|
|
29
|
+
- The detected repo is internal (not shown in Settings) — it's derived live
|
|
30
|
+
from each project's git origin on every scan, so existing projects work with
|
|
31
|
+
no migration.
|
|
32
|
+
- Override detection (rare: wrong/missing origin) via
|
|
33
|
+
`settings.projectRepos: { "<projectPath>": "owner/repo" }`.
|
|
34
|
+
- Auto-cloned repos under `cloned-projects/` are swept after
|
|
35
|
+
`clonedProjectKeepDays` (default 30) of disuse, unless a running pipeline
|
|
36
|
+
still has a worktree there.
|
|
37
|
+
|
|
7
38
|
## Features
|
|
8
39
|
|
|
9
40
|
### Code Tab
|
package/lib/init.ts
CHANGED
|
@@ -220,7 +220,7 @@ export function ensureInitialized() {
|
|
|
220
220
|
// expired (failed/cancelled past retention). Interval from settings,
|
|
221
221
|
// clamped to >= 1h to avoid runaway IO. Runs in background only.
|
|
222
222
|
try {
|
|
223
|
-
const { gcPipelineTmp } = require('./pipeline-gc');
|
|
223
|
+
const { gcPipelineTmp, gcClonedProjects } = require('./pipeline-gc');
|
|
224
224
|
const { loadSettings: ls } = require('./settings');
|
|
225
225
|
const hours = Math.max(1, Number(ls().pipelineTmpGcIntervalHours) || 6);
|
|
226
226
|
setInterval(() => {
|
|
@@ -228,6 +228,11 @@ export function ensureInitialized() {
|
|
|
228
228
|
const r = gcPipelineTmp();
|
|
229
229
|
if (r.removed.length) console.log(`[pipeline-gc] swept ${r.removed.length}/${r.scanned} dir(s)`);
|
|
230
230
|
} catch (e) { console.warn('[pipeline-gc] sweep failed:', (e as Error).message); }
|
|
231
|
+
// Also sweep the auto-clone cache (long retention; conservative).
|
|
232
|
+
try {
|
|
233
|
+
const c = gcClonedProjects();
|
|
234
|
+
if (c.removed.length) console.log(`[pipeline-gc] swept ${c.removed.length}/${c.scanned} cloned repo(s)`);
|
|
235
|
+
} catch (e) { console.warn('[pipeline-gc] cloned-projects sweep failed:', (e as Error).message); }
|
|
231
236
|
}, hours * 60 * 60 * 1000);
|
|
232
237
|
} catch (e) { console.warn('[pipeline-gc] setup failed:', (e as Error).message); }
|
|
233
238
|
|
package/lib/pipeline-gc.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { scanProjects, getProjectWorktreeRoot } from './projects';
|
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import { getPipeline } from './pipeline';
|
|
26
26
|
import { loadSettings } from './settings';
|
|
27
|
+
import { getDataDir } from './dirs';
|
|
27
28
|
|
|
28
29
|
export interface GcResult {
|
|
29
30
|
scanned: number;
|
|
@@ -106,3 +107,64 @@ export function gcPipelineTmp(opts: { dryRun?: boolean } = {}): GcResult {
|
|
|
106
107
|
|
|
107
108
|
return { scanned, removed, kept };
|
|
108
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GC the auto-cloned repo cache at <dataDir>/cloned-projects/<owner>-<repo>/.
|
|
113
|
+
* These are full git clones Forge makes when a pipeline runs without a local
|
|
114
|
+
* project (resolveOrCloneProject). They're reused across runs, so we keep
|
|
115
|
+
* them a long time and only sweep ones untouched for clonedProjectKeepDays
|
|
116
|
+
* (default 30). Conservative on purpose:
|
|
117
|
+
* - default 30d, and 0/negative disables the sweep entirely;
|
|
118
|
+
* - never deletes a clone that still has a worktree for a non-terminal
|
|
119
|
+
* (running/started) pipeline under it.
|
|
120
|
+
* Uses the newest mtime across the repo dir + its .forge/worktrees so an
|
|
121
|
+
* actively-used clone (recent worktree) is never considered stale.
|
|
122
|
+
*/
|
|
123
|
+
export function gcClonedProjects(opts: { dryRun?: boolean } = {}): GcResult {
|
|
124
|
+
const settings = loadSettings();
|
|
125
|
+
const keepDays = (settings as { clonedProjectKeepDays?: number }).clonedProjectKeepDays ?? 30;
|
|
126
|
+
const removed: GcResult['removed'] = [];
|
|
127
|
+
const kept: GcResult['kept'] = [];
|
|
128
|
+
let scanned = 0;
|
|
129
|
+
if (!keepDays || keepDays <= 0) return { scanned, removed, kept }; // disabled
|
|
130
|
+
|
|
131
|
+
const keepMs = keepDays * 86400_000;
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const root = join(getDataDir(), 'cloned-projects');
|
|
134
|
+
let entries: string[];
|
|
135
|
+
try { entries = readdirSync(root); } catch { return { scanned, removed, kept }; }
|
|
136
|
+
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const repoDir = join(root, entry);
|
|
139
|
+
let st: ReturnType<typeof statSync>;
|
|
140
|
+
try { st = statSync(repoDir); } catch { continue; }
|
|
141
|
+
if (!st.isDirectory()) continue;
|
|
142
|
+
scanned++;
|
|
143
|
+
|
|
144
|
+
// Newest signal across the repo dir + its worktree root.
|
|
145
|
+
let newest = st.mtimeMs;
|
|
146
|
+
const wtRoot = join(repoDir, '.forge', 'worktrees');
|
|
147
|
+
let liveWorktree = false;
|
|
148
|
+
try {
|
|
149
|
+
for (const wt of readdirSync(wtRoot)) {
|
|
150
|
+
const wtPath = join(wtRoot, wt);
|
|
151
|
+
try { newest = Math.max(newest, statSync(wtPath).mtimeMs); } catch {}
|
|
152
|
+
if (wt.startsWith('pipeline-')) {
|
|
153
|
+
const p = getPipeline(wt.slice('pipeline-'.length));
|
|
154
|
+
if (p && p.status !== 'done' && p.status !== 'failed' && p.status !== 'cancelled') {
|
|
155
|
+
liveWorktree = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch { /* no worktrees dir */ }
|
|
160
|
+
|
|
161
|
+
if (liveWorktree) { kept.push({ path: repoDir, reason: 'in-use (running pipeline worktree)' }); continue; }
|
|
162
|
+
if (now - newest > keepMs) {
|
|
163
|
+
if (!opts.dryRun) { try { rmSync(repoDir, { recursive: true, force: true }); } catch {} }
|
|
164
|
+
removed.push({ path: repoDir, reason: `clone unused >${keepDays}d` });
|
|
165
|
+
} else {
|
|
166
|
+
kept.push({ path: repoDir, reason: 'fresh' });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { scanned, removed, kept };
|
|
170
|
+
}
|
package/lib/pipeline.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
14
|
-
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot } from './projects';
|
|
14
|
+
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
17
17
|
import type { Task } from '../src/types';
|
|
@@ -747,16 +747,22 @@ export function resolveTemplate(template: string, ctx: {
|
|
|
747
747
|
// ─── Per-run scratch dir (`{{run.tmp_dir}}`) ────────────────
|
|
748
748
|
|
|
749
749
|
/**
|
|
750
|
-
* Compute the absolute scratch-dir path for a pipeline run.
|
|
751
|
-
*
|
|
750
|
+
* Compute the absolute scratch-dir path for a pipeline run. Falls back to
|
|
751
|
+
* the scratch project's worktree root when input.project is missing or
|
|
752
|
+
* unresolvable — NEVER returns '' (an empty {{run.tmp_dir}} made YAML like
|
|
753
|
+
* `mkdir -p {{run.tmp_dir}}/mr-${IID}` resolve to `/mr-NNN`, i.e. the
|
|
754
|
+
* read-only filesystem root — that was the "mkdir: /mr-14949: Read-only
|
|
755
|
+
* file system" crash). Does NOT mkdir.
|
|
752
756
|
*/
|
|
753
757
|
function computePipelineTmpDir(pipeline: Pipeline): string {
|
|
754
758
|
const name = pipeline.input?.project;
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
759
|
+
const proj = name ? getProjectInfo(name) : null;
|
|
760
|
+
if (proj) {
|
|
761
|
+
// Worktree root differs for scratch vs real projects — see getProjectWorktreeRoot.
|
|
762
|
+
return join(getProjectWorktreeRoot(proj), `pipeline-${pipeline.id}`);
|
|
763
|
+
}
|
|
764
|
+
// No / unresolved project → writable scratch base under <dataDir>/scratch.
|
|
765
|
+
return join(ensureScratchProject(), 'worktrees', `pipeline-${pipeline.id}`);
|
|
760
766
|
}
|
|
761
767
|
|
|
762
768
|
/**
|
|
@@ -764,6 +770,21 @@ function computePipelineTmpDir(pipeline: Pipeline): string {
|
|
|
764
770
|
* non-fatal — `tmpDir` stays unset and `{{run.tmp_dir}}` renders empty.
|
|
765
771
|
*/
|
|
766
772
|
function ensurePipelineTmpDir(pipeline: Pipeline): void {
|
|
773
|
+
// Align run.tmp_dir with the project the nodes will actually use. When no
|
|
774
|
+
// project was given, resolve it through the same three-tier path the nodes
|
|
775
|
+
// use (existing → gitlab clone → scratch) and write the concrete name back
|
|
776
|
+
// to input.project, so tmp_dir lands inside that (possibly cloned) repo and
|
|
777
|
+
// downstream resolveOrCloneProject finds it as 'existing' (no double clone).
|
|
778
|
+
// Every node already calls resolveOrCloneProject, so this adds no NEW clone
|
|
779
|
+
// — it just happens once up front. Never throws (falls back to scratch).
|
|
780
|
+
if (!pipeline.input?.project) {
|
|
781
|
+
try {
|
|
782
|
+
const r = resolveOrCloneProject('');
|
|
783
|
+
if (r?.project?.name) {
|
|
784
|
+
pipeline.input = { ...(pipeline.input || {}), project: r.project.name };
|
|
785
|
+
}
|
|
786
|
+
} catch { /* leave empty → computePipelineTmpDir's scratch fallback */ }
|
|
787
|
+
}
|
|
767
788
|
const dir = computePipelineTmpDir(pipeline);
|
|
768
789
|
if (!dir) return;
|
|
769
790
|
try {
|
package/lib/projects.ts
CHANGED
|
@@ -13,6 +13,46 @@ export interface LocalProject {
|
|
|
13
13
|
hasClaudeMd: boolean;
|
|
14
14
|
language: string | null;
|
|
15
15
|
lastModified: string;
|
|
16
|
+
/** Normalized "owner/repo" derived from the git origin remote (or a
|
|
17
|
+
* settings.projectRepos override). Lets MR/bug pipelines map a repo
|
|
18
|
+
* path back to this local checkout. Undefined when not a git repo /
|
|
19
|
+
* no origin. */
|
|
20
|
+
repo?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalize a git remote URL to an "owner/repo" path (lowercased), so it
|
|
24
|
+
* can be matched against a gitlab MR path. Handles ssh + https forms:
|
|
25
|
+
* git@host:owner/repo.git → owner/repo
|
|
26
|
+
* https://host/owner/repo.git → owner/repo
|
|
27
|
+
* https://host/group/sub/repo → group/sub/repo
|
|
28
|
+
* Returns '' if it can't parse one. */
|
|
29
|
+
function normalizeRepoPath(url: string): string {
|
|
30
|
+
let s = (url || '').trim();
|
|
31
|
+
if (!s) return '';
|
|
32
|
+
s = s.replace(/\.git$/i, '');
|
|
33
|
+
// scp-style: git@host:owner/repo
|
|
34
|
+
const scp = s.match(/^[^@]+@[^:]+:(.+)$/);
|
|
35
|
+
if (scp) return scp[1].replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
36
|
+
// url-style: scheme://[user@]host/owner/repo
|
|
37
|
+
const m = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i);
|
|
38
|
+
if (m) return m[1].replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read a project's origin remote from .git/config WITHOUT spawning git
|
|
43
|
+
* (scanProjects runs often; a subprocess per project would be costly).
|
|
44
|
+
* Returns the normalized "owner/repo" or '' . */
|
|
45
|
+
function readGitOriginRepo(projectPath: string): string {
|
|
46
|
+
try {
|
|
47
|
+
const cfg = readFileSync(join(projectPath, '.git', 'config'), 'utf-8');
|
|
48
|
+
// Find the [remote "origin"] section then its `url = ...` line.
|
|
49
|
+
const sec = cfg.match(/\[remote "origin"\]([\s\S]*?)(?:\n\[|$)/);
|
|
50
|
+
if (!sec) return '';
|
|
51
|
+
const u = sec[1].match(/^\s*url\s*=\s*(.+)\s*$/m);
|
|
52
|
+
return u ? normalizeRepoPath(u[1]) : '';
|
|
53
|
+
} catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
16
56
|
}
|
|
17
57
|
|
|
18
58
|
/** Reserved name for the synthetic "scratch" project that lives under
|
|
@@ -112,6 +152,13 @@ export function scanProjects(): LocalProject[] {
|
|
|
112
152
|
const language = detectLanguage(projectPath);
|
|
113
153
|
const stat = statSync(projectPath);
|
|
114
154
|
|
|
155
|
+
// repo: manual settings.projectRepos override wins; else auto-detect
|
|
156
|
+
// from the git origin remote. Cheap (file read, no subprocess).
|
|
157
|
+
const repoOverride = (settings.projectRepos || {})[projectPath];
|
|
158
|
+
const repo = (typeof repoOverride === 'string' && repoOverride.trim())
|
|
159
|
+
? normalizeRepoPath(repoOverride) || repoOverride.trim().toLowerCase()
|
|
160
|
+
: (hasGit ? readGitOriginRepo(projectPath) : '');
|
|
161
|
+
|
|
115
162
|
projects.push({
|
|
116
163
|
name: entry.name,
|
|
117
164
|
path: projectPath,
|
|
@@ -120,6 +167,7 @@ export function scanProjects(): LocalProject[] {
|
|
|
120
167
|
hasClaudeMd,
|
|
121
168
|
language,
|
|
122
169
|
lastModified: stat.mtime.toISOString(),
|
|
170
|
+
repo: repo || undefined,
|
|
123
171
|
});
|
|
124
172
|
} catch {
|
|
125
173
|
// Skip inaccessible directories
|
|
@@ -301,6 +349,34 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
|
|
|
301
349
|
if (trimmed) {
|
|
302
350
|
const hit = getProjectInfo(trimmed);
|
|
303
351
|
if (hit) return { project: hit, source: 'existing' };
|
|
352
|
+
|
|
353
|
+
const projects = scanProjects();
|
|
354
|
+
|
|
355
|
+
// Repo match (best signal): when the caller passes an "owner/repo" path
|
|
356
|
+
// (e.g. derived from an MR URL), match it against each project's git
|
|
357
|
+
// origin remote (LocalProject.repo, auto-detected in scanProjects).
|
|
358
|
+
// This is how "review this MR" finds the right local checkout without
|
|
359
|
+
// the user naming the project. Compares full path then basename; only
|
|
360
|
+
// an UNAMBIGUOUS single match wins.
|
|
361
|
+
if (trimmed.includes('/')) {
|
|
362
|
+
const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
363
|
+
const wantBase = want.split('/').pop()!;
|
|
364
|
+
const byRepo = projects.filter((p) => p.repo && p.repo === want);
|
|
365
|
+
if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
|
|
366
|
+
const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
|
|
367
|
+
if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Fuzzy name match across the configured project roots before cloning:
|
|
371
|
+
// the caller may pass a gitlab path ("owner/repo") or the cloned-projects
|
|
372
|
+
// dir name ("owner-repo") while the local checkout is just "repo". Try
|
|
373
|
+
// the basename and the owner-repo form; only accept an UNAMBIGUOUS
|
|
374
|
+
// single match so we never silently pick the wrong repo.
|
|
375
|
+
const base = trimmed.split('/').pop()!.trim();
|
|
376
|
+
const ownerRepo = trimmed.replace(/\//g, '-');
|
|
377
|
+
const cands = projects.filter((p) =>
|
|
378
|
+
p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
|
|
379
|
+
if (cands.length === 1) return { project: cands[0], source: 'existing' };
|
|
304
380
|
}
|
|
305
381
|
|
|
306
382
|
// Fallback to gitlab connector when no local match. Two cases worth
|
|
@@ -155,9 +155,6 @@ async function runChatAction(schedule: Schedule, run: ScheduleRun, output: strin
|
|
|
155
155
|
*/
|
|
156
156
|
async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: string): Promise<void> {
|
|
157
157
|
const settings = loadSettings();
|
|
158
|
-
if (!settings.smtpHost) {
|
|
159
|
-
throw new Error('SMTP not configured (Settings → SMTP)');
|
|
160
|
-
}
|
|
161
158
|
const cfg = schedule.action_config || {};
|
|
162
159
|
const toRaw = cfg.to;
|
|
163
160
|
const to = Array.isArray(toRaw)
|
|
@@ -178,6 +175,30 @@ async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: str
|
|
|
178
175
|
const bodyText = bodyTpl.replace('{date}', today).replace('{body_output}', output);
|
|
179
176
|
const asHtml = !!cfg.html;
|
|
180
177
|
|
|
178
|
+
// Transport: 'smtp' (default, nodemailer) or 'owa' (send via the OWA
|
|
179
|
+
// browser connector through the extension bridge — no SMTP server needed,
|
|
180
|
+
// rides the user's logged-in mailbox; requires the extension connected,
|
|
181
|
+
// same as any browser connector in a scheduled run).
|
|
182
|
+
const emailTransport = String(cfg.transport || 'smtp').toLowerCase();
|
|
183
|
+
if (emailTransport === 'owa') {
|
|
184
|
+
const { dispatchTool } = await import('../chat/tool-dispatcher');
|
|
185
|
+
const r = await dispatchTool({
|
|
186
|
+
id: `sched-email-${Date.now()}`,
|
|
187
|
+
name: 'owa.send_mail',
|
|
188
|
+
input: { to: to.join(', '), subject, body: bodyText, body_html: asHtml },
|
|
189
|
+
});
|
|
190
|
+
let parsed: any = r.content;
|
|
191
|
+
try { parsed = JSON.parse(r.content); } catch { /* keep string */ }
|
|
192
|
+
if (r.is_error || (parsed && parsed.error)) {
|
|
193
|
+
throw new Error(`OWA send failed: ${(parsed && parsed.error) || r.content}`);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!settings.smtpHost) {
|
|
199
|
+
throw new Error("SMTP not configured (Settings → SMTP). For mailbox send without SMTP, set action_config.transport='owa'.");
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
// Lazy import keeps the SMTP client out of the main bundle until the
|
|
182
203
|
// first email action actually fires.
|
|
183
204
|
const nodemailer = (await import('nodemailer')).default;
|
package/lib/settings.ts
CHANGED
|
@@ -93,6 +93,11 @@ export interface McpServerConfig {
|
|
|
93
93
|
|
|
94
94
|
export interface Settings {
|
|
95
95
|
projectRoots: string[];
|
|
96
|
+
/** Optional per-project repo override: { <projectPath>: "owner/repo" }.
|
|
97
|
+
* Normally the repo is auto-detected from the project's git origin remote
|
|
98
|
+
* (see lib/projects.ts); this only overrides when detection is wrong or
|
|
99
|
+
* the checkout has no origin. */
|
|
100
|
+
projectRepos?: Record<string, string>;
|
|
96
101
|
docRoots: string[];
|
|
97
102
|
claudePath: string;
|
|
98
103
|
// claudeHome removed in 0.10.10 — was a never-UI-exposed override that
|
package/package.json
CHANGED