claude-remote-cli 3.9.5 → 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 +201 -2
- 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/test/branch-linker.test.js +231 -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
|
@@ -20,7 +20,14 @@ 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);
|
|
@@ -269,6 +276,45 @@ async function main() {
|
|
|
269
276
|
// Mount workspace router
|
|
270
277
|
const workspaceRouter = createWorkspaceRouter({ configPath: CONFIG_PATH });
|
|
271
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);
|
|
272
318
|
// Mount analytics router
|
|
273
319
|
app.use('/analytics', requireAuth, createAnalyticsRouter(configDir));
|
|
274
320
|
// Restore sessions from a previous update restart
|
|
@@ -278,6 +324,45 @@ async function main() {
|
|
|
278
324
|
}
|
|
279
325
|
// Populate session metadata cache in background (non-blocking)
|
|
280
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(); });
|
|
281
366
|
// Push notifications on session idle (skip when hooks already sent attention notification)
|
|
282
367
|
sessions.onIdleChange((sessionId, idle) => {
|
|
283
368
|
if (idle) {
|
|
@@ -509,6 +594,50 @@ async function main() {
|
|
|
509
594
|
await execFileAsync('tmux', ['-V']);
|
|
510
595
|
});
|
|
511
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
|
+
});
|
|
512
641
|
// GET /push/vapid-key
|
|
513
642
|
app.get('/push/vapid-key', requireAuth, (_req, res) => {
|
|
514
643
|
const key = push.getVapidPublicKey();
|
|
@@ -606,7 +735,7 @@ async function main() {
|
|
|
606
735
|
});
|
|
607
736
|
// POST /sessions
|
|
608
737
|
app.post('/sessions', requireAuth, async (req, res) => {
|
|
609
|
-
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;
|
|
610
739
|
if (!repoPath) {
|
|
611
740
|
res.status(400).json({ error: 'repoPath is required' });
|
|
612
741
|
return;
|
|
@@ -617,6 +746,57 @@ async function main() {
|
|
|
617
746
|
const resolved = resolveSessionSettings(config, repoPath, { agent, yolo, useTmux, claudeArgs });
|
|
618
747
|
const resolvedAgent = resolved.agent;
|
|
619
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
|
+
}
|
|
620
800
|
const baseArgs = [
|
|
621
801
|
...(resolved.claudeArgs),
|
|
622
802
|
...(resolved.yolo ? AGENT_YOLO_ARGS[resolvedAgent] : []),
|
|
@@ -728,7 +908,13 @@ async function main() {
|
|
|
728
908
|
useTmux: resolved.useTmux,
|
|
729
909
|
...(safeCols != null && { cols: safeCols }),
|
|
730
910
|
...(safeRows != null && { rows: safeRows }),
|
|
911
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
731
912
|
});
|
|
913
|
+
if (ticketContext) {
|
|
914
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
915
|
+
console.error('[index] transition on session create failed:', err);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
732
918
|
res.status(201).json(repoSession);
|
|
733
919
|
return;
|
|
734
920
|
}
|
|
@@ -754,6 +940,7 @@ async function main() {
|
|
|
754
940
|
useTmux: resolved.useTmux,
|
|
755
941
|
...(safeCols != null && { cols: safeCols }),
|
|
756
942
|
...(safeRows != null && { rows: safeRows }),
|
|
943
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
757
944
|
});
|
|
758
945
|
writeMeta(CONFIG_PATH, {
|
|
759
946
|
worktreePath: sessionRepoPath,
|
|
@@ -761,6 +948,11 @@ async function main() {
|
|
|
761
948
|
lastActivity: new Date().toISOString(),
|
|
762
949
|
branchName: branchName || worktreeName,
|
|
763
950
|
});
|
|
951
|
+
if (ticketContext) {
|
|
952
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
953
|
+
console.error('[index] transition on session create failed:', err);
|
|
954
|
+
});
|
|
955
|
+
}
|
|
764
956
|
res.status(201).json(session);
|
|
765
957
|
return;
|
|
766
958
|
}
|
|
@@ -801,6 +993,7 @@ async function main() {
|
|
|
801
993
|
...(safeRows != null && { rows: safeRows }),
|
|
802
994
|
needsBranchRename: isMountainName || (needsBranchRename ?? false),
|
|
803
995
|
branchRenamePrompt: branchRenamePrompt ?? '',
|
|
996
|
+
...(initialPrompt != null && { initialPrompt }),
|
|
804
997
|
});
|
|
805
998
|
if (!worktreePath) {
|
|
806
999
|
writeMeta(CONFIG_PATH, {
|
|
@@ -810,6 +1003,11 @@ async function main() {
|
|
|
810
1003
|
branchName: branchName || worktreeName,
|
|
811
1004
|
});
|
|
812
1005
|
}
|
|
1006
|
+
if (ticketContext) {
|
|
1007
|
+
transitionOnSessionCreate(ticketContext).catch((err) => {
|
|
1008
|
+
console.error('[index] transition on session create failed:', err);
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
813
1011
|
res.status(201).json(session);
|
|
814
1012
|
});
|
|
815
1013
|
// POST /sessions/repo — start a session in the repo root (no worktree)
|
|
@@ -999,7 +1197,8 @@ async function main() {
|
|
|
999
1197
|
catch {
|
|
1000
1198
|
// tmux not installed or no sessions — ignore
|
|
1001
1199
|
}
|
|
1002
|
-
function gracefulShutdown() {
|
|
1200
|
+
async function gracefulShutdown() {
|
|
1201
|
+
await stopPolling();
|
|
1003
1202
|
closeAnalytics();
|
|
1004
1203
|
branchWatcher.close();
|
|
1005
1204
|
server.close();
|
|
@@ -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
|
+
}
|