claude-remote-cli 3.10.0 → 3.11.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-B7wmLeyf.js +52 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server/branch-linker.js +3 -5
- package/dist/server/index.js +5 -20
- package/dist/server/integration-jira.js +103 -108
- package/dist/server/ticket-transitions.js +17 -129
- package/dist/test/integration-jira.test.js +133 -214
- package/dist/test/ticket-transitions.test.js +52 -257
- package/package.json +1 -1
- package/dist/frontend/assets/index-Dgf6cKGu.js +0 -52
- package/dist/server/integration-linear.js +0 -176
- package/dist/test/integration-linear.test.js +0 -293
package/dist/frontend/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-B7wmLeyf.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-BTOnhJQN.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -17,7 +17,7 @@ export function invalidateBranchLinkerCache() {
|
|
|
17
17
|
*/
|
|
18
18
|
function extractTicketIds(branchName) {
|
|
19
19
|
const ids = [];
|
|
20
|
-
// Jira
|
|
20
|
+
// Jira style: PROJECT-123 (2+ uppercase letters, dash, digits)
|
|
21
21
|
// Skip "GH" prefix — that's our GitHub Issues namespace, handled separately below.
|
|
22
22
|
const jiraRegex = /([A-Z]{2,}-\d+)/gi;
|
|
23
23
|
let match;
|
|
@@ -83,12 +83,10 @@ export function createBranchLinkerRouter(deps) {
|
|
|
83
83
|
if (ticketId.startsWith('GH-')) {
|
|
84
84
|
source = 'github';
|
|
85
85
|
}
|
|
86
|
-
else
|
|
86
|
+
else {
|
|
87
|
+
// Non-GH ticket IDs (e.g., PROJ-123) assumed to be Jira
|
|
87
88
|
source = 'jira';
|
|
88
89
|
}
|
|
89
|
-
else if (process.env.LINEAR_API_KEY) {
|
|
90
|
-
source = 'linear';
|
|
91
|
-
}
|
|
92
90
|
links.push({
|
|
93
91
|
ticketId,
|
|
94
92
|
link: {
|
package/dist/server/index.js
CHANGED
|
@@ -26,7 +26,6 @@ import { createBranchLinkerRouter, invalidateBranchLinkerCache } from './branch-
|
|
|
26
26
|
import { createHooksRouter } from './hooks.js';
|
|
27
27
|
import { createTicketTransitionsRouter } from './ticket-transitions.js';
|
|
28
28
|
import { createIntegrationJiraRouter } from './integration-jira.js';
|
|
29
|
-
import { createIntegrationLinearRouter } from './integration-linear.js';
|
|
30
29
|
import { startPolling, stopPolling } from './review-poller.js';
|
|
31
30
|
import { MOUNTAIN_NAMES } from './types.js';
|
|
32
31
|
import { semverLessThan } from './utils.js';
|
|
@@ -282,9 +281,6 @@ async function main() {
|
|
|
282
281
|
// Mount Jira integration router
|
|
283
282
|
const integrationJiraRouter = createIntegrationJiraRouter({ configPath: CONFIG_PATH });
|
|
284
283
|
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
284
|
// Mount branch linker router
|
|
289
285
|
const branchLinkerRouter = createBranchLinkerRouter({
|
|
290
286
|
configPath: CONFIG_PATH,
|
|
@@ -753,8 +749,8 @@ async function main() {
|
|
|
753
749
|
}
|
|
754
750
|
if (ticketContext) {
|
|
755
751
|
// Validate source is a known integration
|
|
756
|
-
if (ticketContext.source !== 'github' && ticketContext.source !== 'jira'
|
|
757
|
-
res.status(400).json({ error: "ticketContext.source must be 'github'
|
|
752
|
+
if (ticketContext.source !== 'github' && ticketContext.source !== 'jira') {
|
|
753
|
+
res.status(400).json({ error: "ticketContext.source must be 'github' or 'jira'" });
|
|
758
754
|
return;
|
|
759
755
|
}
|
|
760
756
|
// Validate repoPath is a configured workspace
|
|
@@ -763,25 +759,14 @@ async function main() {
|
|
|
763
759
|
res.status(400).json({ error: 'ticketContext.repoPath is not a configured workspace' });
|
|
764
760
|
return;
|
|
765
761
|
}
|
|
766
|
-
//
|
|
767
|
-
|
|
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
|
-
}
|
|
762
|
+
// Jira integration is configured via acli CLI — no env var check needed.
|
|
763
|
+
// Auth validation happens when acli commands are actually called.
|
|
779
764
|
// Validate ticket ID format per source
|
|
780
765
|
if (ticketContext.source === 'github' && !/^GH-\d+$/.test(ticketContext.ticketId)) {
|
|
781
766
|
res.status(400).json({ error: 'ticketContext.ticketId for github must match GH-<number>' });
|
|
782
767
|
return;
|
|
783
768
|
}
|
|
784
|
-
if (
|
|
769
|
+
if (ticketContext.source === 'jira' && !/^[A-Z][A-Z0-9]*-\d+$/.test(ticketContext.ticketId)) {
|
|
785
770
|
res.status(400).json({ error: 'ticketContext.ticketId must match <PROJECT>-<number>' });
|
|
786
771
|
return;
|
|
787
772
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
1
3
|
import { Router } from 'express';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const JIRA_TIMEOUT_MS = 10_000;
|
|
2
6
|
const CACHE_TTL_MS = 60_000;
|
|
3
7
|
const JIRA_ISSUES_CACHE_KEY = 'jira_issues';
|
|
4
8
|
/**
|
|
@@ -7,47 +11,26 @@ const JIRA_ISSUES_CACHE_KEY = 'jira_issues';
|
|
|
7
11
|
* Caller is responsible for mounting and applying auth middleware:
|
|
8
12
|
* app.use('/integration-jira', requireAuth, createIntegrationJiraRouter({ configPath }));
|
|
9
13
|
*/
|
|
10
|
-
export function createIntegrationJiraRouter(
|
|
14
|
+
export function createIntegrationJiraRouter(deps) {
|
|
15
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
11
16
|
const router = Router();
|
|
12
17
|
// Single 60s in-memory cache (Jira is cross-workspace, not per-repo)
|
|
13
18
|
const issuesCache = new Map();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const isLocalHttp = parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
|
|
24
|
-
if (!isHttps && !isLocalHttp) {
|
|
25
|
-
console.warn('[integration-jira] JIRA_BASE_URL failed validation (must be https or http://localhost), treating as unconfigured');
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
19
|
+
// Cached site URL — resolved once per server lifetime
|
|
20
|
+
let cachedSiteUrl = null;
|
|
21
|
+
async function getSiteUrl() {
|
|
22
|
+
if (cachedSiteUrl !== null)
|
|
23
|
+
return cachedSiteUrl;
|
|
24
|
+
const { stdout } = await exec('acli', ['jira', 'auth', 'status'], { timeout: JIRA_TIMEOUT_MS });
|
|
25
|
+
const match = /Site:\s*([\w-]+\.atlassian\.net)/.exec(stdout);
|
|
26
|
+
if (!match || !match[1]) {
|
|
27
|
+
throw new Error('Could not parse site URL from acli jira auth status output');
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
return { token, email, baseUrl };
|
|
34
|
-
}
|
|
35
|
-
function buildAuthHeader(email, token) {
|
|
36
|
-
return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`;
|
|
29
|
+
cachedSiteUrl = match[1];
|
|
30
|
+
return cachedSiteUrl;
|
|
37
31
|
}
|
|
38
|
-
// GET /integrations/jira/configured — returns whether env vars are set
|
|
39
|
-
router.get('/configured', (_req, res) => {
|
|
40
|
-
const env = getEnvVars();
|
|
41
|
-
res.json({ configured: env !== null });
|
|
42
|
-
});
|
|
43
32
|
// GET /integrations/jira/issues — search issues assigned to currentUser
|
|
44
33
|
router.get('/issues', async (_req, res) => {
|
|
45
|
-
const env = getEnvVars();
|
|
46
|
-
if (!env) {
|
|
47
|
-
const response = { issues: [], error: 'jira_not_configured' };
|
|
48
|
-
res.json(response);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
34
|
const now = Date.now();
|
|
52
35
|
// Return cached result if still fresh
|
|
53
36
|
const cached = issuesCache.get(JIRA_ISSUES_CACHE_KEY);
|
|
@@ -56,119 +39,131 @@ export function createIntegrationJiraRouter(_deps) {
|
|
|
56
39
|
res.json(response);
|
|
57
40
|
return;
|
|
58
41
|
}
|
|
59
|
-
|
|
60
|
-
const fields = 'summary,status,priority,customfield_10016,customfield_10020,assignee,updated';
|
|
61
|
-
const url = `${env.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&fields=${encodeURIComponent(fields)}&maxResults=50`;
|
|
62
|
-
let data;
|
|
42
|
+
let siteUrl;
|
|
63
43
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}),
|
|
71
|
-
]);
|
|
72
|
-
const settled = fetchResult[0];
|
|
73
|
-
if (settled.status === 'rejected') {
|
|
74
|
-
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
44
|
+
siteUrl = await getSiteUrl();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const errCode = err.code;
|
|
48
|
+
if (errCode === 'ENOENT') {
|
|
49
|
+
const response = { issues: [], error: 'acli_not_in_path' };
|
|
75
50
|
res.json(response);
|
|
76
51
|
return;
|
|
77
52
|
}
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
const response = { issues: [], error: '
|
|
53
|
+
const stderr = err.stderr ?? '';
|
|
54
|
+
if (stderr.includes('not logged') || stderr.includes('auth') || stderr.includes('unauthorized')) {
|
|
55
|
+
const response = { issues: [], error: 'acli_not_authenticated' };
|
|
81
56
|
res.json(response);
|
|
82
57
|
return;
|
|
83
58
|
}
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
60
|
+
res.json(response);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let stdout;
|
|
64
|
+
try {
|
|
65
|
+
({ stdout } = await exec('acli', [
|
|
66
|
+
'jira', 'workitem', 'search',
|
|
67
|
+
'--jql', 'assignee=currentUser() AND status NOT IN (Done, Closed) ORDER BY updated DESC',
|
|
68
|
+
'--json',
|
|
69
|
+
'--limit', '50',
|
|
70
|
+
], { timeout: JIRA_TIMEOUT_MS }));
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const errCode = err.code;
|
|
74
|
+
if (errCode === 'ENOENT') {
|
|
75
|
+
const response = { issues: [], error: 'acli_not_in_path' };
|
|
86
76
|
res.json(response);
|
|
87
77
|
return;
|
|
88
78
|
}
|
|
89
|
-
|
|
79
|
+
const stderr = err.stderr ?? '';
|
|
80
|
+
if (stderr.includes('not logged') || stderr.includes('auth') || stderr.includes('unauthorized')) {
|
|
81
|
+
const response = { issues: [], error: 'acli_not_authenticated' };
|
|
82
|
+
res.json(response);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
86
|
+
res.json(response);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let items;
|
|
90
|
+
try {
|
|
91
|
+
items = JSON.parse(stdout);
|
|
90
92
|
}
|
|
91
93
|
catch {
|
|
92
94
|
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
93
95
|
res.json(response);
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
96
|
-
const issues =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
assignee: item.fields.assignee?.displayName ?? null,
|
|
109
|
-
updatedAt: item.fields.updated,
|
|
110
|
-
projectKey,
|
|
111
|
-
};
|
|
112
|
-
});
|
|
113
|
-
// Sort by updatedAt descending
|
|
114
|
-
issues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
98
|
+
const issues = items.map((item) => ({
|
|
99
|
+
key: item.key,
|
|
100
|
+
title: item.fields.summary,
|
|
101
|
+
url: `https://${siteUrl}/browse/${item.key}`,
|
|
102
|
+
status: item.fields.status.name,
|
|
103
|
+
priority: item.fields.priority?.name ?? null,
|
|
104
|
+
assignee: item.fields.assignee?.displayName ?? null,
|
|
105
|
+
projectKey: item.key.split('-')[0] ?? item.key,
|
|
106
|
+
updatedAt: '',
|
|
107
|
+
sprint: null,
|
|
108
|
+
storyPoints: null,
|
|
109
|
+
}));
|
|
115
110
|
// Update cache
|
|
116
111
|
issuesCache.set(JIRA_ISSUES_CACHE_KEY, { issues, fetchedAt: now });
|
|
117
112
|
const response = { issues };
|
|
118
113
|
res.json(response);
|
|
119
114
|
});
|
|
120
|
-
// GET /integrations/jira/statuses?projectKey=X — fetch
|
|
115
|
+
// GET /integrations/jira/statuses?projectKey=X — fetch unique statuses for a project
|
|
121
116
|
router.get('/statuses', async (req, res) => {
|
|
122
|
-
const env = getEnvVars();
|
|
123
|
-
if (!env) {
|
|
124
|
-
res.json({ statuses: [], error: 'jira_not_configured' });
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
117
|
const projectKey = req.query['projectKey'];
|
|
128
118
|
if (!projectKey || typeof projectKey !== 'string') {
|
|
129
119
|
res.status(400).json({ statuses: [], error: 'missing_project_key' });
|
|
130
120
|
return;
|
|
131
121
|
}
|
|
132
|
-
|
|
133
|
-
|
|
122
|
+
// Sanitize: only allow [A-Z0-9]+ to prevent command injection
|
|
123
|
+
if (!/^[A-Z0-9]+$/.test(projectKey)) {
|
|
124
|
+
res.status(400).json({ statuses: [], error: 'invalid_project_key' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let stdout;
|
|
134
128
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
const httpRes = settled.value;
|
|
149
|
-
if (httpRes.status === 401 || httpRes.status === 403) {
|
|
150
|
-
res.json({ statuses: [], error: 'jira_auth_failed' });
|
|
129
|
+
({ stdout } = await exec('acli', [
|
|
130
|
+
'jira', 'workitem', 'search',
|
|
131
|
+
'--jql', `project = ${projectKey}`,
|
|
132
|
+
'--fields', 'status',
|
|
133
|
+
'--json',
|
|
134
|
+
'--limit', '50',
|
|
135
|
+
], { timeout: JIRA_TIMEOUT_MS }));
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
const errCode = err.code;
|
|
139
|
+
if (errCode === 'ENOENT') {
|
|
140
|
+
res.json({ statuses: [], error: 'acli_not_in_path' });
|
|
151
141
|
return;
|
|
152
142
|
}
|
|
153
|
-
|
|
154
|
-
|
|
143
|
+
const stderr = err.stderr ?? '';
|
|
144
|
+
if (stderr.includes('not logged') || stderr.includes('auth') || stderr.includes('unauthorized')) {
|
|
145
|
+
res.json({ statuses: [], error: 'acli_not_authenticated' });
|
|
155
146
|
return;
|
|
156
147
|
}
|
|
157
|
-
|
|
148
|
+
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let items;
|
|
152
|
+
try {
|
|
153
|
+
items = JSON.parse(stdout);
|
|
158
154
|
}
|
|
159
155
|
catch {
|
|
160
156
|
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
161
157
|
return;
|
|
162
158
|
}
|
|
163
|
-
//
|
|
159
|
+
// Deduplicate statuses by id
|
|
164
160
|
const seen = new Set();
|
|
165
161
|
const statuses = [];
|
|
166
|
-
for (const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
162
|
+
for (const item of items) {
|
|
163
|
+
const { id, name } = item.fields.status;
|
|
164
|
+
if (!seen.has(id)) {
|
|
165
|
+
seen.add(id);
|
|
166
|
+
statuses.push({ id, name });
|
|
172
167
|
}
|
|
173
168
|
}
|
|
174
169
|
res.json({ statuses });
|
|
@@ -32,44 +32,10 @@ async function removeLabel(exec, repoPath, issueNumber, label) {
|
|
|
32
32
|
// Label may not exist — non-fatal
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
36
|
-
function
|
|
35
|
+
/** Call a Jira transition by name via acli. Returns true on success, false on failure. */
|
|
36
|
+
async function jiraTransition(exec, ticketId, transitionName) {
|
|
37
37
|
try {
|
|
38
|
-
|
|
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
|
-
}
|
|
38
|
+
await exec('acli', ['jira', 'workitem', 'transition', '--key', ticketId, '--status', transitionName, '--yes'], { timeout: 10_000 });
|
|
73
39
|
return true;
|
|
74
40
|
}
|
|
75
41
|
catch (err) {
|
|
@@ -77,54 +43,8 @@ async function jiraTransition(ticketId, transitionId) {
|
|
|
77
43
|
return false;
|
|
78
44
|
}
|
|
79
45
|
}
|
|
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
46
|
/**
|
|
124
47
|
* 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
48
|
*/
|
|
129
49
|
function detectTicketSource(ticketId, links) {
|
|
130
50
|
// Use explicit source from branch link if available
|
|
@@ -135,14 +55,9 @@ function detectTicketSource(ticketId, links) {
|
|
|
135
55
|
}
|
|
136
56
|
if (ticketId.startsWith('GH-'))
|
|
137
57
|
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).
|
|
58
|
+
// Prefer Jira for PROJECT-style keys (>= 3 uppercase letters before dash).
|
|
140
59
|
const prefix = ticketId.split('-')[0] ?? '';
|
|
141
|
-
if (prefix.length >= 3
|
|
142
|
-
return 'jira';
|
|
143
|
-
if (process.env.LINEAR_API_KEY)
|
|
144
|
-
return 'linear';
|
|
145
|
-
if (process.env.JIRA_API_TOKEN)
|
|
60
|
+
if (prefix.length >= 3)
|
|
146
61
|
return 'jira';
|
|
147
62
|
return 'github'; // fallback
|
|
148
63
|
}
|
|
@@ -152,11 +67,9 @@ export function createTicketTransitionsRouter(deps) {
|
|
|
152
67
|
const exec = deps.execAsync ?? execFileAsync;
|
|
153
68
|
const { configPath } = deps;
|
|
154
69
|
const router = Router();
|
|
155
|
-
/** Get status mapping for a transition state from config */
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
return config.integrations?.jira?.statusMappings?.[state];
|
|
159
|
-
return config.integrations?.linear?.statusMappings?.[state];
|
|
70
|
+
/** Get Jira status mapping for a transition state from config */
|
|
71
|
+
function getJiraStatusMapping(config, state) {
|
|
72
|
+
return config.integrations?.jira?.statusMappings?.[state];
|
|
160
73
|
}
|
|
161
74
|
async function transitionOnSessionCreate(ctx) {
|
|
162
75
|
const current = transitionMap.get(ctx.ticketId);
|
|
@@ -172,18 +85,9 @@ export function createTicketTransitionsRouter(deps) {
|
|
|
172
85
|
}
|
|
173
86
|
else if (ctx.source === 'jira') {
|
|
174
87
|
const config = loadConfig(configPath);
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
const ok = await jiraTransition(ctx.ticketId,
|
|
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);
|
|
88
|
+
const transitionName = getJiraStatusMapping(config, 'in-progress');
|
|
89
|
+
if (transitionName) {
|
|
90
|
+
const ok = await jiraTransition(exec, ctx.ticketId, transitionName);
|
|
187
91
|
if (ok)
|
|
188
92
|
transitionMap.set(ctx.ticketId, 'in-progress');
|
|
189
93
|
}
|
|
@@ -212,17 +116,9 @@ export function createTicketTransitionsRouter(deps) {
|
|
|
212
116
|
transitionMap.set(ticketId, 'code-review');
|
|
213
117
|
}
|
|
214
118
|
else if (source === 'jira') {
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
-
const ok = await jiraTransition(ticketId,
|
|
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);
|
|
119
|
+
const transitionName = getJiraStatusMapping(config, 'code-review');
|
|
120
|
+
if (transitionName) {
|
|
121
|
+
const ok = await jiraTransition(exec, ticketId, transitionName);
|
|
226
122
|
if (ok)
|
|
227
123
|
transitionMap.set(ticketId, 'code-review');
|
|
228
124
|
}
|
|
@@ -242,17 +138,9 @@ export function createTicketTransitionsRouter(deps) {
|
|
|
242
138
|
transitionMap.set(ticketId, 'ready-for-qa');
|
|
243
139
|
}
|
|
244
140
|
else if (source === 'jira') {
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
-
const ok = await jiraTransition(ticketId,
|
|
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);
|
|
141
|
+
const transitionName = getJiraStatusMapping(config, 'ready-for-qa');
|
|
142
|
+
if (transitionName) {
|
|
143
|
+
const ok = await jiraTransition(exec, ticketId, transitionName);
|
|
256
144
|
if (ok)
|
|
257
145
|
transitionMap.set(ticketId, 'ready-for-qa');
|
|
258
146
|
}
|