@proletariat/cli 0.3.51 → 0.3.52
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/commands/agent/status.js +1 -0
- package/dist/commands/asana/connect.d.ts +15 -0
- package/dist/commands/asana/connect.js +267 -0
- package/dist/commands/asana/sync.d.ts +15 -0
- package/dist/commands/asana/sync.js +189 -0
- package/dist/commands/config/index.js +7 -1
- package/dist/commands/execution/list.js +3 -0
- package/dist/commands/execution/view.js +10 -0
- package/dist/commands/monday/connect.d.ts +16 -0
- package/dist/commands/monday/connect.js +212 -0
- package/dist/commands/monday/sync.d.ts +14 -0
- package/dist/commands/monday/sync.js +178 -0
- package/dist/commands/orchestrator/start.d.ts +6 -0
- package/dist/commands/orchestrator/start.js +149 -11
- package/dist/commands/session/list.js +6 -5
- package/dist/commands/work/index.js +7 -0
- package/dist/commands/work/jira.d.ts +28 -0
- package/dist/commands/work/jira.js +225 -0
- package/dist/commands/work/source/set.d.ts +12 -0
- package/dist/commands/work/source/set.js +52 -0
- package/dist/commands/work/source.d.ts +11 -0
- package/dist/commands/work/source.js +53 -0
- package/dist/commands/work/spawn.d.ts +1 -0
- package/dist/commands/work/spawn.js +73 -8
- package/dist/commands/work/start.d.ts +8 -0
- package/dist/commands/work/start.js +241 -3
- package/dist/lib/asana/client.d.ts +15 -0
- package/dist/lib/asana/client.js +120 -0
- package/dist/lib/asana/config.d.ts +9 -0
- package/dist/lib/asana/config.js +61 -0
- package/dist/lib/asana/index.d.ts +5 -0
- package/dist/lib/asana/index.js +4 -0
- package/dist/lib/asana/mapper.d.ts +13 -0
- package/dist/lib/asana/mapper.js +70 -0
- package/dist/lib/asana/sync.d.ts +13 -0
- package/dist/lib/asana/sync.js +36 -0
- package/dist/lib/asana/types.d.ts +40 -0
- package/dist/lib/asana/types.js +1 -0
- package/dist/lib/database/drizzle-schema.d.ts +393 -0
- package/dist/lib/database/drizzle-schema.js +45 -0
- package/dist/lib/execution/config.d.ts +10 -0
- package/dist/lib/execution/config.js +19 -0
- package/dist/lib/execution/runners.d.ts +10 -0
- package/dist/lib/execution/runners.js +110 -1
- package/dist/lib/execution/spawner.js +26 -0
- package/dist/lib/execution/storage.d.ts +4 -0
- package/dist/lib/execution/storage.js +8 -3
- package/dist/lib/execution/types.d.ts +4 -0
- package/dist/lib/external-issues/adapters.d.ts +18 -1
- package/dist/lib/external-issues/adapters.js +49 -1
- package/dist/lib/external-issues/index.d.ts +4 -1
- package/dist/lib/external-issues/index.js +5 -0
- package/dist/lib/external-issues/jira.d.ts +23 -0
- package/dist/lib/external-issues/jira.js +223 -0
- package/dist/lib/external-issues/linear.js +4 -3
- package/dist/lib/external-issues/mapper.d.ts +3 -2
- package/dist/lib/external-issues/mapper.js +5 -2
- package/dist/lib/external-issues/mapping-store.d.ts +12 -0
- package/dist/lib/external-issues/mapping-store.js +164 -0
- package/dist/lib/external-issues/types.d.ts +34 -0
- package/dist/lib/external-issues/validation.js +11 -0
- package/dist/lib/external-issues/work-start.d.ts +10 -0
- package/dist/lib/external-issues/work-start.js +12 -0
- package/dist/lib/linear/mapper.d.ts +2 -0
- package/dist/lib/linear/mapper.js +66 -2
- package/dist/lib/monday/client.d.ts +14 -0
- package/dist/lib/monday/client.js +113 -0
- package/dist/lib/monday/config.d.ts +10 -0
- package/dist/lib/monday/config.js +64 -0
- package/dist/lib/monday/index.d.ts +5 -0
- package/dist/lib/monday/index.js +4 -0
- package/dist/lib/monday/mapper.d.ts +14 -0
- package/dist/lib/monday/mapper.js +89 -0
- package/dist/lib/monday/sync.d.ts +13 -0
- package/dist/lib/monday/sync.js +45 -0
- package/dist/lib/monday/types.d.ts +38 -0
- package/dist/lib/monday/types.js +4 -0
- package/dist/lib/pmo/schema.d.ts +10 -1
- package/dist/lib/pmo/schema.js +73 -0
- package/dist/lib/pmo/storage/base.js +32 -0
- package/dist/lib/prompt-json.d.ts +11 -0
- package/dist/lib/work-source/config.d.ts +14 -0
- package/dist/lib/work-source/config.js +70 -0
- package/dist/lib/work-source/index.d.ts +1 -0
- package/dist/lib/work-source/index.js +1 -0
- package/oclif.manifest.json +2584 -2017
- package/package.json +1 -1
|
@@ -152,6 +152,107 @@ export function getDockerCredentialInfo() {
|
|
|
152
152
|
return null;
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Check if Claude Code authentication is available on the host system.
|
|
157
|
+
* Returns true if either:
|
|
158
|
+
* 1. OAuth credentials exist in ~/.claude/.credentials.json, OR
|
|
159
|
+
* 2. ANTHROPIC_API_KEY environment variable is set
|
|
160
|
+
*
|
|
161
|
+
* This is used to validate auth before spawning host sessions (e.g., orchestrator)
|
|
162
|
+
* to avoid creating stuck sessions when the keychain is locked (SSH contexts).
|
|
163
|
+
*/
|
|
164
|
+
export function hostCredentialsExist() {
|
|
165
|
+
// Check for ANTHROPIC_API_KEY first (works in all contexts, including SSH)
|
|
166
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
// Check for OAuth credentials in ~/.claude/.credentials.json
|
|
170
|
+
try {
|
|
171
|
+
const homeDir = process.env.HOME || os.homedir();
|
|
172
|
+
const credPath = path.join(homeDir, '.claude', '.credentials.json');
|
|
173
|
+
if (!fs.existsSync(credPath)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const credData = fs.readFileSync(credPath, 'utf-8');
|
|
177
|
+
const creds = JSON.parse(credData);
|
|
178
|
+
// Check if OAuth credentials exist (similar to Docker check)
|
|
179
|
+
// Don't check expiration - Claude Code handles token refresh internally
|
|
180
|
+
if (creds.claudeAiOauth?.accessToken) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Ensure tmux server has keychain access for Claude Code OAuth.
|
|
191
|
+
*
|
|
192
|
+
* On macOS, tmux sessions can lose access to the keychain if the tmux server
|
|
193
|
+
* was started in a context without keychain access (e.g., from a background
|
|
194
|
+
* process, SSH session, or parent process with restricted keychain access).
|
|
195
|
+
*
|
|
196
|
+
* This function:
|
|
197
|
+
* 1. Checks if a tmux server is running
|
|
198
|
+
* 2. Tests if it can access Claude Code OAuth credentials
|
|
199
|
+
* 3. If not, restarts the tmux server to restore keychain access
|
|
200
|
+
*
|
|
201
|
+
* This runs transparently before spawning agent sessions, ensuring OAuth
|
|
202
|
+
* authentication works without manual intervention.
|
|
203
|
+
*/
|
|
204
|
+
async function ensureTmuxServerHasKeychainAccess() {
|
|
205
|
+
// Skip if no tmux server is running (will be started fresh with keychain access)
|
|
206
|
+
try {
|
|
207
|
+
const serverRunning = execSync('tmux list-sessions 2>/dev/null || echo ""', {
|
|
208
|
+
encoding: 'utf-8',
|
|
209
|
+
stdio: 'pipe'
|
|
210
|
+
});
|
|
211
|
+
if (!serverRunning.trim()) {
|
|
212
|
+
return; // No server running, will start fresh
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return; // tmux not installed or no server running
|
|
217
|
+
}
|
|
218
|
+
// Test if tmux server can access Claude Code credentials
|
|
219
|
+
// We spawn a test session and check if Claude Code can authenticate
|
|
220
|
+
const testSession = `prlt-keychain-test-${Date.now()}`;
|
|
221
|
+
try {
|
|
222
|
+
// Create test session
|
|
223
|
+
execSync(`tmux new-session -d -s "${testSession}"`, { stdio: 'pipe' });
|
|
224
|
+
// Send command to check Claude Code auth
|
|
225
|
+
// Use 'unset CLAUDECODE' to avoid nested session error
|
|
226
|
+
execSync(`tmux send-keys -t "${testSession}" "unset CLAUDECODE && claude -p 'test' 2>&1 | head -1" Enter`, { stdio: 'pipe' });
|
|
227
|
+
// Wait for response (Claude Code startup + auth check)
|
|
228
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
229
|
+
// Capture output
|
|
230
|
+
const output = execSync(`tmux capture-pane -t "${testSession}" -p`, {
|
|
231
|
+
encoding: 'utf-8',
|
|
232
|
+
stdio: 'pipe'
|
|
233
|
+
});
|
|
234
|
+
// Clean up test session
|
|
235
|
+
execSync(`tmux kill-session -t "${testSession}"`, { stdio: 'pipe' });
|
|
236
|
+
// Check if auth failed
|
|
237
|
+
if (output.includes('Not logged in') || output.includes('Please run /login')) {
|
|
238
|
+
// Keychain access is broken - restart tmux server
|
|
239
|
+
// This happens silently - the next tmux session will have keychain access
|
|
240
|
+
execSync('tmux kill-server', { stdio: 'pipe' });
|
|
241
|
+
// Brief delay to ensure server fully stops
|
|
242
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (_error) {
|
|
246
|
+
// Test session failed - clean up if it exists
|
|
247
|
+
try {
|
|
248
|
+
execSync(`tmux kill-session -t "${testSession}"`, { stdio: 'pipe' });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Ignore cleanup errors
|
|
252
|
+
}
|
|
253
|
+
// Continue - worst case, spawn will fail with clear error message
|
|
254
|
+
}
|
|
255
|
+
}
|
|
155
256
|
// =============================================================================
|
|
156
257
|
// Executor Commands
|
|
157
258
|
// =============================================================================
|
|
@@ -438,7 +539,8 @@ ${setTitleCmds}
|
|
|
438
539
|
echo "🚀 Starting: ${sessionName}"
|
|
439
540
|
echo ""
|
|
440
541
|
cd "${context.worktreePath}"
|
|
441
|
-
|
|
542
|
+
# Run executor in subshell with CLAUDECODE unset (prevents nested session error)
|
|
543
|
+
(unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT; ${executorInvocation})
|
|
442
544
|
|
|
443
545
|
# Clean up script and prompt files
|
|
444
546
|
rm -f "$SCRIPT_PATH" "$PROMPT_PATH"
|
|
@@ -1693,11 +1795,13 @@ async function runDevcontainerInTmux(context, devcontainerCmd, config, displayMo
|
|
|
1693
1795
|
// Create a script inside the container that runs claude and keeps shell open
|
|
1694
1796
|
// TERM must be set for Claude's TUI to render properly
|
|
1695
1797
|
// Unset CI to prevent Claude from detecting CI environment which suppresses TUI output
|
|
1798
|
+
// Unset CLAUDECODE to allow Claude Code to run (prevents nested session error)
|
|
1696
1799
|
// Note: We keep DEVCONTAINER set so prlt workspace detection works correctly
|
|
1697
1800
|
const tmuxScript = `#!/bin/bash
|
|
1698
1801
|
export TERM=xterm-256color
|
|
1699
1802
|
export COLORTERM=truecolor
|
|
1700
1803
|
unset CI
|
|
1804
|
+
unset CLAUDECODE
|
|
1701
1805
|
echo "🚀 Starting: ${sessionName}"
|
|
1702
1806
|
echo ""
|
|
1703
1807
|
${claudeCmd}
|
|
@@ -2123,6 +2227,11 @@ export async function runVm(context, executor, config, host) {
|
|
|
2123
2227
|
// Runner Dispatcher
|
|
2124
2228
|
// =============================================================================
|
|
2125
2229
|
export async function runExecution(environment, context, executor, config = DEFAULT_EXECUTION_CONFIG, options) {
|
|
2230
|
+
// Ensure tmux server has keychain access for OAuth (host only)
|
|
2231
|
+
// Docker uses claude-credentials volume, devcontainer runs inside container
|
|
2232
|
+
if (environment === 'host') {
|
|
2233
|
+
await ensureTmuxServerHasKeychainAccess();
|
|
2234
|
+
}
|
|
2126
2235
|
switch (environment) {
|
|
2127
2236
|
case 'devcontainer':
|
|
2128
2237
|
return runDevcontainer(context, executor, config, options?.displayMode, options?.sessionManager);
|
|
@@ -16,6 +16,7 @@ import { hasDevcontainerConfig } from './devcontainer.js';
|
|
|
16
16
|
import { loadExecutionConfig, getOrPromptCoderName } from './config.js';
|
|
17
17
|
import { runExecution, isDockerRunning, isGitHubTokenAvailable, isDevcontainerCliInstalled, runExecutorPreflight, getAgentContainerName, isContainerRunning, getContainerId, buildSessionName } from './runners.js';
|
|
18
18
|
import { detectRepoWorktrees, resolveWorktreePath } from './context.js';
|
|
19
|
+
import { ExternalExecutionMappingStore } from '../external-issues/mapping-store.js';
|
|
19
20
|
import { generateBranchName, DEFAULT_EXECUTION_CONFIG, } from './types.js';
|
|
20
21
|
// =============================================================================
|
|
21
22
|
// Git Utilities
|
|
@@ -103,6 +104,12 @@ function findBaseBranchInContainer(containerId, containerRepoPath, candidates =
|
|
|
103
104
|
* contention when all agents mount the same shared volumes concurrently.
|
|
104
105
|
*/
|
|
105
106
|
const SPAWN_STAGGER_DELAY_MS = 2000;
|
|
107
|
+
const EXTERNAL_MAPPING_PROVIDERS = new Set(['linear', 'jira', 'asana', 'monday', 'pmo']);
|
|
108
|
+
function getExternalProvider(value) {
|
|
109
|
+
if (!value)
|
|
110
|
+
return null;
|
|
111
|
+
return EXTERNAL_MAPPING_PROVIDERS.has(value) ? value : null;
|
|
112
|
+
}
|
|
106
113
|
// =============================================================================
|
|
107
114
|
// Agent Selection
|
|
108
115
|
// =============================================================================
|
|
@@ -501,6 +508,25 @@ export async function spawnAgentForTicket(ticket, agentName, storage, executionS
|
|
|
501
508
|
await storage.moveTicket(ticket.projectId, ticket.id, inProgressColumn);
|
|
502
509
|
}
|
|
503
510
|
await autoExportToBoard(pmoPath, storage, log);
|
|
511
|
+
const externalProvider = getExternalProvider(ticket.metadata?.external_source);
|
|
512
|
+
const externalId = ticket.metadata?.external_id;
|
|
513
|
+
if (externalProvider && externalId) {
|
|
514
|
+
const mappingStore = new ExternalExecutionMappingStore(db);
|
|
515
|
+
mappingStore.upsertMapping({
|
|
516
|
+
provider: externalProvider,
|
|
517
|
+
externalId,
|
|
518
|
+
externalKey: ticket.metadata?.external_key ?? null,
|
|
519
|
+
canonicalUrl: ticket.metadata?.external_url ?? null,
|
|
520
|
+
latestStateSnapshot: {
|
|
521
|
+
ticketId: ticket.id,
|
|
522
|
+
ticketStatus: ticket.statusName ?? null,
|
|
523
|
+
ticketCategory: ticket.statusCategory ?? null,
|
|
524
|
+
teamKey: ticket.metadata?.external_project ?? null,
|
|
525
|
+
},
|
|
526
|
+
executionId: execution.id,
|
|
527
|
+
lastSpawnedAt: new Date(),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
504
530
|
return {
|
|
505
531
|
success: true,
|
|
506
532
|
executionId: execution.id,
|
|
@@ -26,6 +26,10 @@ function rowToAgentWork(row) {
|
|
|
26
26
|
sessionId: row.session_id || undefined,
|
|
27
27
|
host: row.host || undefined,
|
|
28
28
|
logPath: row.log_path || undefined,
|
|
29
|
+
externalSource: row.external_source || undefined,
|
|
30
|
+
externalKey: row.external_key || undefined,
|
|
31
|
+
externalId: row.external_id || undefined,
|
|
32
|
+
externalUrl: row.external_url || undefined,
|
|
29
33
|
startedAt: new Date(row.started_at),
|
|
30
34
|
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
|
|
31
35
|
exitCode: row.exit_code ?? undefined,
|
|
@@ -52,9 +56,10 @@ export class ExecutionStorage {
|
|
|
52
56
|
this.db.prepare(`
|
|
53
57
|
INSERT INTO ${T.agent_work} (
|
|
54
58
|
id, ticket_id, agent_name, executor, environment, display_mode, permission_mode,
|
|
55
|
-
status, branch, pid, container_id, session_id, host, log_path,
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
status, branch, pid, container_id, session_id, host, log_path,
|
|
60
|
+
external_source, external_key, external_id, external_url, started_at
|
|
61
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
`).run(id, params.ticketId, params.agentName, params.executor, params.environment, params.displayMode, params.permissionMode, params.branch || null, params.pid || null, params.containerId || null, params.sessionId || null, params.host || null, params.logPath || null, params.externalSource || null, params.externalKey || null, params.externalId || null, params.externalUrl || null, now);
|
|
58
63
|
return this.getExecution(id);
|
|
59
64
|
}
|
|
60
65
|
/**
|
|
@@ -54,6 +54,10 @@ export interface AgentWork {
|
|
|
54
54
|
sessionId?: string;
|
|
55
55
|
host?: string;
|
|
56
56
|
logPath?: string;
|
|
57
|
+
externalSource?: string;
|
|
58
|
+
externalKey?: string;
|
|
59
|
+
externalId?: string;
|
|
60
|
+
externalUrl?: string;
|
|
57
61
|
startedAt: Date;
|
|
58
62
|
completedAt?: Date;
|
|
59
63
|
exitCode?: number;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ExternalExecutionMappingStore } from './mapping-store.js';
|
|
2
|
+
import { type ExternalIssueAdapter, type ExternalExecutionMapping, type IssueEnvelope } from './types.js';
|
|
2
3
|
type FetchIssueByKey = (key: string) => Promise<unknown>;
|
|
3
4
|
type FetchIssuesByQuery = (query: Record<string, unknown>) => Promise<unknown[]>;
|
|
4
5
|
interface AdapterFetchers {
|
|
@@ -13,6 +14,14 @@ export declare class LinearIssueAdapter implements ExternalIssueAdapter {
|
|
|
13
14
|
normalize(raw: unknown): IssueEnvelope;
|
|
14
15
|
fetchByKey(key: string): Promise<IssueEnvelope>;
|
|
15
16
|
fetchByQuery(query: Record<string, unknown>): Promise<IssueEnvelope[]>;
|
|
17
|
+
persistMapping(store: ExternalExecutionMappingStore, envelope: IssueEnvelope, params?: {
|
|
18
|
+
executionId?: string;
|
|
19
|
+
prUrl?: string;
|
|
20
|
+
lastSyncedAt?: Date;
|
|
21
|
+
lastSpawnedAt?: Date;
|
|
22
|
+
}): ExternalExecutionMapping;
|
|
23
|
+
readMappingByExternalId(store: ExternalExecutionMappingStore, externalId: string): ExternalExecutionMapping | null;
|
|
24
|
+
readMappingsByExecutionId(store: ExternalExecutionMappingStore, executionId: string): ExternalExecutionMapping[];
|
|
16
25
|
}
|
|
17
26
|
export declare class JiraIssueAdapter implements ExternalIssueAdapter {
|
|
18
27
|
readonly source: "jira";
|
|
@@ -22,5 +31,13 @@ export declare class JiraIssueAdapter implements ExternalIssueAdapter {
|
|
|
22
31
|
normalize(raw: unknown): IssueEnvelope;
|
|
23
32
|
fetchByKey(key: string): Promise<IssueEnvelope>;
|
|
24
33
|
fetchByQuery(query: Record<string, unknown>): Promise<IssueEnvelope[]>;
|
|
34
|
+
persistMapping(store: ExternalExecutionMappingStore, envelope: IssueEnvelope, params?: {
|
|
35
|
+
executionId?: string;
|
|
36
|
+
prUrl?: string;
|
|
37
|
+
lastSyncedAt?: Date;
|
|
38
|
+
lastSpawnedAt?: Date;
|
|
39
|
+
}): ExternalExecutionMapping;
|
|
40
|
+
readMappingByExternalId(store: ExternalExecutionMappingStore, externalId: string): ExternalExecutionMapping | null;
|
|
41
|
+
readMappingsByExecutionId(store: ExternalExecutionMappingStore, executionId: string): ExternalExecutionMapping[];
|
|
25
42
|
}
|
|
26
43
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { validateOrThrow } from './validation.js';
|
|
2
|
-
import { ExternalIssueError } from './types.js';
|
|
2
|
+
import { ExternalIssueError, } from './types.js';
|
|
3
3
|
function asRecord(value) {
|
|
4
4
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
5
5
|
return {};
|
|
@@ -206,6 +206,30 @@ export class LinearIssueAdapter {
|
|
|
206
206
|
const rawIssues = await fetchIssuesByQuery(query);
|
|
207
207
|
return rawIssues.map((raw) => this.normalize(raw));
|
|
208
208
|
}
|
|
209
|
+
persistMapping(store, envelope, params) {
|
|
210
|
+
return store.upsertMapping({
|
|
211
|
+
provider: this.source,
|
|
212
|
+
externalId: envelope.external_id,
|
|
213
|
+
externalKey: envelope.external_key,
|
|
214
|
+
canonicalUrl: envelope.url,
|
|
215
|
+
latestStateSnapshot: {
|
|
216
|
+
status: envelope.status,
|
|
217
|
+
priority: envelope.priority,
|
|
218
|
+
assignee: envelope.assignee,
|
|
219
|
+
projectKey: envelope.project_key,
|
|
220
|
+
},
|
|
221
|
+
executionId: params?.executionId,
|
|
222
|
+
prUrl: params?.prUrl,
|
|
223
|
+
lastSyncedAt: params?.lastSyncedAt,
|
|
224
|
+
lastSpawnedAt: params?.lastSpawnedAt,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
readMappingByExternalId(store, externalId) {
|
|
228
|
+
return store.getByExternalId(this.source, externalId);
|
|
229
|
+
}
|
|
230
|
+
readMappingsByExecutionId(store, executionId) {
|
|
231
|
+
return store.findByExecutionId(executionId).filter((mapping) => mapping.provider === this.source);
|
|
232
|
+
}
|
|
209
233
|
}
|
|
210
234
|
export class JiraIssueAdapter {
|
|
211
235
|
source = 'jira';
|
|
@@ -248,4 +272,28 @@ export class JiraIssueAdapter {
|
|
|
248
272
|
const rawIssues = await fetchIssuesByQuery(query);
|
|
249
273
|
return rawIssues.map((raw) => this.normalize(raw));
|
|
250
274
|
}
|
|
275
|
+
persistMapping(store, envelope, params) {
|
|
276
|
+
return store.upsertMapping({
|
|
277
|
+
provider: this.source,
|
|
278
|
+
externalId: envelope.external_id,
|
|
279
|
+
externalKey: envelope.external_key,
|
|
280
|
+
canonicalUrl: envelope.url,
|
|
281
|
+
latestStateSnapshot: {
|
|
282
|
+
status: envelope.status,
|
|
283
|
+
priority: envelope.priority,
|
|
284
|
+
assignee: envelope.assignee,
|
|
285
|
+
projectKey: envelope.project_key,
|
|
286
|
+
},
|
|
287
|
+
executionId: params?.executionId,
|
|
288
|
+
prUrl: params?.prUrl,
|
|
289
|
+
lastSyncedAt: params?.lastSyncedAt,
|
|
290
|
+
lastSpawnedAt: params?.lastSpawnedAt,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
readMappingByExternalId(store, externalId) {
|
|
294
|
+
return store.getByExternalId(this.source, externalId);
|
|
295
|
+
}
|
|
296
|
+
readMappingsByExecutionId(store, executionId) {
|
|
297
|
+
return store.findByExecutionId(executionId).filter((mapping) => mapping.provider === this.source);
|
|
298
|
+
}
|
|
251
299
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* Shared contract for normalizing external issues (Linear, Jira) into
|
|
5
5
|
* a canonical IssueEnvelope format with deterministic spawn context mapping.
|
|
6
6
|
*/
|
|
7
|
-
export { type IssueSource, type IssueEnvelope, type IssueSpawnContext, type IssueValidationError, type IssueValidationErrorCode, type IssueValidationResult, type ExternalIssueAdapter, type ExternalIssueErrorCode, type ExternalIssueAdapterErrorCode, type NormalizedIssueEnvelope, type IssueSourceMetadata, ISSUE_SOURCES, ExternalIssueError, ExternalIssueAdapterError, toNormalizedEnvelope, } from './types.js';
|
|
7
|
+
export { type IssueSource, type IssueEnvelope, type IssueSpawnContext, type IssueValidationError, type IssueValidationErrorCode, type IssueValidationResult, type ExternalIssueAdapter, type ExternalIssueErrorCode, type ExternalIssueAdapterErrorCode, type NormalizedIssueEnvelope, type IssueSourceMetadata, type ExternalMappingProvider, type ExternalExecutionMapping, type UpsertExternalExecutionMappingInput, ISSUE_SOURCES, ExternalIssueError, ExternalIssueAdapterError, toNormalizedEnvelope, } from './types.js';
|
|
8
8
|
export { validateIssueEnvelope, validateOrThrow, } from './validation.js';
|
|
9
9
|
export { mapToSpawnContext, } from './mapper.js';
|
|
10
10
|
export { LinearIssueAdapter, JiraIssueAdapter, } from './adapters.js';
|
|
11
|
+
export { ExternalExecutionMappingStore } from './mapping-store.js';
|
|
12
|
+
export { normalizeJiraIssue, normalizeJiraIssueToEnvelope, buildJiraTicketDescription, buildJiraMetadata, buildJiraSpawnContextMessage, getJiraIssueByKey, } from './jira.js';
|
|
13
|
+
export { resolveMirrorToPmo, type MirrorResolution, type MirrorResolutionInput, } from './work-start.js';
|
|
@@ -12,3 +12,8 @@ export { validateIssueEnvelope, validateOrThrow, } from './validation.js';
|
|
|
12
12
|
export { mapToSpawnContext, } from './mapper.js';
|
|
13
13
|
// Adapters
|
|
14
14
|
export { LinearIssueAdapter, JiraIssueAdapter, } from './adapters.js';
|
|
15
|
+
// Mapping store
|
|
16
|
+
export { ExternalExecutionMappingStore } from './mapping-store.js';
|
|
17
|
+
// Source helpers
|
|
18
|
+
export { normalizeJiraIssue, normalizeJiraIssueToEnvelope, buildJiraTicketDescription, buildJiraMetadata, buildJiraSpawnContextMessage, getJiraIssueByKey, } from './jira.js';
|
|
19
|
+
export { resolveMirrorToPmo, } from './work-start.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type IssueEnvelope, type NormalizedIssueEnvelope } from './types.js';
|
|
2
|
+
export interface JiraAdapterConfig {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
host?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
apiToken?: string;
|
|
7
|
+
projectKey?: string;
|
|
8
|
+
jql?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function normalizeJiraIssue(rawIssue: unknown): IssueEnvelope;
|
|
11
|
+
export declare function normalizeJiraIssueToEnvelope(rawIssue: unknown): NormalizedIssueEnvelope;
|
|
12
|
+
export declare function buildJiraTicketDescription(envelope: NormalizedIssueEnvelope): string;
|
|
13
|
+
export declare function buildJiraMetadata(envelope: NormalizedIssueEnvelope): Record<string, string>;
|
|
14
|
+
export declare function buildJiraSpawnContextMessage(envelope: NormalizedIssueEnvelope, additionalMessage?: string): string;
|
|
15
|
+
export declare function buildJiraIssueChoiceCommand(issueKey: string, projectId?: string): string;
|
|
16
|
+
export declare function getJiraIssueByKey(configInput: JiraAdapterConfig, issueKey: string, options?: {
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}): Promise<NormalizedIssueEnvelope | null>;
|
|
19
|
+
export declare function listJiraIssues(configInput: JiraAdapterConfig, options?: {
|
|
20
|
+
limit?: number;
|
|
21
|
+
jql?: string;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
}): Promise<NormalizedIssueEnvelope[]>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { ExternalIssueAdapterError, toNormalizedEnvelope, } from './types.js';
|
|
2
|
+
import { JiraIssueAdapter } from './adapters.js';
|
|
3
|
+
const DEFAULT_JIRA_API_PATH = '/rest/api/3';
|
|
4
|
+
const JIRA_ISSUE_FIELDS = [
|
|
5
|
+
'summary',
|
|
6
|
+
'description',
|
|
7
|
+
'labels',
|
|
8
|
+
'priority',
|
|
9
|
+
'status',
|
|
10
|
+
'project',
|
|
11
|
+
'issuetype',
|
|
12
|
+
'assignee',
|
|
13
|
+
];
|
|
14
|
+
function normalizeBaseUrl(value) {
|
|
15
|
+
const trimmed = value.trim().replace(/\/+$/, '');
|
|
16
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
17
|
+
return trimmed;
|
|
18
|
+
}
|
|
19
|
+
return `https://${trimmed}`;
|
|
20
|
+
}
|
|
21
|
+
function ensureJiraConfig(config) {
|
|
22
|
+
const baseUrl = config.baseUrl
|
|
23
|
+
|| config.host
|
|
24
|
+
|| process.env.PRLT_JIRA_BASE_URL
|
|
25
|
+
|| process.env.JIRA_BASE_URL
|
|
26
|
+
|| process.env.PRLT_JIRA_HOST
|
|
27
|
+
|| process.env.JIRA_HOST;
|
|
28
|
+
const email = config.email || process.env.PRLT_JIRA_EMAIL || process.env.JIRA_EMAIL || '';
|
|
29
|
+
const apiToken = config.apiToken || process.env.PRLT_JIRA_API_TOKEN || process.env.JIRA_API_TOKEN;
|
|
30
|
+
const projectKey = config.projectKey || process.env.PRLT_JIRA_PROJECT || process.env.JIRA_PROJECT_KEY || '';
|
|
31
|
+
const jql = config.jql || process.env.PRLT_JIRA_JQL || '';
|
|
32
|
+
if (!baseUrl) {
|
|
33
|
+
throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Jira base URL. Set PRLT_JIRA_BASE_URL/JIRA_BASE_URL or PRLT_JIRA_HOST/JIRA_HOST.');
|
|
34
|
+
}
|
|
35
|
+
if (!apiToken) {
|
|
36
|
+
throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Jira API token. Set PRLT_JIRA_API_TOKEN or JIRA_API_TOKEN.');
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
40
|
+
host: normalizeBaseUrl(baseUrl),
|
|
41
|
+
email,
|
|
42
|
+
apiToken,
|
|
43
|
+
projectKey,
|
|
44
|
+
jql,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function ensureIssueKey(key) {
|
|
48
|
+
const trimmed = key.trim().toUpperCase();
|
|
49
|
+
if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(trimmed)) {
|
|
50
|
+
throw new ExternalIssueAdapterError('BAD_PAYLOAD', `Invalid Jira issue key: "${key}".`);
|
|
51
|
+
}
|
|
52
|
+
return trimmed;
|
|
53
|
+
}
|
|
54
|
+
function buildAuthHeader(config) {
|
|
55
|
+
if (config.email) {
|
|
56
|
+
const token = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
|
|
57
|
+
return `Basic ${token}`;
|
|
58
|
+
}
|
|
59
|
+
return `Bearer ${config.apiToken}`;
|
|
60
|
+
}
|
|
61
|
+
function buildListJql(config, explicitJql) {
|
|
62
|
+
const jql = explicitJql?.trim() || config.jql.trim();
|
|
63
|
+
if (jql.length > 0) {
|
|
64
|
+
return jql;
|
|
65
|
+
}
|
|
66
|
+
const projectKey = config.projectKey.trim();
|
|
67
|
+
if (!projectKey) {
|
|
68
|
+
throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Jira project key/JQL. Pass --project-key or --jql, or set PRLT_JIRA_PROJECT/PRLT_JIRA_JQL.');
|
|
69
|
+
}
|
|
70
|
+
return `project = "${projectKey}" AND statusCategory != Done ORDER BY updated DESC`;
|
|
71
|
+
}
|
|
72
|
+
function getErrorMessage(payload, fallback) {
|
|
73
|
+
if (!payload || typeof payload !== 'object') {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
const errorMessages = payload.errorMessages;
|
|
77
|
+
if (Array.isArray(errorMessages) && typeof errorMessages[0] === 'string' && errorMessages[0].trim()) {
|
|
78
|
+
return errorMessages[0].trim();
|
|
79
|
+
}
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
async function parseJsonOrThrow(response) {
|
|
83
|
+
try {
|
|
84
|
+
return await response.json();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Jira response was not valid JSON.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function deriveJiraCategory(envelope) {
|
|
91
|
+
const typeValue = envelope.item_type?.trim().toLowerCase() || '';
|
|
92
|
+
if (typeValue === 'bug' || typeValue === 'incident') {
|
|
93
|
+
return 'bug';
|
|
94
|
+
}
|
|
95
|
+
if (typeValue === 'task' || typeValue === 'story' || typeValue === 'issue') {
|
|
96
|
+
return 'feature';
|
|
97
|
+
}
|
|
98
|
+
return 'feature';
|
|
99
|
+
}
|
|
100
|
+
function ensureIssueShape(issue) {
|
|
101
|
+
if (!issue.id || !issue.key || !issue.fields) {
|
|
102
|
+
throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Jira issue payload is missing required fields (id, key, fields).', issue);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function normalizeJiraIssue(rawIssue) {
|
|
106
|
+
if (!rawIssue || typeof rawIssue !== 'object') {
|
|
107
|
+
throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Jira issue payload is invalid.', rawIssue);
|
|
108
|
+
}
|
|
109
|
+
const issue = rawIssue;
|
|
110
|
+
ensureIssueShape(issue);
|
|
111
|
+
const adapter = new JiraIssueAdapter();
|
|
112
|
+
return adapter.normalize(issue);
|
|
113
|
+
}
|
|
114
|
+
export function normalizeJiraIssueToEnvelope(rawIssue) {
|
|
115
|
+
const envelope = normalizeJiraIssue(rawIssue);
|
|
116
|
+
return toNormalizedEnvelope(envelope, deriveJiraCategory(envelope));
|
|
117
|
+
}
|
|
118
|
+
export function buildJiraTicketDescription(envelope) {
|
|
119
|
+
const body = envelope.description.trim();
|
|
120
|
+
const metadataLines = [
|
|
121
|
+
`- Source: ${envelope.source.name}`,
|
|
122
|
+
`- External key: ${envelope.source.externalKey}`,
|
|
123
|
+
`- External id: ${envelope.source.externalId}`,
|
|
124
|
+
`- URL: ${envelope.source.url}`,
|
|
125
|
+
`- Status: ${envelope.status}`,
|
|
126
|
+
`- Priority: ${envelope.priority || 'Unset'}`,
|
|
127
|
+
`- Labels: ${envelope.labels.length > 0 ? envelope.labels.join(', ') : 'None'}`,
|
|
128
|
+
];
|
|
129
|
+
const parts = [
|
|
130
|
+
body,
|
|
131
|
+
'## External Issue Context',
|
|
132
|
+
metadataLines.join('\n'),
|
|
133
|
+
].filter(Boolean);
|
|
134
|
+
return parts.join('\n\n');
|
|
135
|
+
}
|
|
136
|
+
export function buildJiraMetadata(envelope) {
|
|
137
|
+
return {
|
|
138
|
+
external_source: envelope.source.name,
|
|
139
|
+
external_key: envelope.source.externalKey,
|
|
140
|
+
external_id: envelope.source.externalId,
|
|
141
|
+
external_url: envelope.source.url,
|
|
142
|
+
external_raw: JSON.stringify(envelope.source.raw),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function buildJiraSpawnContextMessage(envelope, additionalMessage) {
|
|
146
|
+
const lines = [
|
|
147
|
+
`External issue source: ${envelope.source.name}`,
|
|
148
|
+
`External issue key: ${envelope.source.externalKey}`,
|
|
149
|
+
`External issue id: ${envelope.source.externalId}`,
|
|
150
|
+
`External issue URL: ${envelope.source.url}`,
|
|
151
|
+
];
|
|
152
|
+
if (additionalMessage?.trim()) {
|
|
153
|
+
lines.push('', additionalMessage.trim());
|
|
154
|
+
}
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
export function buildJiraIssueChoiceCommand(issueKey, projectId) {
|
|
158
|
+
let command = `prlt work jira --issue ${issueKey} --json`;
|
|
159
|
+
if (projectId) {
|
|
160
|
+
command += ` -P ${projectId}`;
|
|
161
|
+
}
|
|
162
|
+
return command;
|
|
163
|
+
}
|
|
164
|
+
export async function getJiraIssueByKey(configInput, issueKey, options) {
|
|
165
|
+
const config = ensureJiraConfig(configInput);
|
|
166
|
+
const fetchImpl = options?.fetchImpl || fetch;
|
|
167
|
+
const key = ensureIssueKey(issueKey);
|
|
168
|
+
const baseApiUrl = `${config.baseUrl}${DEFAULT_JIRA_API_PATH}`;
|
|
169
|
+
const fields = encodeURIComponent(JIRA_ISSUE_FIELDS.join(','));
|
|
170
|
+
const issueUrl = `${baseApiUrl}/issue/${encodeURIComponent(key)}?fields=${fields}`;
|
|
171
|
+
const response = await fetchImpl(issueUrl, {
|
|
172
|
+
method: 'GET',
|
|
173
|
+
headers: {
|
|
174
|
+
Accept: 'application/json',
|
|
175
|
+
Authorization: buildAuthHeader(config),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
if (response.status === 401 || response.status === 403) {
|
|
179
|
+
throw new ExternalIssueAdapterError('AUTH_FAILED', 'Jira authentication failed. Verify your Jira credentials.');
|
|
180
|
+
}
|
|
181
|
+
if (response.status === 404) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const payload = await parseJsonOrThrow(response);
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
const msg = getErrorMessage(payload, `Jira request failed with status ${response.status}.`);
|
|
187
|
+
throw new ExternalIssueAdapterError('REQUEST_FAILED', msg, payload);
|
|
188
|
+
}
|
|
189
|
+
return normalizeJiraIssueToEnvelope(payload);
|
|
190
|
+
}
|
|
191
|
+
export async function listJiraIssues(configInput, options) {
|
|
192
|
+
const config = ensureJiraConfig(configInput);
|
|
193
|
+
const fetchImpl = options?.fetchImpl || fetch;
|
|
194
|
+
const limit = Math.max(1, Math.min(options?.limit ?? 20, 100));
|
|
195
|
+
const jql = buildListJql(config, options?.jql);
|
|
196
|
+
const baseApiUrl = `${config.baseUrl}${DEFAULT_JIRA_API_PATH}`;
|
|
197
|
+
const response = await fetchImpl(`${baseApiUrl}/search`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: {
|
|
200
|
+
'Content-Type': 'application/json',
|
|
201
|
+
Accept: 'application/json',
|
|
202
|
+
Authorization: buildAuthHeader(config),
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
jql,
|
|
206
|
+
maxResults: limit,
|
|
207
|
+
fields: JIRA_ISSUE_FIELDS,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
if (response.status === 401 || response.status === 403) {
|
|
211
|
+
throw new ExternalIssueAdapterError('AUTH_FAILED', 'Jira authentication failed. Verify your Jira credentials.');
|
|
212
|
+
}
|
|
213
|
+
const payload = await parseJsonOrThrow(response);
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const msg = getErrorMessage(payload, `Jira request failed with status ${response.status}.`);
|
|
216
|
+
throw new ExternalIssueAdapterError('REQUEST_FAILED', msg, payload);
|
|
217
|
+
}
|
|
218
|
+
const data = payload;
|
|
219
|
+
if (!Array.isArray(data.issues)) {
|
|
220
|
+
throw new ExternalIssueAdapterError('BAD_PAYLOAD', 'Jira response payload was missing issues array.', payload);
|
|
221
|
+
}
|
|
222
|
+
return data.issues.map(normalizeJiraIssueToEnvelope);
|
|
223
|
+
}
|
|
@@ -65,14 +65,15 @@ function priorityFromLinear(value) {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
function ensureLinearConfig(config) {
|
|
68
|
+
function ensureLinearConfig(config, options) {
|
|
69
69
|
const apiKey = config.apiKey || process.env.LINEAR_API_KEY || process.env.PRLT_LINEAR_API_KEY;
|
|
70
70
|
const team = config.team || process.env.PRLT_LINEAR_TEAM || process.env.LINEAR_TEAM_KEY;
|
|
71
71
|
const apiUrl = config.apiUrl || process.env.PRLT_LINEAR_API_URL || DEFAULT_LINEAR_API_URL;
|
|
72
72
|
if (!apiKey) {
|
|
73
73
|
throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Linear API key. Set LINEAR_API_KEY or PRLT_LINEAR_API_KEY.');
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
const requireTeam = options?.requireTeam ?? true;
|
|
76
|
+
if (requireTeam && !team) {
|
|
76
77
|
throw new ExternalIssueAdapterError('MISSING_CONFIG', 'Missing Linear team key. Pass --team or set PRLT_LINEAR_TEAM.');
|
|
77
78
|
}
|
|
78
79
|
return { apiKey, team, apiUrl };
|
|
@@ -180,7 +181,7 @@ export function buildLinearIssueChoiceCommand(issueIdentifier, projectId) {
|
|
|
180
181
|
* Fetch a single Linear issue by identifier (for example, ENG-123) and normalize it.
|
|
181
182
|
*/
|
|
182
183
|
export async function getLinearIssueByIdentifier(configInput, identifier, options) {
|
|
183
|
-
const config = ensureLinearConfig(configInput);
|
|
184
|
+
const config = ensureLinearConfig(configInput, { requireTeam: false });
|
|
184
185
|
const fetchImpl = options?.fetchImpl || fetch;
|
|
185
186
|
const response = await fetchImpl(config.apiUrl, {
|
|
186
187
|
method: 'POST',
|
|
@@ -15,7 +15,8 @@ import type { IssueEnvelope, IssueSpawnContext } from './types.js';
|
|
|
15
15
|
* The mapping is deterministic: the same IssueEnvelope always produces
|
|
16
16
|
* the same IssueSpawnContext.
|
|
17
17
|
*
|
|
18
|
-
* @param
|
|
18
|
+
* @param input - IssueEnvelope-like input
|
|
19
19
|
* @returns Spawn context with prompt and metadata
|
|
20
|
+
* @throws ExternalIssueError when input fails IssueEnvelope validation
|
|
20
21
|
*/
|
|
21
|
-
export declare function mapToSpawnContext(
|
|
22
|
+
export declare function mapToSpawnContext(input: IssueEnvelope | unknown): IssueSpawnContext;
|