claude-remote-cli 3.9.5 → 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/assets/index-BTOnhJQN.css +32 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/branch-linker.js +134 -0
- package/dist/server/config.js +31 -1
- package/dist/server/index.js +186 -2
- package/dist/server/integration-github.js +117 -0
- package/dist/server/integration-jira.js +172 -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 +153 -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 +221 -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 +265 -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,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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const JIRA_TIMEOUT_MS = 10_000;
|
|
6
|
+
const CACHE_TTL_MS = 60_000;
|
|
7
|
+
const JIRA_ISSUES_CACHE_KEY = 'jira_issues';
|
|
8
|
+
/**
|
|
9
|
+
* Creates and returns an Express Router that handles all /integration-jira routes.
|
|
10
|
+
*
|
|
11
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
12
|
+
* app.use('/integration-jira', requireAuth, createIntegrationJiraRouter({ configPath }));
|
|
13
|
+
*/
|
|
14
|
+
export function createIntegrationJiraRouter(deps) {
|
|
15
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
16
|
+
const router = Router();
|
|
17
|
+
// Single 60s in-memory cache (Jira is cross-workspace, not per-repo)
|
|
18
|
+
const issuesCache = new Map();
|
|
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
|
+
}
|
|
29
|
+
cachedSiteUrl = match[1];
|
|
30
|
+
return cachedSiteUrl;
|
|
31
|
+
}
|
|
32
|
+
// GET /integrations/jira/issues — search issues assigned to currentUser
|
|
33
|
+
router.get('/issues', async (_req, res) => {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
// Return cached result if still fresh
|
|
36
|
+
const cached = issuesCache.get(JIRA_ISSUES_CACHE_KEY);
|
|
37
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
38
|
+
const response = { issues: cached.issues };
|
|
39
|
+
res.json(response);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let siteUrl;
|
|
43
|
+
try {
|
|
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' };
|
|
50
|
+
res.json(response);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
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' };
|
|
56
|
+
res.json(response);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
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' };
|
|
76
|
+
res.json(response);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
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);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
const response = { issues: [], error: 'jira_fetch_failed' };
|
|
95
|
+
res.json(response);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
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
|
+
}));
|
|
110
|
+
// Update cache
|
|
111
|
+
issuesCache.set(JIRA_ISSUES_CACHE_KEY, { issues, fetchedAt: now });
|
|
112
|
+
const response = { issues };
|
|
113
|
+
res.json(response);
|
|
114
|
+
});
|
|
115
|
+
// GET /integrations/jira/statuses?projectKey=X — fetch unique statuses for a project
|
|
116
|
+
router.get('/statuses', async (req, res) => {
|
|
117
|
+
const projectKey = req.query['projectKey'];
|
|
118
|
+
if (!projectKey || typeof projectKey !== 'string') {
|
|
119
|
+
res.status(400).json({ statuses: [], error: 'missing_project_key' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
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;
|
|
128
|
+
try {
|
|
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' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
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' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let items;
|
|
152
|
+
try {
|
|
153
|
+
items = JSON.parse(stdout);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
res.json({ statuses: [], error: 'jira_fetch_failed' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Deduplicate statuses by id
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const statuses = [];
|
|
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 });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
res.json({ statuses });
|
|
170
|
+
});
|
|
171
|
+
return router;
|
|
172
|
+
}
|
|
@@ -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
|
+
}
|