claude-remote-cli 3.9.4 → 3.10.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/dist/frontend/assets/index-BTOnhJQN.css +32 -0
- package/dist/frontend/assets/index-Dgf6cKGu.js +52 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/branch-linker.js +136 -0
- package/dist/server/config.js +31 -1
- package/dist/server/index.js +260 -6
- package/dist/server/integration-github.js +117 -0
- package/dist/server/integration-jira.js +177 -0
- package/dist/server/integration-linear.js +176 -0
- package/dist/server/org-dashboard.js +222 -0
- package/dist/server/review-poller.js +241 -0
- package/dist/server/sessions.js +43 -3
- package/dist/server/ticket-transitions.js +265 -0
- package/dist/server/watcher.js +124 -0
- package/dist/test/branch-linker.test.js +231 -0
- package/dist/test/branch-watcher.test.js +73 -0
- package/dist/test/config.test.js +56 -0
- package/dist/test/integration-github.test.js +203 -0
- package/dist/test/integration-jira.test.js +302 -0
- package/dist/test/integration-linear.test.js +293 -0
- package/dist/test/org-dashboard.test.js +240 -0
- package/dist/test/review-poller.test.js +344 -0
- package/dist/test/ticket-transitions.test.js +470 -0
- package/package.json +1 -1
- package/dist/frontend/assets/index-BYv7-2w9.css +0 -32
- package/dist/frontend/assets/index-CO9tRKXI.js +0 -52
package/dist/frontend/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<meta name="theme-color" content="#1a1a1a" />
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-Dgf6cKGu.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BTOnhJQN.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="app"></div>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
8
|
+
const CACHE_TTL_MS = 60_000;
|
|
9
|
+
let cache = null;
|
|
10
|
+
/** Clears the branch linker cache (call when sessions are created or ended). */
|
|
11
|
+
export function invalidateBranchLinkerCache() {
|
|
12
|
+
cache = null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Extracts all ticket IDs from a branch name.
|
|
16
|
+
* Returns an array of normalized ticket IDs (e.g. "PROJ-123", "GH-456").
|
|
17
|
+
*/
|
|
18
|
+
function extractTicketIds(branchName) {
|
|
19
|
+
const ids = [];
|
|
20
|
+
// Jira/Linear style: PROJECT-123 (2+ uppercase letters, dash, digits)
|
|
21
|
+
// Skip "GH" prefix — that's our GitHub Issues namespace, handled separately below.
|
|
22
|
+
const jiraRegex = /([A-Z]{2,}-\d+)/gi;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = jiraRegex.exec(branchName)) !== null) {
|
|
25
|
+
if (match[1] && match[1].toUpperCase().split('-')[0] !== 'GH') {
|
|
26
|
+
ids.push(match[1].toUpperCase());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// GitHub Issues: gh-123 at word boundaries (start/end or preceded/followed by dash or slash)
|
|
30
|
+
const ghRegex = /(?:^|[-/])gh-(\d+)(?:[-/]|$)/gi;
|
|
31
|
+
while ((match = ghRegex.exec(branchName)) !== null) {
|
|
32
|
+
ids.push(`GH-${match[1]}`);
|
|
33
|
+
}
|
|
34
|
+
return ids;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates and returns an Express Router that handles all /branch-linker routes.
|
|
38
|
+
*
|
|
39
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
40
|
+
* app.use('/branch-linker', requireAuth, createBranchLinkerRouter({ configPath }));
|
|
41
|
+
*/
|
|
42
|
+
export function createBranchLinkerRouter(deps) {
|
|
43
|
+
const { configPath } = deps;
|
|
44
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
45
|
+
const getActiveBranchNames = deps.getActiveBranchNames ?? (() => new Map());
|
|
46
|
+
const router = Router();
|
|
47
|
+
function getConfig() {
|
|
48
|
+
return loadConfig(configPath);
|
|
49
|
+
}
|
|
50
|
+
/** Core link-building logic, usable both from the HTTP handler and internal callers. */
|
|
51
|
+
async function fetchLinks() {
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
const workspacePaths = config.workspaces ?? [];
|
|
54
|
+
if (workspacePaths.length === 0) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
// Return cached result if still fresh
|
|
59
|
+
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
|
|
60
|
+
return cache.links;
|
|
61
|
+
}
|
|
62
|
+
// Get active branch names per repo from sessions
|
|
63
|
+
const activeBranchNames = getActiveBranchNames();
|
|
64
|
+
// Fetch branches per workspace using Promise.allSettled (partial failures are non-fatal)
|
|
65
|
+
const results = await Promise.allSettled(workspacePaths.map(async (wsPath) => {
|
|
66
|
+
let stdout;
|
|
67
|
+
try {
|
|
68
|
+
({ stdout } = await exec('git', ['branch', '--format=%(refname:short)'], { cwd: wsPath, timeout: GIT_TIMEOUT_MS }));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Not a git repo or git not available — non-fatal
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const repoName = path.basename(wsPath);
|
|
75
|
+
const activeInRepo = activeBranchNames.get(wsPath) ?? new Set();
|
|
76
|
+
const branchNames = stdout.split('\n').map((b) => b.trim()).filter(Boolean);
|
|
77
|
+
const links = [];
|
|
78
|
+
for (const branchName of branchNames) {
|
|
79
|
+
const ticketIds = extractTicketIds(branchName);
|
|
80
|
+
for (const ticketId of ticketIds) {
|
|
81
|
+
// Infer ticket source from ID pattern
|
|
82
|
+
let source;
|
|
83
|
+
if (ticketId.startsWith('GH-')) {
|
|
84
|
+
source = 'github';
|
|
85
|
+
}
|
|
86
|
+
else if (process.env.JIRA_API_TOKEN) {
|
|
87
|
+
source = 'jira';
|
|
88
|
+
}
|
|
89
|
+
else if (process.env.LINEAR_API_KEY) {
|
|
90
|
+
source = 'linear';
|
|
91
|
+
}
|
|
92
|
+
links.push({
|
|
93
|
+
ticketId,
|
|
94
|
+
link: {
|
|
95
|
+
repoPath: wsPath,
|
|
96
|
+
repoName,
|
|
97
|
+
branchName,
|
|
98
|
+
hasActiveSession: activeInRepo.has(branchName),
|
|
99
|
+
source,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return links;
|
|
105
|
+
}));
|
|
106
|
+
// Build the ticket -> BranchLink[] map
|
|
107
|
+
const linksMap = new Map();
|
|
108
|
+
for (const result of results) {
|
|
109
|
+
if (result.status === 'fulfilled') {
|
|
110
|
+
for (const { ticketId, link } of result.value) {
|
|
111
|
+
const existing = linksMap.get(ticketId);
|
|
112
|
+
if (existing) {
|
|
113
|
+
existing.push(link);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
linksMap.set(ticketId, [link]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Convert Map to plain object for JSON serialization
|
|
122
|
+
const response = {};
|
|
123
|
+
for (const [ticketId, links] of linksMap) {
|
|
124
|
+
response[ticketId] = links;
|
|
125
|
+
}
|
|
126
|
+
// Update module-level cache
|
|
127
|
+
cache = { links: response, fetchedAt: now };
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
// GET /branch-linker/links — map of ticketId -> BranchLink[]
|
|
131
|
+
router.get('/links', async (_req, res) => {
|
|
132
|
+
const response = await fetchLinks();
|
|
133
|
+
res.json(response);
|
|
134
|
+
});
|
|
135
|
+
return Object.assign(router, { fetchLinks });
|
|
136
|
+
}
|
package/dist/server/config.js
CHANGED
|
@@ -21,7 +21,37 @@ export function loadConfig(configPath) {
|
|
|
21
21
|
}
|
|
22
22
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
23
23
|
const parsed = JSON.parse(raw);
|
|
24
|
-
|
|
24
|
+
const config = { ...DEFAULTS, ...parsed };
|
|
25
|
+
// Validate and clean workspaceGroups
|
|
26
|
+
if (config.workspaceGroups != null) {
|
|
27
|
+
const validPaths = new Set(config.workspaces ?? []);
|
|
28
|
+
const seenPaths = new Set();
|
|
29
|
+
const cleaned = {};
|
|
30
|
+
for (const [groupName, paths] of Object.entries(config.workspaceGroups)) {
|
|
31
|
+
if (!Array.isArray(paths)) {
|
|
32
|
+
console.warn(`workspaceGroups: group "${groupName}" value is not an array, skipping`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const filteredPaths = [];
|
|
36
|
+
for (const p of paths) {
|
|
37
|
+
if (!validPaths.has(p)) {
|
|
38
|
+
console.warn(`workspaceGroups: path "${p}" in group "${groupName}" is not in workspaces[], skipping`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (seenPaths.has(p)) {
|
|
42
|
+
console.warn(`workspaceGroups: path "${p}" in group "${groupName}" is already assigned to another group, skipping`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
seenPaths.add(p);
|
|
46
|
+
filteredPaths.push(p);
|
|
47
|
+
}
|
|
48
|
+
if (filteredPaths.length > 0) {
|
|
49
|
+
cleaned[groupName] = filteredPaths;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
config.workspaceGroups = cleaned;
|
|
53
|
+
}
|
|
54
|
+
return config;
|
|
25
55
|
}
|
|
26
56
|
export function saveConfig(configPath, config) {
|
|
27
57
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
package/dist/server/index.js
CHANGED
|
@@ -13,14 +13,21 @@ import * as auth from './auth.js';
|
|
|
13
13
|
import * as sessions from './sessions.js';
|
|
14
14
|
import { AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS, serializeAll, restoreFromDisk, activeTmuxSessionNames, populateMetaCache } from './sessions.js';
|
|
15
15
|
import { setupWebSocket } from './ws.js';
|
|
16
|
-
import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
|
|
16
|
+
import { WorktreeWatcher, BranchWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
|
|
17
17
|
import { isInstalled as serviceIsInstalled } from './service.js';
|
|
18
18
|
import { extensionForMime, setClipboardImage } from './clipboard.js';
|
|
19
19
|
import { listBranches, isBranchStale } from './git.js';
|
|
20
20
|
import * as push from './push.js';
|
|
21
21
|
import { initAnalytics, closeAnalytics, createAnalyticsRouter } from './analytics.js';
|
|
22
22
|
import { createWorkspaceRouter } from './workspaces.js';
|
|
23
|
+
import { createOrgDashboardRouter } from './org-dashboard.js';
|
|
24
|
+
import { createIntegrationGitHubRouter } from './integration-github.js';
|
|
25
|
+
import { createBranchLinkerRouter, invalidateBranchLinkerCache } from './branch-linker.js';
|
|
23
26
|
import { createHooksRouter } from './hooks.js';
|
|
27
|
+
import { createTicketTransitionsRouter } from './ticket-transitions.js';
|
|
28
|
+
import { createIntegrationJiraRouter } from './integration-jira.js';
|
|
29
|
+
import { createIntegrationLinearRouter } from './integration-linear.js';
|
|
30
|
+
import { startPolling, stopPolling } from './review-poller.js';
|
|
24
31
|
import { MOUNTAIN_NAMES } from './types.js';
|
|
25
32
|
import { semverLessThan } from './utils.js';
|
|
26
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -235,6 +242,26 @@ async function main() {
|
|
|
235
242
|
watcher.rebuild(config.workspaces || []);
|
|
236
243
|
const server = http.createServer(app);
|
|
237
244
|
const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher, CONFIG_PATH);
|
|
245
|
+
// Watch .git/HEAD files for branch changes and update active sessions
|
|
246
|
+
const branchWatcher = new BranchWatcher((cwdPath, newBranch) => {
|
|
247
|
+
for (const session of sessions.list()) {
|
|
248
|
+
if (session.repoPath === cwdPath || session.cwd === cwdPath) {
|
|
249
|
+
const raw = sessions.get(session.id);
|
|
250
|
+
if (raw) {
|
|
251
|
+
raw.branchName = newBranch;
|
|
252
|
+
broadcastEvent('session-renamed', {
|
|
253
|
+
sessionId: session.id,
|
|
254
|
+
branchName: newBranch,
|
|
255
|
+
displayName: raw.displayName,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
branchWatcher.rebuild(config.workspaces || []);
|
|
262
|
+
watcher.on('worktrees-changed', () => {
|
|
263
|
+
branchWatcher.rebuild(config.workspaces || []);
|
|
264
|
+
});
|
|
238
265
|
// Configure session defaults for hooks injection
|
|
239
266
|
sessions.configure({ port: config.port, forceOutputParser: config.forceOutputParser ?? false });
|
|
240
267
|
// Mount hooks router BEFORE auth middleware — hook callbacks come from localhost Claude Code
|
|
@@ -249,6 +276,45 @@ async function main() {
|
|
|
249
276
|
// Mount workspace router
|
|
250
277
|
const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
|
|
251
278
|
app.use('/workspaces', requireAuth, workspaceRouter);
|
|
279
|
+
// Mount GitHub integration router
|
|
280
|
+
const integrationGitHubRouter = createIntegrationGitHubRouter({ configPath: CONFIG_PATH });
|
|
281
|
+
app.use('/integration-github', requireAuth, integrationGitHubRouter);
|
|
282
|
+
// Mount Jira integration router
|
|
283
|
+
const integrationJiraRouter = createIntegrationJiraRouter({ configPath: CONFIG_PATH });
|
|
284
|
+
app.use('/integration-jira', requireAuth, integrationJiraRouter);
|
|
285
|
+
// Mount Linear integration router
|
|
286
|
+
const integrationLinearRouter = createIntegrationLinearRouter({ configPath: CONFIG_PATH });
|
|
287
|
+
app.use('/integration-linear', requireAuth, integrationLinearRouter);
|
|
288
|
+
// Mount branch linker router
|
|
289
|
+
const branchLinkerRouter = createBranchLinkerRouter({
|
|
290
|
+
configPath: CONFIG_PATH,
|
|
291
|
+
getActiveBranchNames: () => {
|
|
292
|
+
const workspaces = config.workspaces ?? [];
|
|
293
|
+
const map = new Map();
|
|
294
|
+
for (const s of sessions.list()) {
|
|
295
|
+
if (!s.branchName)
|
|
296
|
+
continue;
|
|
297
|
+
// Normalize: match session repoPath to workspace root
|
|
298
|
+
// (worktree sessions store the worktree path, not workspace root)
|
|
299
|
+
const wsRoot = workspaces.find((ws) => s.repoPath.startsWith(ws)) ?? s.repoPath;
|
|
300
|
+
const existing = map.get(wsRoot);
|
|
301
|
+
if (existing) {
|
|
302
|
+
existing.add(s.branchName);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
map.set(wsRoot, new Set([s.branchName]));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return map;
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
app.use('/branch-linker', requireAuth, branchLinkerRouter);
|
|
312
|
+
// Mount ticket transitions router
|
|
313
|
+
const { router: ticketTransitionsRouter, transitionOnSessionCreate, checkPrTransitions } = createTicketTransitionsRouter({ configPath: CONFIG_PATH });
|
|
314
|
+
app.use('/ticket-transitions', requireAuth, ticketTransitionsRouter);
|
|
315
|
+
// Mount org dashboard router — use branchLinkerRouter.fetchLinks() directly (no loopback HTTP)
|
|
316
|
+
const orgDashboardRouter = createOrgDashboardRouter({ configPath: CONFIG_PATH, checkPrTransitions, getBranchLinks: () => branchLinkerRouter.fetchLinks() });
|
|
317
|
+
app.use('/org-dashboard', requireAuth, orgDashboardRouter);
|
|
252
318
|
// Mount analytics router
|
|
253
319
|
app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
|
|
254
320
|
// Restore sessions from a previous update restart
|
|
@@ -258,6 +324,45 @@ async function main() {
|
|
|
258
324
|
}
|
|
259
325
|
// Populate session metadata cache in background (non-blocking)
|
|
260
326
|
populateMetaCache().catch(() => { });
|
|
327
|
+
// Build shared deps for review poller
|
|
328
|
+
function buildPollerDeps() {
|
|
329
|
+
return {
|
|
330
|
+
configPath: CONFIG_PATH,
|
|
331
|
+
getWorkspacePaths: () => config.workspaces ?? [],
|
|
332
|
+
getWorkspaceSettings: (wsPath) => config.workspaceSettings?.[wsPath],
|
|
333
|
+
createSession: async (opts) => {
|
|
334
|
+
const resolved = resolveSessionSettings(config, opts.repoPath, {});
|
|
335
|
+
const roots = config.rootDirs || [];
|
|
336
|
+
const root = roots.find((r) => opts.repoPath.startsWith(r)) || '';
|
|
337
|
+
const repoName = opts.repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
338
|
+
const worktreeName = opts.worktreePath.split('/').pop() || '';
|
|
339
|
+
const displayName = sessions.nextAgentName();
|
|
340
|
+
sessions.create({
|
|
341
|
+
type: 'worktree',
|
|
342
|
+
agent: resolved.agent,
|
|
343
|
+
repoName,
|
|
344
|
+
repoPath: opts.worktreePath,
|
|
345
|
+
cwd: opts.worktreePath,
|
|
346
|
+
root,
|
|
347
|
+
worktreeName,
|
|
348
|
+
branchName: opts.branchName,
|
|
349
|
+
displayName,
|
|
350
|
+
args: [...resolved.claudeArgs, ...(resolved.yolo ? AGENT_YOLO_ARGS[resolved.agent] : [])],
|
|
351
|
+
configPath: CONFIG_PATH,
|
|
352
|
+
useTmux: resolved.useTmux,
|
|
353
|
+
...(opts.initialPrompt != null && { initialPrompt: opts.initialPrompt }),
|
|
354
|
+
});
|
|
355
|
+
},
|
|
356
|
+
broadcastEvent,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// Start review request poller if enabled
|
|
360
|
+
if (config.automations?.autoCheckoutReviewRequests) {
|
|
361
|
+
startPolling(buildPollerDeps());
|
|
362
|
+
}
|
|
363
|
+
// Invalidate branch linker cache on session lifecycle changes
|
|
364
|
+
sessions.onSessionCreate(() => { invalidateBranchLinkerCache(); });
|
|
365
|
+
sessions.onSessionEnd(() => { invalidateBranchLinkerCache(); });
|
|
261
366
|
// Push notifications on session idle (skip when hooks already sent attention notification)
|
|
262
367
|
sessions.onIdleChange((sessionId, idle) => {
|
|
263
368
|
if (idle) {
|
|
@@ -301,9 +406,43 @@ async function main() {
|
|
|
301
406
|
});
|
|
302
407
|
res.json({ ok: true });
|
|
303
408
|
});
|
|
304
|
-
// GET /sessions
|
|
305
|
-
|
|
306
|
-
|
|
409
|
+
// GET /sessions — enrich with live branch from git (rate-limited to avoid spawning git on every poll)
|
|
410
|
+
const branchRefreshCache = new Map(); // sessionId -> last refresh timestamp
|
|
411
|
+
const BRANCH_REFRESH_INTERVAL_MS = 10_000;
|
|
412
|
+
app.get('/sessions', requireAuth, async (_req, res) => {
|
|
413
|
+
const allSessions = sessions.list();
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
// Prune cache entries for sessions that no longer exist
|
|
416
|
+
const activeIds = new Set(allSessions.map((s) => s.id));
|
|
417
|
+
for (const sessionId of branchRefreshCache.keys()) {
|
|
418
|
+
if (!activeIds.has(sessionId))
|
|
419
|
+
branchRefreshCache.delete(sessionId);
|
|
420
|
+
}
|
|
421
|
+
await Promise.all(allSessions.map(async (s) => {
|
|
422
|
+
if (s.type !== 'repo' && s.type !== 'worktree')
|
|
423
|
+
return;
|
|
424
|
+
if (!s.repoPath)
|
|
425
|
+
return;
|
|
426
|
+
const lastRefresh = branchRefreshCache.get(s.id) ?? 0;
|
|
427
|
+
if (now - lastRefresh < BRANCH_REFRESH_INTERVAL_MS)
|
|
428
|
+
return;
|
|
429
|
+
const cwd = s.type === 'repo' ? s.repoPath : s.cwd;
|
|
430
|
+
if (!cwd)
|
|
431
|
+
return;
|
|
432
|
+
branchRefreshCache.set(s.id, now);
|
|
433
|
+
try {
|
|
434
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
435
|
+
const liveBranch = stdout.trim();
|
|
436
|
+
if (liveBranch && liveBranch !== s.branchName) {
|
|
437
|
+
s.branchName = liveBranch;
|
|
438
|
+
const raw = sessions.get(s.id);
|
|
439
|
+
if (raw)
|
|
440
|
+
raw.branchName = liveBranch;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch { /* non-fatal */ }
|
|
444
|
+
}));
|
|
445
|
+
res.json(allSessions);
|
|
307
446
|
});
|
|
308
447
|
// GET /repos — scan root dirs for repos
|
|
309
448
|
app.get('/repos', requireAuth, async (_req, res) => {
|
|
@@ -455,6 +594,50 @@ async function main() {
|
|
|
455
594
|
await execFileAsync('tmux', ['-V']);
|
|
456
595
|
});
|
|
457
596
|
boolConfigEndpoints('defaultNotifications', true);
|
|
597
|
+
// GET /config/automations — get automation settings
|
|
598
|
+
app.get('/config/automations', requireAuth, (_req, res) => {
|
|
599
|
+
res.json(config.automations ?? {});
|
|
600
|
+
});
|
|
601
|
+
// PATCH /config/automations — update automation settings and start/stop poller
|
|
602
|
+
app.patch('/config/automations', requireAuth, (req, res) => {
|
|
603
|
+
const body = req.body;
|
|
604
|
+
const prev = config.automations ?? {};
|
|
605
|
+
const next = { ...prev };
|
|
606
|
+
if (typeof body.autoCheckoutReviewRequests === 'boolean') {
|
|
607
|
+
next.autoCheckoutReviewRequests = body.autoCheckoutReviewRequests;
|
|
608
|
+
}
|
|
609
|
+
if (typeof body.autoReviewOnCheckout === 'boolean') {
|
|
610
|
+
next.autoReviewOnCheckout = body.autoReviewOnCheckout;
|
|
611
|
+
}
|
|
612
|
+
if (typeof body.pollIntervalMs === 'number' && body.pollIntervalMs >= 60000) {
|
|
613
|
+
next.pollIntervalMs = body.pollIntervalMs;
|
|
614
|
+
}
|
|
615
|
+
// Enforce: auto-review requires auto-checkout
|
|
616
|
+
if (!next.autoCheckoutReviewRequests) {
|
|
617
|
+
next.autoReviewOnCheckout = false;
|
|
618
|
+
}
|
|
619
|
+
config.automations = next;
|
|
620
|
+
try {
|
|
621
|
+
saveConfig(CONFIG_PATH, config);
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
config.automations = prev;
|
|
625
|
+
console.error('[config] Failed to save automation settings:', err);
|
|
626
|
+
res.status(500).json({ error: 'Failed to save settings' });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// Start or stop poller based on new setting
|
|
630
|
+
void stopPolling().then(() => {
|
|
631
|
+
if (next.autoCheckoutReviewRequests) {
|
|
632
|
+
startPolling(buildPollerDeps());
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
res.json(next);
|
|
636
|
+
});
|
|
637
|
+
// GET /config/workspace-groups — return workspace group configuration
|
|
638
|
+
app.get('/config/workspace-groups', requireAuth, (_req, res) => {
|
|
639
|
+
res.json({ groups: config.workspaceGroups ?? {} });
|
|
640
|
+
});
|
|
458
641
|
// GET /push/vapid-key
|
|
459
642
|
app.get('/push/vapid-key', requireAuth, (_req, res) => {
|
|
460
643
|
const key = push.getVapidPublicKey();
|
|
@@ -552,7 +735,7 @@ async function main() {
|
|
|
552
735
|
});
|
|
553
736
|
// POST /sessions
|
|
554
737
|
app.post('/sessions', requireAuth, async (req, res) => {
|
|
555
|
-
const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, cols, rows, needsBranchRename, branchRenamePrompt } = req.body;
|
|
738
|
+
const { repoPath, repoName, worktreePath, branchName, claudeArgs, yolo, agent, useTmux, cols, rows, needsBranchRename, branchRenamePrompt, ticketContext } = req.body;
|
|
556
739
|
if (!repoPath) {
|
|
557
740
|
res.status(400).json({ error: 'repoPath is required' });
|
|
558
741
|
return;
|
|
@@ -563,6 +746,57 @@ async function main() {
|
|
|
563
746
|
const resolved = resolveSessionSettings(config, repoPath, { agent, yolo, useTmux, claudeArgs });
|
|
564
747
|
const resolvedAgent = resolved.agent;
|
|
565
748
|
const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
749
|
+
let initialPrompt;
|
|
750
|
+
if (ticketContext && (typeof ticketContext.ticketId !== 'string' || typeof ticketContext.title !== 'string' || typeof ticketContext.url !== 'string')) {
|
|
751
|
+
res.status(400).json({ error: 'ticketContext requires string ticketId, title, and url' });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (ticketContext) {
|
|
755
|
+
// Validate source is a known integration
|
|
756
|
+
if (ticketContext.source !== 'github' && ticketContext.source !== 'jira' && ticketContext.source !== 'linear') {
|
|
757
|
+
res.status(400).json({ error: "ticketContext.source must be 'github', 'jira', or 'linear'" });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Validate repoPath is a configured workspace
|
|
761
|
+
const configuredWorkspaces = config.workspaces || [];
|
|
762
|
+
if (!configuredWorkspaces.includes(ticketContext.repoPath)) {
|
|
763
|
+
res.status(400).json({ error: 'ticketContext.repoPath is not a configured workspace' });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// Validate integration is configured for the claimed source
|
|
767
|
+
if (ticketContext.source === 'jira') {
|
|
768
|
+
if (!process.env['JIRA_API_TOKEN'] || !process.env['JIRA_EMAIL'] || !process.env['JIRA_BASE_URL']) {
|
|
769
|
+
res.status(400).json({ error: 'Jira integration is not configured' });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
else if (ticketContext.source === 'linear') {
|
|
774
|
+
if (!process.env['LINEAR_API_KEY']) {
|
|
775
|
+
res.status(400).json({ error: 'Linear integration is not configured' });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Validate ticket ID format per source
|
|
780
|
+
if (ticketContext.source === 'github' && !/^GH-\d+$/.test(ticketContext.ticketId)) {
|
|
781
|
+
res.status(400).json({ error: 'ticketContext.ticketId for github must match GH-<number>' });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if ((ticketContext.source === 'jira' || ticketContext.source === 'linear') && !/^[A-Z]+-\d+$/.test(ticketContext.ticketId)) {
|
|
785
|
+
res.status(400).json({ error: 'ticketContext.ticketId must match <PROJECT>-<number>' });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (ticketContext) {
|
|
790
|
+
// Use ticketContext.repoPath (workspace root) for settings lookup
|
|
791
|
+
const settings = config.workspaceSettings?.[ticketContext.repoPath];
|
|
792
|
+
const template = settings?.promptStartWork ??
|
|
793
|
+
'You are working on ticket {ticketId}: {title}\n\nTicket URL: {ticketUrl}\n\nPlease start by understanding the issue and proposing an approach.';
|
|
794
|
+
initialPrompt = template
|
|
795
|
+
.replace(/\{ticketId\}/g, ticketContext.ticketId)
|
|
796
|
+
.replace(/\{title\}/g, ticketContext.title)
|
|
797
|
+
.replace(/\{ticketUrl\}/g, ticketContext.url)
|
|
798
|
+
.replace(/\{description\}/g, ticketContext.description ?? '');
|
|
799
|
+
}
|
|
566
800
|
const baseArgs = [
|
|
567
801
|
...(resolved.claudeArgs),
|
|
568
802
|
...(resolved.yolo ? AGENT_YOLO_ARGS[resolvedAgent] : []),
|
|
@@ -674,7 +908,13 @@ async function main() {
|
|
|
674
908
|
useTmux: resolved.useTmux,
|
|
675
909
|
...(safeCols != null && { cols: safeCols }),
|
|
676
910
|
...(safeRows != null && { rows: safeRows }),
|
|
911
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
677
912
|
});
|
|
913
|
+
if (ticketContext) {
|
|
914
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
915
|
+
console.error('[index] transition on session create failed:', err);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
678
918
|
res.status(201).json(repoSession);
|
|
679
919
|
return;
|
|
680
920
|
}
|
|
@@ -700,6 +940,7 @@ async function main() {
|
|
|
700
940
|
useTmux: resolved.useTmux,
|
|
701
941
|
...(safeCols != null && { cols: safeCols }),
|
|
702
942
|
...(safeRows != null && { rows: safeRows }),
|
|
943
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
703
944
|
});
|
|
704
945
|
writeMeta(CONFIG_PATH, {
|
|
705
946
|
worktreePath: sessionRepoPath,
|
|
@@ -707,6 +948,11 @@ async function main() {
|
|
|
707
948
|
lastActivity: new Date().toISOString(),
|
|
708
949
|
branchName: branchName || worktreeName,
|
|
709
950
|
});
|
|
951
|
+
if (ticketContext) {
|
|
952
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
953
|
+
console.error('[index] transition on session create failed:', err);
|
|
954
|
+
});
|
|
955
|
+
}
|
|
710
956
|
res.status(201).json(session);
|
|
711
957
|
return;
|
|
712
958
|
}
|
|
@@ -747,6 +993,7 @@ async function main() {
|
|
|
747
993
|
...(safeRows != null && { rows: safeRows }),
|
|
748
994
|
needsBranchRename: isMountainName || (needsBranchRename ?? false),
|
|
749
995
|
branchRenamePrompt: branchRenamePrompt ?? '',
|
|
996
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
750
997
|
});
|
|
751
998
|
if (!worktreePath) {
|
|
752
999
|
writeMeta(CONFIG_PATH, {
|
|
@@ -756,6 +1003,11 @@ async function main() {
|
|
|
756
1003
|
branchName: branchName || worktreeName,
|
|
757
1004
|
});
|
|
758
1005
|
}
|
|
1006
|
+
if (ticketContext) {
|
|
1007
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
1008
|
+
console.error('[index] transition on session create failed:', err);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
759
1011
|
res.status(201).json(session);
|
|
760
1012
|
});
|
|
761
1013
|
// POST /sessions/repo — start a session in the repo root (no worktree)
|
|
@@ -945,8 +1197,10 @@ async function main() {
|
|
|
945
1197
|
catch {
|
|
946
1198
|
// tmux not installed or no sessions — ignore
|
|
947
1199
|
}
|
|
948
|
-
function gracefulShutdown() {
|
|
1200
|
+
async function gracefulShutdown() {
|
|
1201
|
+
await stopPolling();
|
|
949
1202
|
closeAnalytics();
|
|
1203
|
+
branchWatcher.close();
|
|
950
1204
|
server.close();
|
|
951
1205
|
// Serialize sessions to disk BEFORE killing them
|
|
952
1206
|
const configDir = path.dirname(CONFIG_PATH);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GH_TIMEOUT_MS = 10_000;
|
|
8
|
+
const CACHE_TTL_MS = 60_000;
|
|
9
|
+
/**
|
|
10
|
+
* Creates and returns an Express Router that handles all /integration-github routes.
|
|
11
|
+
*
|
|
12
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
13
|
+
* app.use('/integration-github', requireAuth, createIntegrationGitHubRouter({ configPath }));
|
|
14
|
+
*/
|
|
15
|
+
export function createIntegrationGitHubRouter(deps) {
|
|
16
|
+
const { configPath } = deps;
|
|
17
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
18
|
+
const router = Router();
|
|
19
|
+
// Per-repo 60s in-memory cache
|
|
20
|
+
const repoCache = new Map();
|
|
21
|
+
function getConfig() {
|
|
22
|
+
return loadConfig(configPath);
|
|
23
|
+
}
|
|
24
|
+
// GET /integrations/github/issues — list open issues assigned to @me across all workspaces
|
|
25
|
+
router.get('/issues', async (_req, res) => {
|
|
26
|
+
const config = getConfig();
|
|
27
|
+
const workspacePaths = config.workspaces ?? [];
|
|
28
|
+
if (workspacePaths.length === 0) {
|
|
29
|
+
const response = { issues: [], error: 'no_workspaces' };
|
|
30
|
+
res.json(response);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
// Fetch issues per repo using Promise.allSettled (partial failures are non-fatal)
|
|
35
|
+
const results = await Promise.allSettled(workspacePaths.map(async (wsPath) => {
|
|
36
|
+
// Return cached result if still fresh
|
|
37
|
+
const cached = repoCache.get(wsPath);
|
|
38
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
39
|
+
return cached.issues;
|
|
40
|
+
}
|
|
41
|
+
let stdout;
|
|
42
|
+
try {
|
|
43
|
+
({ stdout } = await exec('gh', [
|
|
44
|
+
'issue', 'list',
|
|
45
|
+
'--assignee', '@me',
|
|
46
|
+
'--state', 'open',
|
|
47
|
+
'--json', 'number,title,url,state,labels,assignees,createdAt,updatedAt',
|
|
48
|
+
'--limit', '50',
|
|
49
|
+
], { cwd: wsPath, timeout: GH_TIMEOUT_MS }));
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const errCode = err.code;
|
|
53
|
+
if (errCode === 'ENOENT') {
|
|
54
|
+
throw Object.assign(new Error('gh_not_in_path'), { code: 'GH_NOT_IN_PATH' });
|
|
55
|
+
}
|
|
56
|
+
// Check for auth failure via stderr
|
|
57
|
+
const stderr = err.stderr ?? '';
|
|
58
|
+
if (stderr.includes('not logged') || stderr.includes('auth') || stderr.includes('authentication')) {
|
|
59
|
+
throw Object.assign(new Error('gh_not_authenticated'), { code: 'GH_NOT_AUTHENTICATED' });
|
|
60
|
+
}
|
|
61
|
+
// Not a github repo or other non-fatal error
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
let items;
|
|
65
|
+
try {
|
|
66
|
+
items = JSON.parse(stdout);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const repoName = path.basename(wsPath);
|
|
72
|
+
const issues = items.map((item) => ({
|
|
73
|
+
number: item.number,
|
|
74
|
+
title: item.title,
|
|
75
|
+
url: item.url,
|
|
76
|
+
state: item.state === 'OPEN' ? 'OPEN' : 'CLOSED',
|
|
77
|
+
labels: item.labels,
|
|
78
|
+
assignees: item.assignees,
|
|
79
|
+
createdAt: item.createdAt,
|
|
80
|
+
updatedAt: item.updatedAt,
|
|
81
|
+
repoName,
|
|
82
|
+
repoPath: wsPath,
|
|
83
|
+
}));
|
|
84
|
+
// Update per-repo cache
|
|
85
|
+
repoCache.set(wsPath, { issues, fetchedAt: now });
|
|
86
|
+
return issues;
|
|
87
|
+
}));
|
|
88
|
+
// Check if gh is not in path or not authenticated (any settled rejection with known codes)
|
|
89
|
+
for (const result of results) {
|
|
90
|
+
if (result.status === 'rejected') {
|
|
91
|
+
const err = result.reason;
|
|
92
|
+
if (err.code === 'GH_NOT_IN_PATH') {
|
|
93
|
+
const response = { issues: [], error: 'gh_not_in_path' };
|
|
94
|
+
res.json(response);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (err.code === 'GH_NOT_AUTHENTICATED') {
|
|
98
|
+
const response = { issues: [], error: 'gh_not_authenticated' };
|
|
99
|
+
res.json(response);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Merge all fulfilled results
|
|
105
|
+
const allIssues = [];
|
|
106
|
+
for (const result of results) {
|
|
107
|
+
if (result.status === 'fulfilled') {
|
|
108
|
+
allIssues.push(...result.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Sort by updatedAt descending
|
|
112
|
+
allIssues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
113
|
+
const response = { issues: allIssues };
|
|
114
|
+
res.json(response);
|
|
115
|
+
});
|
|
116
|
+
return router;
|
|
117
|
+
}
|