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.
@@ -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
+ }