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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
const CACHE_TTL_MS = 60_000;
|
|
3
|
+
const JIRA_ISSUES_CACHE_KEY = 'jira_issues';
|
|
4
|
+
/**
|
|
5
|
+
* Creates and returns an Express Router that handles all /integration-jira routes.
|
|
6
|
+
*
|
|
7
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
8
|
+
* app.use('/integration-jira', requireAuth, createIntegrationJiraRouter({ configPath }));
|
|
9
|
+
*/
|
|
10
|
+
export function createIntegrationJiraRouter(_deps) {
|
|
11
|
+
const router = Router();
|
|
12
|
+
// Single 60s in-memory cache (Jira is cross-workspace, not per-repo)
|
|
13
|
+
const issuesCache = new Map();
|
|
14
|
+
function getEnvVars() {
|
|
15
|
+
const token = process.env.JIRA_API_TOKEN;
|
|
16
|
+
const email = process.env.JIRA_EMAIL;
|
|
17
|
+
const baseUrl = process.env.JIRA_BASE_URL;
|
|
18
|
+
if (!token || !email || !baseUrl)
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(baseUrl);
|
|
22
|
+
const isHttps = parsed.protocol === 'https:';
|
|
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
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
console.warn('[integration-jira] JIRA_BASE_URL is not a valid URL, treating as unconfigured');
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return { token, email, baseUrl };
|
|
34
|
+
}
|
|
35
|
+
function buildAuthHeader(email, token) {
|
|
36
|
+
return `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`;
|
|
37
|
+
}
|
|
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
|
+
// GET /integrations/jira/issues — search issues assigned to currentUser
|
|
44
|
+
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
|
+
const now = Date.now();
|
|
52
|
+
// Return cached result if still fresh
|
|
53
|
+
const cached = issuesCache.get(JIRA_ISSUES_CACHE_KEY);
|
|
54
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
55
|
+
const response = { issues: cached.issues };
|
|
56
|
+
res.json(response);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const jql = 'assignee=currentUser() AND status NOT IN (Done, Closed) ORDER BY updated DESC';
|
|
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;
|
|
63
|
+
try {
|
|
64
|
+
const fetchResult = await Promise.allSettled([
|
|
65
|
+
fetch(url, {
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: buildAuthHeader(env.email, env.token),
|
|
68
|
+
Accept: 'application/json',
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
const settled = fetchResult[0];
|
|
73
|
+
if (settled.status === 'rejected') {
|
|
74
|
+
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
75
|
+
res.json(response);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const httpRes = settled.value;
|
|
79
|
+
if (httpRes.status === 401 || httpRes.status === 403) {
|
|
80
|
+
const response = { issues: [], error: 'jira_auth_failed' };
|
|
81
|
+
res.json(response);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!httpRes.ok) {
|
|
85
|
+
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
86
|
+
res.json(response);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
data = (await httpRes.json());
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
93
|
+
res.json(response);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const issues = data.issues.map((item) => {
|
|
97
|
+
const projectKey = item.key.split('-')[0] ?? item.key;
|
|
98
|
+
const sprint = item.fields.customfield_10020;
|
|
99
|
+
const latestSprint = sprint && sprint.length > 0 ? sprint[sprint.length - 1]?.name ?? null : null;
|
|
100
|
+
return {
|
|
101
|
+
key: item.key,
|
|
102
|
+
title: item.fields.summary,
|
|
103
|
+
url: `${env.baseUrl}/browse/${item.key}`,
|
|
104
|
+
status: item.fields.status.name,
|
|
105
|
+
priority: item.fields.priority?.name ?? null,
|
|
106
|
+
sprint: latestSprint,
|
|
107
|
+
storyPoints: item.fields.customfield_10016,
|
|
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());
|
|
115
|
+
// Update cache
|
|
116
|
+
issuesCache.set(JIRA_ISSUES_CACHE_KEY, { issues, fetchedAt: now });
|
|
117
|
+
const response = { issues };
|
|
118
|
+
res.json(response);
|
|
119
|
+
});
|
|
120
|
+
// GET /integrations/jira/statuses?projectKey=X — fetch project statuses
|
|
121
|
+
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
|
+
const projectKey = req.query['projectKey'];
|
|
128
|
+
if (!projectKey || typeof projectKey !== 'string') {
|
|
129
|
+
res.status(400).json({ statuses: [], error: 'missing_project_key' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const url = `${env.baseUrl}/rest/api/3/project/${encodeURIComponent(projectKey)}/statuses`;
|
|
133
|
+
let rawData;
|
|
134
|
+
try {
|
|
135
|
+
const fetchResults = await Promise.allSettled([
|
|
136
|
+
fetch(url, {
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: buildAuthHeader(env.email, env.token),
|
|
139
|
+
Accept: 'application/json',
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
const settled = fetchResults[0];
|
|
144
|
+
if (settled.status === 'rejected') {
|
|
145
|
+
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const httpRes = settled.value;
|
|
149
|
+
if (httpRes.status === 401 || httpRes.status === 403) {
|
|
150
|
+
res.json({ statuses: [], error: 'jira_auth_failed' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!httpRes.ok) {
|
|
154
|
+
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
rawData = (await httpRes.json());
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Flatten statuses across all issue types and deduplicate by id
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const statuses = [];
|
|
166
|
+
for (const issueType of rawData) {
|
|
167
|
+
for (const s of issueType.statuses) {
|
|
168
|
+
if (!seen.has(s.id)) {
|
|
169
|
+
seen.add(s.id);
|
|
170
|
+
statuses.push({ id: s.id, name: s.name });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
res.json({ statuses });
|
|
175
|
+
});
|
|
176
|
+
return router;
|
|
177
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
|
|
3
|
+
const CACHE_TTL_MS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* Creates and returns an Express Router that handles all /integration-linear routes.
|
|
6
|
+
*
|
|
7
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
8
|
+
* app.use('/integration-linear', requireAuth, createIntegrationLinearRouter({ configPath }));
|
|
9
|
+
*/
|
|
10
|
+
export function createIntegrationLinearRouter(_deps) {
|
|
11
|
+
const router = Router();
|
|
12
|
+
// Single 60s in-memory cache for assigned issues
|
|
13
|
+
let issuesCache = null;
|
|
14
|
+
// GET /integrations/linear/configured — check whether the API key is set
|
|
15
|
+
router.get('/configured', (_req, res) => {
|
|
16
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
17
|
+
res.json({ configured: Boolean(apiKey) });
|
|
18
|
+
});
|
|
19
|
+
// GET /integrations/linear/issues — fetch assigned issues (non-completed, non-canceled)
|
|
20
|
+
router.get('/issues', async (_req, res) => {
|
|
21
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
const response = { issues: [], error: 'linear_not_configured' };
|
|
24
|
+
res.json(response);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Return cached result if still fresh
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (issuesCache && now - issuesCache.fetchedAt < CACHE_TTL_MS) {
|
|
30
|
+
const response = { issues: issuesCache.issues };
|
|
31
|
+
res.json(response);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const query = `
|
|
35
|
+
query {
|
|
36
|
+
viewer {
|
|
37
|
+
assignedIssues(
|
|
38
|
+
filter: { state: { type: { nin: ["completed", "canceled"] } } }
|
|
39
|
+
first: 50
|
|
40
|
+
orderBy: updatedAt
|
|
41
|
+
) {
|
|
42
|
+
nodes {
|
|
43
|
+
id
|
|
44
|
+
identifier
|
|
45
|
+
title
|
|
46
|
+
url
|
|
47
|
+
state { name }
|
|
48
|
+
priority
|
|
49
|
+
priorityLabel
|
|
50
|
+
cycle { name }
|
|
51
|
+
estimate
|
|
52
|
+
assignee { name }
|
|
53
|
+
updatedAt
|
|
54
|
+
team { id }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
let data;
|
|
61
|
+
try {
|
|
62
|
+
const fetchRes = await fetch(LINEAR_GRAPHQL_URL, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ query }),
|
|
69
|
+
});
|
|
70
|
+
if (fetchRes.status === 401 || fetchRes.status === 403) {
|
|
71
|
+
const response = { issues: [], error: 'linear_auth_failed' };
|
|
72
|
+
res.json(response);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!fetchRes.ok) {
|
|
76
|
+
const response = { issues: [], error: 'linear_fetch_failed' };
|
|
77
|
+
res.json(response);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
data = await fetchRes.json();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
const response = { issues: [], error: 'linear_fetch_failed' };
|
|
84
|
+
res.json(response);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Check for GraphQL-level auth errors
|
|
88
|
+
const gqlData = data;
|
|
89
|
+
if (gqlData.errors && gqlData.errors.length > 0) {
|
|
90
|
+
const errType = gqlData.errors[0]?.extensions?.type;
|
|
91
|
+
if (errType === 'authentication' || errType === 'authorization') {
|
|
92
|
+
const response = { issues: [], error: 'linear_auth_failed' };
|
|
93
|
+
res.json(response);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const response = { issues: [], error: 'linear_fetch_failed' };
|
|
97
|
+
res.json(response);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const nodes = gqlData.data?.viewer?.assignedIssues?.nodes ?? [];
|
|
101
|
+
const issues = nodes.map((node) => ({
|
|
102
|
+
id: node.id,
|
|
103
|
+
identifier: node.identifier,
|
|
104
|
+
title: node.title,
|
|
105
|
+
url: node.url,
|
|
106
|
+
state: node.state?.name ?? '',
|
|
107
|
+
priority: node.priority,
|
|
108
|
+
priorityLabel: node.priorityLabel,
|
|
109
|
+
cycle: node.cycle?.name ?? null,
|
|
110
|
+
estimate: node.estimate ?? null,
|
|
111
|
+
assignee: node.assignee?.name ?? null,
|
|
112
|
+
updatedAt: node.updatedAt,
|
|
113
|
+
teamId: node.team?.id ?? '',
|
|
114
|
+
}));
|
|
115
|
+
// Update cache
|
|
116
|
+
issuesCache = { issues, fetchedAt: now };
|
|
117
|
+
const response = { issues };
|
|
118
|
+
res.json(response);
|
|
119
|
+
});
|
|
120
|
+
// GET /integrations/linear/states?teamId=X — fetch workflow states for a team
|
|
121
|
+
router.get('/states', async (req, res) => {
|
|
122
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
123
|
+
if (!apiKey) {
|
|
124
|
+
res.json({ states: [], error: 'linear_not_configured' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const teamId = req.query['teamId'];
|
|
128
|
+
if (typeof teamId !== 'string' || !teamId) {
|
|
129
|
+
res.status(400).json({ states: [], error: 'missing_team_id' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const query = `
|
|
133
|
+
query($teamId: String!) {
|
|
134
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
135
|
+
nodes { id name }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`;
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
const fetchRes = await fetch(LINEAR_GRAPHQL_URL, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: {
|
|
144
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
},
|
|
147
|
+
body: JSON.stringify({ query, variables: { teamId } }),
|
|
148
|
+
});
|
|
149
|
+
if (fetchRes.status === 401 || fetchRes.status === 403) {
|
|
150
|
+
res.json({ states: [], error: 'linear_auth_failed' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!fetchRes.ok) {
|
|
154
|
+
res.json({ states: [], error: 'linear_fetch_failed' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
data = await fetchRes.json();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
res.json({ states: [], error: 'linear_fetch_failed' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const gqlData = data;
|
|
164
|
+
if (gqlData.errors && gqlData.errors.length > 0) {
|
|
165
|
+
res.json({ states: [], error: 'linear_fetch_failed' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const nodes = gqlData.data?.workflowStates?.nodes ?? [];
|
|
169
|
+
const states = nodes.map((node) => ({
|
|
170
|
+
id: node.id,
|
|
171
|
+
name: node.name,
|
|
172
|
+
}));
|
|
173
|
+
res.json({ states });
|
|
174
|
+
});
|
|
175
|
+
return router;
|
|
176
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
* Extracts "owner/repo" from a git remote URL.
|
|
11
|
+
* Handles both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) forms.
|
|
12
|
+
*/
|
|
13
|
+
function extractOwnerRepo(remoteUrl) {
|
|
14
|
+
// SSH: git@github.com:owner/repo.git
|
|
15
|
+
const sshMatch = remoteUrl.match(/git@[^:]+:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
16
|
+
if (sshMatch)
|
|
17
|
+
return sshMatch[1] ?? null;
|
|
18
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
19
|
+
const httpsMatch = remoteUrl.match(/https?:\/\/[^/]+\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
20
|
+
if (httpsMatch)
|
|
21
|
+
return httpsMatch[1] ?? null;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Returns a map of "owner/repo" → workspace path for all git workspaces.
|
|
26
|
+
* Workspaces that are not git repos or have no remote are omitted.
|
|
27
|
+
*/
|
|
28
|
+
async function buildRepoMap(workspacePaths, exec) {
|
|
29
|
+
const map = new Map();
|
|
30
|
+
await Promise.all(workspacePaths.map(async (wsPath) => {
|
|
31
|
+
try {
|
|
32
|
+
const { stdout } = await exec('git', ['remote', 'get-url', 'origin'], { cwd: wsPath, timeout: GH_TIMEOUT_MS });
|
|
33
|
+
const ownerRepo = extractOwnerRepo(stdout.trim());
|
|
34
|
+
if (ownerRepo) {
|
|
35
|
+
map.set(ownerRepo.toLowerCase(), wsPath);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Not a git repo or no remote — skip
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Extracts "owner/repo" from a GitHub API repository_url.
|
|
46
|
+
* e.g. "https://api.github.com/repos/owner/repo" → "owner/repo"
|
|
47
|
+
*/
|
|
48
|
+
function repoFromApiUrl(repositoryUrl) {
|
|
49
|
+
const match = repositoryUrl.match(/\/repos\/([^/]+\/[^/]+)$/);
|
|
50
|
+
return match ? (match[1] ?? null) : null;
|
|
51
|
+
}
|
|
52
|
+
// Router factory
|
|
53
|
+
/**
|
|
54
|
+
* Creates and returns an Express Router that handles all /org-dashboard routes.
|
|
55
|
+
*
|
|
56
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
57
|
+
* app.use('/org-dashboard', requireAuth, createOrgDashboardRouter({ configPath }));
|
|
58
|
+
*/
|
|
59
|
+
export function createOrgDashboardRouter(deps) {
|
|
60
|
+
const { configPath } = deps;
|
|
61
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
62
|
+
const router = Router();
|
|
63
|
+
// Server-lifetime cache for GitHub user login
|
|
64
|
+
let cachedUser = null;
|
|
65
|
+
// 60s in-memory cache for search results
|
|
66
|
+
let cache = null;
|
|
67
|
+
function getConfig() {
|
|
68
|
+
return loadConfig(configPath);
|
|
69
|
+
}
|
|
70
|
+
// GET /org-dashboard/prs — list all open PRs involving the current user across all workspaces
|
|
71
|
+
router.get('/prs', async (_req, res) => {
|
|
72
|
+
const config = getConfig();
|
|
73
|
+
const workspacePaths = config.workspaces ?? [];
|
|
74
|
+
if (workspacePaths.length === 0) {
|
|
75
|
+
const response = { prs: [], error: 'no_workspaces' };
|
|
76
|
+
res.json(response);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Return cached results if still fresh
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
|
|
82
|
+
const response = { prs: cache.prs };
|
|
83
|
+
res.json(response);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Resolve GitHub user (cached for server lifetime)
|
|
87
|
+
if (!cachedUser) {
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await exec('gh', ['api', 'user', '--jq', '.login'], { timeout: GH_TIMEOUT_MS });
|
|
90
|
+
cachedUser = stdout.trim();
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const errCode = err.code;
|
|
94
|
+
if (errCode === 'ENOENT') {
|
|
95
|
+
const response = { prs: [], error: 'gh_not_in_path' };
|
|
96
|
+
res.json(response);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const response = { prs: [], error: 'gh_not_authenticated' };
|
|
100
|
+
res.json(response);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const currentUser = cachedUser;
|
|
105
|
+
// Build repo → workspace path map
|
|
106
|
+
const repoMap = await buildRepoMap(workspacePaths, exec);
|
|
107
|
+
// Single gh search API call
|
|
108
|
+
let searchResponse;
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await exec('gh', ['api', 'search/issues?q=is:pr+is:open+involves:@me&per_page=100'], { timeout: GH_TIMEOUT_MS });
|
|
111
|
+
searchResponse = JSON.parse(stdout);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
115
|
+
const errCode = err.code;
|
|
116
|
+
if (msg.includes('ETIMEDOUT') || msg.includes('timed out')) {
|
|
117
|
+
const response = { prs: [], error: 'gh_timeout' };
|
|
118
|
+
res.json(response);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (errCode === 'ENOENT') {
|
|
122
|
+
const response = { prs: [], error: 'gh_not_in_path' };
|
|
123
|
+
res.json(response);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const response = { prs: [], error: 'gh_not_authenticated' };
|
|
127
|
+
res.json(response);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const items = searchResponse.items ?? [];
|
|
131
|
+
// Filter to only repos matching workspace paths and map to PullRequest
|
|
132
|
+
const prs = [];
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
// The search API can return non-PR issues — skip them
|
|
135
|
+
if (!item.pull_request)
|
|
136
|
+
continue;
|
|
137
|
+
const ownerRepo = repoFromApiUrl(item.repository_url);
|
|
138
|
+
if (!ownerRepo)
|
|
139
|
+
continue;
|
|
140
|
+
const wsPath = repoMap.get(ownerRepo.toLowerCase());
|
|
141
|
+
if (!wsPath)
|
|
142
|
+
continue;
|
|
143
|
+
// Determine role
|
|
144
|
+
const isAuthor = item.user.login === currentUser;
|
|
145
|
+
const isReviewer = !isAuthor &&
|
|
146
|
+
Array.isArray(item.requested_reviewers) &&
|
|
147
|
+
item.requested_reviewers.some((r) => r.login === currentUser);
|
|
148
|
+
if (!isAuthor && !isReviewer)
|
|
149
|
+
continue;
|
|
150
|
+
const role = isAuthor ? 'author' : 'reviewer';
|
|
151
|
+
const repoName = path.basename(wsPath);
|
|
152
|
+
prs.push({
|
|
153
|
+
number: item.number,
|
|
154
|
+
title: item.title,
|
|
155
|
+
url: item.html_url,
|
|
156
|
+
headRefName: item.pull_request?.head?.ref ?? '',
|
|
157
|
+
baseRefName: item.pull_request?.base?.ref ?? '',
|
|
158
|
+
state: 'OPEN',
|
|
159
|
+
author: item.user.login,
|
|
160
|
+
role,
|
|
161
|
+
updatedAt: item.updated_at,
|
|
162
|
+
additions: 0,
|
|
163
|
+
deletions: 0,
|
|
164
|
+
reviewDecision: null,
|
|
165
|
+
mergeable: null,
|
|
166
|
+
repoName,
|
|
167
|
+
repoPath: wsPath,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Sort by updatedAt descending
|
|
171
|
+
prs.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
172
|
+
// Update cache
|
|
173
|
+
cache = { prs, fetchedAt: now };
|
|
174
|
+
// Fire ticket transitions check (best-effort, don't block response)
|
|
175
|
+
// Include recently merged PRs for MERGED->ready-for-qa transitions
|
|
176
|
+
if (deps.checkPrTransitions && deps.getBranchLinks) {
|
|
177
|
+
const transitionPrs = [...prs];
|
|
178
|
+
// Fetch recently merged PRs (last 7 days) for transition checks
|
|
179
|
+
try {
|
|
180
|
+
const mergedSince = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
181
|
+
const { stdout: mergedStdout } = await exec('gh', ['api', `search/issues?q=is:pr+is:merged+merged:>=${mergedSince}+involves:@me&per_page=50`], { timeout: GH_TIMEOUT_MS });
|
|
182
|
+
const mergedResponse = JSON.parse(mergedStdout);
|
|
183
|
+
for (const item of mergedResponse.items ?? []) {
|
|
184
|
+
if (!item.pull_request)
|
|
185
|
+
continue;
|
|
186
|
+
const ownerRepo = repoFromApiUrl(item.repository_url);
|
|
187
|
+
if (!ownerRepo)
|
|
188
|
+
continue;
|
|
189
|
+
const wsPath = repoMap.get(ownerRepo.toLowerCase());
|
|
190
|
+
if (!wsPath)
|
|
191
|
+
continue;
|
|
192
|
+
transitionPrs.push({
|
|
193
|
+
number: item.number,
|
|
194
|
+
title: item.title,
|
|
195
|
+
url: item.html_url,
|
|
196
|
+
headRefName: item.pull_request?.head?.ref ?? '',
|
|
197
|
+
baseRefName: item.pull_request?.base?.ref ?? '',
|
|
198
|
+
state: 'MERGED',
|
|
199
|
+
author: item.user.login,
|
|
200
|
+
role: 'author',
|
|
201
|
+
updatedAt: item.updated_at,
|
|
202
|
+
additions: 0,
|
|
203
|
+
deletions: 0,
|
|
204
|
+
reviewDecision: null,
|
|
205
|
+
mergeable: null,
|
|
206
|
+
repoName: path.basename(wsPath),
|
|
207
|
+
repoPath: wsPath,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Merged PR fetch is best-effort — don't block transitions
|
|
213
|
+
}
|
|
214
|
+
deps.getBranchLinks()
|
|
215
|
+
.then((links) => deps.checkPrTransitions(transitionPrs, links))
|
|
216
|
+
.catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
const response = { prs };
|
|
219
|
+
res.json(response);
|
|
220
|
+
});
|
|
221
|
+
return router;
|
|
222
|
+
}
|