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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GH_TIMEOUT_MS = 15_000;
|
|
8
|
+
const DEFAULT_POLL_INTERVAL_MS = 300_000; // 5 minutes
|
|
9
|
+
// ─── Module state ─────────────────────────────────────────────────────────────
|
|
10
|
+
let timer = null;
|
|
11
|
+
let ghMissingWarned = false;
|
|
12
|
+
let pollInFlight = false;
|
|
13
|
+
let activePollPromise = null;
|
|
14
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
15
|
+
export function startPolling(deps) {
|
|
16
|
+
if (timer !== null)
|
|
17
|
+
return;
|
|
18
|
+
const config = loadConfig(deps.configPath);
|
|
19
|
+
const intervalMs = config.automations?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
20
|
+
activePollPromise = pollOnce(deps);
|
|
21
|
+
activePollPromise.finally(() => { activePollPromise = null; });
|
|
22
|
+
timer = setInterval(() => {
|
|
23
|
+
activePollPromise = pollOnce(deps);
|
|
24
|
+
activePollPromise.finally(() => { activePollPromise = null; });
|
|
25
|
+
}, intervalMs);
|
|
26
|
+
}
|
|
27
|
+
export async function stopPolling() {
|
|
28
|
+
if (timer !== null) {
|
|
29
|
+
clearInterval(timer);
|
|
30
|
+
timer = null;
|
|
31
|
+
}
|
|
32
|
+
if (activePollPromise) {
|
|
33
|
+
await activePollPromise;
|
|
34
|
+
activePollPromise = null;
|
|
35
|
+
}
|
|
36
|
+
ghMissingWarned = false;
|
|
37
|
+
}
|
|
38
|
+
export function isPolling() {
|
|
39
|
+
return timer !== null;
|
|
40
|
+
}
|
|
41
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* Extracts "owner/repo" from a git remote URL.
|
|
44
|
+
* Handles SSH (git@github.com:owner/repo.git) and HTTPS forms.
|
|
45
|
+
*/
|
|
46
|
+
function extractOwnerRepo(remoteUrl) {
|
|
47
|
+
const sshMatch = remoteUrl.match(/git@[^:]+:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
48
|
+
if (sshMatch)
|
|
49
|
+
return sshMatch[1] ?? null;
|
|
50
|
+
const httpsMatch = remoteUrl.match(/https?:\/\/[^/]+\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
51
|
+
if (httpsMatch)
|
|
52
|
+
return httpsMatch[1] ?? null;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** Extracts the PR number from a GitHub API URL like .../pulls/123 */
|
|
56
|
+
function extractPrNumber(subjectUrl) {
|
|
57
|
+
const match = subjectUrl.match(/\/pulls\/(\d+)$/);
|
|
58
|
+
if (!match)
|
|
59
|
+
return null;
|
|
60
|
+
const num = parseInt(match[1] ?? '', 10);
|
|
61
|
+
return isNaN(num) ? null : num;
|
|
62
|
+
}
|
|
63
|
+
/** Returns the workspace path whose git remote matches the given owner/repo, or null. */
|
|
64
|
+
async function findWorkspaceForRepo(ownerRepo, workspacePaths, exec) {
|
|
65
|
+
for (const workspacePath of workspacePaths) {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await exec('git', ['remote', 'get-url', 'origin'], {
|
|
68
|
+
cwd: workspacePath,
|
|
69
|
+
timeout: GH_TIMEOUT_MS,
|
|
70
|
+
});
|
|
71
|
+
const remoteOwnerRepo = extractOwnerRepo(stdout.trim());
|
|
72
|
+
if (remoteOwnerRepo && remoteOwnerRepo.toLowerCase() === ownerRepo.toLowerCase()) {
|
|
73
|
+
return workspacePath;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// Not a git repo, no remote, or timed out — skip this workspace
|
|
78
|
+
const error = err;
|
|
79
|
+
if (error.code && error.code !== 'ENOENT') {
|
|
80
|
+
console.warn(`[review-poller] Error checking remote for ${workspacePath}:`, error.message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// ─── Core poll logic ──────────────────────────────────────────────────────────
|
|
87
|
+
async function pollOnce(deps) {
|
|
88
|
+
if (pollInFlight)
|
|
89
|
+
return;
|
|
90
|
+
pollInFlight = true;
|
|
91
|
+
try {
|
|
92
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
93
|
+
let config;
|
|
94
|
+
try {
|
|
95
|
+
config = loadConfig(deps.configPath);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.warn('[review-poller] Failed to load config:', err);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!config.automations?.autoCheckoutReviewRequests)
|
|
102
|
+
return;
|
|
103
|
+
// Capture poll-start time as watermark — avoids gap where notifications
|
|
104
|
+
// arriving between fetch and save would be skipped permanently
|
|
105
|
+
const pollStartTimestamp = new Date().toISOString();
|
|
106
|
+
// First run: default to "now" so we skip all historical notifications.
|
|
107
|
+
// The first poll cycle always produces zero checkouts — only notifications
|
|
108
|
+
// arriving after this timestamp will be processed.
|
|
109
|
+
const lastPollTimestamp = config.automations?.lastPollTimestamp ?? new Date().toISOString();
|
|
110
|
+
// Fetch review_requested notifications from GitHub
|
|
111
|
+
let notifications;
|
|
112
|
+
try {
|
|
113
|
+
const { stdout } = await exec('gh', [
|
|
114
|
+
'api',
|
|
115
|
+
'/notifications',
|
|
116
|
+
'--jq',
|
|
117
|
+
'.[] | select(.reason == "review_requested") | {id, reason, subject, repository, updated_at}',
|
|
118
|
+
], { timeout: GH_TIMEOUT_MS });
|
|
119
|
+
// gh --jq with select returns newline-delimited JSON objects
|
|
120
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
121
|
+
notifications = [];
|
|
122
|
+
let parseFailures = 0;
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
try {
|
|
125
|
+
notifications.push(JSON.parse(line));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
parseFailures++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (parseFailures > 0 && notifications.length === 0) {
|
|
132
|
+
console.warn(`[review-poller] All ${parseFailures} notification lines failed to parse — gh output format may have changed`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const error = err;
|
|
137
|
+
if (error.code === 'ENOENT') {
|
|
138
|
+
if (!ghMissingWarned) {
|
|
139
|
+
console.warn('[review-poller] gh CLI not found — stopping poller');
|
|
140
|
+
ghMissingWarned = true;
|
|
141
|
+
}
|
|
142
|
+
void stopPolling();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (error.killed) {
|
|
146
|
+
console.warn('[review-poller] gh notifications timed out, skipping cycle');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Auth failures and other gh errors come through stderr in the error message
|
|
150
|
+
console.warn('[review-poller] gh notifications failed, skipping cycle:', error.message);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Filter to notifications newer than the last poll
|
|
154
|
+
const newNotifications = notifications.filter((n) => new Date(n.updated_at) > new Date(lastPollTimestamp));
|
|
155
|
+
const workspacePaths = deps.getWorkspacePaths();
|
|
156
|
+
for (const notification of newNotifications) {
|
|
157
|
+
if (notification.subject.type !== 'PullRequest')
|
|
158
|
+
continue;
|
|
159
|
+
const prNumber = extractPrNumber(notification.subject.url);
|
|
160
|
+
if (prNumber === null) {
|
|
161
|
+
console.warn('[review-poller] Could not extract PR number from:', notification.subject.url);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const ownerRepo = notification.repository.full_name;
|
|
165
|
+
let workspacePath;
|
|
166
|
+
try {
|
|
167
|
+
workspacePath = await findWorkspaceForRepo(ownerRepo, workspacePaths, exec);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
console.warn('[review-poller] Error finding workspace for', ownerRepo, ':', err);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (workspacePath === null) {
|
|
174
|
+
// No local workspace for this repo — skip silently
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const localBranch = `review-pr-${prNumber}`;
|
|
178
|
+
const worktreePath = path.join(workspacePath, '.worktrees', localBranch);
|
|
179
|
+
// Skip if worktree already exists (e.g., from a previous poll)
|
|
180
|
+
if (fs.existsSync(worktreePath)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// Fetch the PR's head ref into a local branch, then create worktree from it
|
|
184
|
+
try {
|
|
185
|
+
await exec('git', ['fetch', 'origin', `pull/${prNumber}/head:${localBranch}`], { cwd: workspacePath, timeout: GH_TIMEOUT_MS });
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
// Branch may already exist from a prior fetch — continue to worktree add
|
|
189
|
+
const errMsg = err.message ?? '';
|
|
190
|
+
if (!errMsg.includes('already exists')) {
|
|
191
|
+
console.warn(`[review-poller] Failed to fetch PR #${prNumber}:`, err);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await exec('git', ['worktree', 'add', worktreePath, localBranch], { cwd: workspacePath, timeout: GH_TIMEOUT_MS });
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
console.warn(`[review-poller] Failed to create worktree for PR #${prNumber}:`, err);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// Optionally start a review session
|
|
203
|
+
const settings = deps.getWorkspaceSettings(workspacePath);
|
|
204
|
+
if (config.automations?.autoReviewOnCheckout && settings?.promptCodeReview) {
|
|
205
|
+
try {
|
|
206
|
+
await deps.createSession({
|
|
207
|
+
repoPath: workspacePath,
|
|
208
|
+
worktreePath,
|
|
209
|
+
branchName: localBranch,
|
|
210
|
+
initialPrompt: settings.promptCodeReview,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
console.warn(`[review-poller] Failed to create review session for PR #${prNumber}:`, err);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
deps.broadcastEvent('review-checkout', {
|
|
218
|
+
prNumber,
|
|
219
|
+
ownerRepo,
|
|
220
|
+
worktreePath,
|
|
221
|
+
branchName: localBranch,
|
|
222
|
+
title: notification.subject.title,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// Update lastPollTimestamp — re-read config to avoid overwriting concurrent changes
|
|
226
|
+
try {
|
|
227
|
+
const freshConfig = loadConfig(deps.configPath);
|
|
228
|
+
freshConfig.automations = {
|
|
229
|
+
...freshConfig.automations,
|
|
230
|
+
lastPollTimestamp: pollStartTimestamp,
|
|
231
|
+
};
|
|
232
|
+
saveConfig(deps.configPath, freshConfig);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
console.warn('[review-poller] Failed to save config after poll:', err);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
pollInFlight = false;
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/server/sessions.js
CHANGED
|
@@ -30,6 +30,20 @@ const stateChangeCallbacks = [];
|
|
|
30
30
|
function onStateChange(cb) {
|
|
31
31
|
stateChangeCallbacks.push(cb);
|
|
32
32
|
}
|
|
33
|
+
const sessionCreateCallbacks = [];
|
|
34
|
+
function onSessionCreate(cb) {
|
|
35
|
+
sessionCreateCallbacks.push(cb);
|
|
36
|
+
}
|
|
37
|
+
function fireSessionCreate(sessionId, repoPath, branchName) {
|
|
38
|
+
for (const cb of sessionCreateCallbacks) {
|
|
39
|
+
try {
|
|
40
|
+
cb(sessionId, repoPath, branchName);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.error('[sessions] sessionCreate callback error:', err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
33
47
|
const sessionEndCallbacks = [];
|
|
34
48
|
function onSessionEnd(cb) {
|
|
35
49
|
sessionEndCallbacks.push(cb);
|
|
@@ -45,10 +59,10 @@ function fireSessionEnd(sessionId, repoPath, branchName) {
|
|
|
45
59
|
}
|
|
46
60
|
}
|
|
47
61
|
export function fireStateChange(sessionId, state) {
|
|
48
|
-
for (const cb of stateChangeCallbacks)
|
|
62
|
+
for (const cb of [...stateChangeCallbacks])
|
|
49
63
|
cb(sessionId, state);
|
|
50
64
|
}
|
|
51
|
-
function create({ id: providedId, needsBranchRename, branchRenamePrompt, agent = 'claude', cols = 80, rows = 24, args = [], port, forceOutputParser, ...rest }) {
|
|
65
|
+
function create({ id: providedId, needsBranchRename, branchRenamePrompt, initialPrompt, agent = 'claude', cols = 80, rows = 24, args = [], port, forceOutputParser, ...rest }) {
|
|
52
66
|
const id = providedId || crypto.randomBytes(8).toString('hex');
|
|
53
67
|
const ptyParams = {
|
|
54
68
|
...rest,
|
|
@@ -79,6 +93,32 @@ function create({ id: providedId, needsBranchRename, branchRenamePrompt, agent =
|
|
|
79
93
|
if (branchRenamePrompt) {
|
|
80
94
|
ptySession.branchRenamePrompt = branchRenamePrompt;
|
|
81
95
|
}
|
|
96
|
+
if (initialPrompt) {
|
|
97
|
+
ptySession.initialPrompt = initialPrompt;
|
|
98
|
+
}
|
|
99
|
+
fireSessionCreate(id, ptySession.repoPath, ptySession.branchName);
|
|
100
|
+
if (initialPrompt) {
|
|
101
|
+
const promptHandler = (changedId, state) => {
|
|
102
|
+
if (changedId === id && state === 'waiting-for-input' && ptySession.initialPrompt) {
|
|
103
|
+
const prompt = ptySession.initialPrompt;
|
|
104
|
+
ptySession.initialPrompt = undefined; // one-shot
|
|
105
|
+
// Small delay to ensure the agent's input handler is ready
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
try {
|
|
108
|
+
ptySession.pty.write(prompt + '\n');
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error('[sessions] Failed to inject initial prompt:', err);
|
|
112
|
+
}
|
|
113
|
+
}, 500);
|
|
114
|
+
// Remove this handler after firing
|
|
115
|
+
const idx = stateChangeCallbacks.indexOf(promptHandler);
|
|
116
|
+
if (idx !== -1)
|
|
117
|
+
stateChangeCallbacks.splice(idx, 1);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
stateChangeCallbacks.push(promptHandler);
|
|
121
|
+
}
|
|
82
122
|
return { ...result, needsBranchRename: !!ptySession.needsBranchRename };
|
|
83
123
|
}
|
|
84
124
|
function get(id) {
|
|
@@ -391,4 +431,4 @@ async function populateMetaCache() {
|
|
|
391
431
|
}
|
|
392
432
|
}));
|
|
393
433
|
}
|
|
394
|
-
export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionEnd, findRepoSession, nextTerminalName, nextAgentName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
|
|
434
|
+
export { configure, create, get, list, kill, killAllTmuxSessions, resize, updateDisplayName, write, onIdleChange, onStateChange, onSessionCreate, onSessionEnd, findRepoSession, nextTerminalName, nextAgentName, serializeAll, restoreFromDisk, activeTmuxSessionNames, getSessionMeta, getAllSessionMeta, populateMetaCache, AGENT_COMMANDS, AGENT_CONTINUE_ARGS, AGENT_YOLO_ARGS };
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const GH_TIMEOUT_MS = 10_000;
|
|
7
|
+
function ghIssueNumber(ticketId) {
|
|
8
|
+
const match = ticketId.match(/^GH-(\d+)$/i);
|
|
9
|
+
return match ? match[1] : null;
|
|
10
|
+
}
|
|
11
|
+
async function addLabel(exec, repoPath, issueNumber, label) {
|
|
12
|
+
try {
|
|
13
|
+
await exec('gh', ['issue', 'edit', issueNumber, '--add-label', label], {
|
|
14
|
+
cwd: repoPath,
|
|
15
|
+
timeout: GH_TIMEOUT_MS,
|
|
16
|
+
});
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error(`[ticket-transitions] Failed to add label "${label}" to #${issueNumber}:`, err);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function removeLabel(exec, repoPath, issueNumber, label) {
|
|
25
|
+
try {
|
|
26
|
+
await exec('gh', ['issue', 'edit', issueNumber, '--remove-label', label], {
|
|
27
|
+
cwd: repoPath,
|
|
28
|
+
timeout: GH_TIMEOUT_MS,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Label may not exist — non-fatal
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Returns true if the URL is safe to use as a Jira base URL. */
|
|
36
|
+
function isValidJiraUrl(url) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(url);
|
|
39
|
+
if (parsed.protocol === 'https:')
|
|
40
|
+
return true;
|
|
41
|
+
if (parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'))
|
|
42
|
+
return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Call a Jira transition by ID. Returns true on success, false on failure. */
|
|
50
|
+
async function jiraTransition(ticketId, transitionId) {
|
|
51
|
+
const baseUrl = process.env.JIRA_BASE_URL;
|
|
52
|
+
const email = process.env.JIRA_EMAIL;
|
|
53
|
+
const token = process.env.JIRA_API_TOKEN;
|
|
54
|
+
if (!baseUrl || !email || !token)
|
|
55
|
+
return false;
|
|
56
|
+
if (!isValidJiraUrl(baseUrl)) {
|
|
57
|
+
console.warn(`[ticket-transitions] JIRA_BASE_URL failed validation, skipping transition for ${ticketId}`);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(ticketId)}/transitions`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'Authorization': `Basic ${Buffer.from(email + ':' + token).toString('base64')}`,
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({ transition: { id: transitionId } }),
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
console.error(`[ticket-transitions] Jira transition returned ${res.status} for ${ticketId}`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error(`[ticket-transitions] Jira transition failed for ${ticketId}:`, err);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Update a Linear issue state. Returns true on success, false on failure. */
|
|
81
|
+
async function linearStateUpdate(ticketIdentifier, stateId) {
|
|
82
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
83
|
+
if (!apiKey)
|
|
84
|
+
return false;
|
|
85
|
+
// Linear mutations need the issue ID, but we only have the identifier (e.g. "TEAM-123").
|
|
86
|
+
// Resolve the issue ID by identifier, then update state.
|
|
87
|
+
try {
|
|
88
|
+
const searchRes = await fetch('https://api.linear.app/graphql', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
query: `query($filter: IssueFilter) { issues(filter: $filter, first: 1) { nodes { id } } }`,
|
|
93
|
+
variables: { filter: { identifier: { eq: ticketIdentifier } } },
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
if (!searchRes.ok) {
|
|
97
|
+
console.error(`[ticket-transitions] Linear issue lookup returned ${searchRes.status} for ${ticketIdentifier}`);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const searchData = (await searchRes.json());
|
|
101
|
+
const issueId = searchData.data?.issues?.nodes?.[0]?.id;
|
|
102
|
+
if (!issueId)
|
|
103
|
+
return false;
|
|
104
|
+
const updateRes = await fetch('https://api.linear.app/graphql', {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
query: `mutation($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`,
|
|
109
|
+
variables: { id: issueId, stateId },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
if (!updateRes.ok) {
|
|
113
|
+
console.error(`[ticket-transitions] Linear state update returned ${updateRes.status} for ${ticketIdentifier}`);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.error(`[ticket-transitions] Linear state update failed for ${ticketIdentifier}:`, err);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort source detection from a ticket ID pattern.
|
|
125
|
+
* Known limitation: when both Jira and Linear are configured, non-GH tickets
|
|
126
|
+
* are matched by whichever env var is present. This is imperfect — a future
|
|
127
|
+
* improvement would persist the source alongside branch links.
|
|
128
|
+
*/
|
|
129
|
+
function detectTicketSource(ticketId, links) {
|
|
130
|
+
// Use explicit source from branch link if available
|
|
131
|
+
if (links) {
|
|
132
|
+
const linkWithSource = links.find((l) => l.source);
|
|
133
|
+
if (linkWithSource?.source)
|
|
134
|
+
return linkWithSource.source;
|
|
135
|
+
}
|
|
136
|
+
if (ticketId.startsWith('GH-'))
|
|
137
|
+
return 'github';
|
|
138
|
+
// Prefer Jira for PROJECT-style keys (>= 3 uppercase letters before dash)
|
|
139
|
+
// since Jira project keys are typically longer than Linear team keys (2-3 chars).
|
|
140
|
+
const prefix = ticketId.split('-')[0] ?? '';
|
|
141
|
+
if (prefix.length >= 3 && process.env.JIRA_API_TOKEN)
|
|
142
|
+
return 'jira';
|
|
143
|
+
if (process.env.LINEAR_API_KEY)
|
|
144
|
+
return 'linear';
|
|
145
|
+
if (process.env.JIRA_API_TOKEN)
|
|
146
|
+
return 'jira';
|
|
147
|
+
return 'github'; // fallback
|
|
148
|
+
}
|
|
149
|
+
export function createTicketTransitionsRouter(deps) {
|
|
150
|
+
// In-memory idempotency guard: ticketId -> last transitioned state
|
|
151
|
+
const transitionMap = new Map();
|
|
152
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
153
|
+
const { configPath } = deps;
|
|
154
|
+
const router = Router();
|
|
155
|
+
/** Get status mapping for a transition state from config */
|
|
156
|
+
function getStatusMapping(config, source, state) {
|
|
157
|
+
if (source === 'jira')
|
|
158
|
+
return config.integrations?.jira?.statusMappings?.[state];
|
|
159
|
+
return config.integrations?.linear?.statusMappings?.[state];
|
|
160
|
+
}
|
|
161
|
+
async function transitionOnSessionCreate(ctx) {
|
|
162
|
+
const current = transitionMap.get(ctx.ticketId);
|
|
163
|
+
if (current && current !== 'none')
|
|
164
|
+
return;
|
|
165
|
+
if (ctx.source === 'github') {
|
|
166
|
+
const issueNum = ghIssueNumber(ctx.ticketId);
|
|
167
|
+
if (!issueNum)
|
|
168
|
+
return;
|
|
169
|
+
const ok = await addLabel(exec, ctx.repoPath, issueNum, 'in-progress');
|
|
170
|
+
if (ok)
|
|
171
|
+
transitionMap.set(ctx.ticketId, 'in-progress');
|
|
172
|
+
}
|
|
173
|
+
else if (ctx.source === 'jira') {
|
|
174
|
+
const config = loadConfig(configPath);
|
|
175
|
+
const transitionId = getStatusMapping(config, 'jira', 'in-progress');
|
|
176
|
+
if (transitionId) {
|
|
177
|
+
const ok = await jiraTransition(ctx.ticketId, transitionId);
|
|
178
|
+
if (ok)
|
|
179
|
+
transitionMap.set(ctx.ticketId, 'in-progress');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (ctx.source === 'linear') {
|
|
183
|
+
const config = loadConfig(configPath);
|
|
184
|
+
const stateId = getStatusMapping(config, 'linear', 'in-progress');
|
|
185
|
+
if (stateId) {
|
|
186
|
+
const ok = await linearStateUpdate(ctx.ticketId, stateId);
|
|
187
|
+
if (ok)
|
|
188
|
+
transitionMap.set(ctx.ticketId, 'in-progress');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function checkPrTransitions(prs, branchLinks) {
|
|
193
|
+
const config = loadConfig(configPath);
|
|
194
|
+
for (const pr of prs) {
|
|
195
|
+
for (const [ticketId, links] of Object.entries(branchLinks)) {
|
|
196
|
+
const linked = links.some((l) => l.branchName === pr.headRefName);
|
|
197
|
+
if (!linked)
|
|
198
|
+
continue;
|
|
199
|
+
const current = transitionMap.get(ticketId);
|
|
200
|
+
const source = detectTicketSource(ticketId, links);
|
|
201
|
+
if (pr.state === 'OPEN' && current !== 'code-review' && current !== 'ready-for-qa') {
|
|
202
|
+
if (source === 'github') {
|
|
203
|
+
const issueNum = ghIssueNumber(ticketId);
|
|
204
|
+
if (!issueNum)
|
|
205
|
+
continue;
|
|
206
|
+
const repoPath = links[0]?.repoPath;
|
|
207
|
+
if (!repoPath)
|
|
208
|
+
continue;
|
|
209
|
+
await removeLabel(exec, repoPath, issueNum, 'in-progress');
|
|
210
|
+
const ok = await addLabel(exec, repoPath, issueNum, 'code-review');
|
|
211
|
+
if (ok)
|
|
212
|
+
transitionMap.set(ticketId, 'code-review');
|
|
213
|
+
}
|
|
214
|
+
else if (source === 'jira') {
|
|
215
|
+
const transitionId = getStatusMapping(config, 'jira', 'code-review');
|
|
216
|
+
if (transitionId) {
|
|
217
|
+
const ok = await jiraTransition(ticketId, transitionId);
|
|
218
|
+
if (ok)
|
|
219
|
+
transitionMap.set(ticketId, 'code-review');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (source === 'linear') {
|
|
223
|
+
const stateId = getStatusMapping(config, 'linear', 'code-review');
|
|
224
|
+
if (stateId) {
|
|
225
|
+
const ok = await linearStateUpdate(ticketId, stateId);
|
|
226
|
+
if (ok)
|
|
227
|
+
transitionMap.set(ticketId, 'code-review');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (pr.state === 'MERGED' && current !== 'ready-for-qa') {
|
|
232
|
+
if (source === 'github') {
|
|
233
|
+
const issueNum = ghIssueNumber(ticketId);
|
|
234
|
+
if (!issueNum)
|
|
235
|
+
continue;
|
|
236
|
+
const repoPath = links[0]?.repoPath;
|
|
237
|
+
if (!repoPath)
|
|
238
|
+
continue;
|
|
239
|
+
await removeLabel(exec, repoPath, issueNum, 'code-review');
|
|
240
|
+
const ok = await addLabel(exec, repoPath, issueNum, 'ready-for-qa');
|
|
241
|
+
if (ok)
|
|
242
|
+
transitionMap.set(ticketId, 'ready-for-qa');
|
|
243
|
+
}
|
|
244
|
+
else if (source === 'jira') {
|
|
245
|
+
const transitionId = getStatusMapping(config, 'jira', 'ready-for-qa');
|
|
246
|
+
if (transitionId) {
|
|
247
|
+
const ok = await jiraTransition(ticketId, transitionId);
|
|
248
|
+
if (ok)
|
|
249
|
+
transitionMap.set(ticketId, 'ready-for-qa');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (source === 'linear') {
|
|
253
|
+
const stateId = getStatusMapping(config, 'linear', 'ready-for-qa');
|
|
254
|
+
if (stateId) {
|
|
255
|
+
const ok = await linearStateUpdate(ticketId, stateId);
|
|
256
|
+
if (ok)
|
|
257
|
+
transitionMap.set(ticketId, 'ready-for-qa');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { router, transitionOnSessionCreate, checkPrTransitions };
|
|
265
|
+
}
|