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