prjct-cli 0.42.0 → 0.44.1
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/CHANGELOG.md +97 -0
- package/core/agentic/command-executor.ts +15 -5
- package/core/ai-tools/formatters.ts +302 -0
- package/core/ai-tools/generator.ts +124 -0
- package/core/ai-tools/index.ts +15 -0
- package/core/ai-tools/registry.ts +195 -0
- package/core/cli/linear.ts +61 -2
- package/core/commands/analysis.ts +36 -2
- package/core/commands/commands.ts +2 -2
- package/core/commands/planning.ts +8 -4
- package/core/commands/shipping.ts +9 -7
- package/core/commands/workflow.ts +67 -17
- package/core/index.ts +3 -1
- package/core/infrastructure/ai-provider.ts +11 -36
- package/core/integrations/issue-tracker/types.ts +7 -1
- package/core/integrations/linear/client.ts +56 -24
- package/core/integrations/linear/index.ts +3 -0
- package/core/integrations/linear/sync.ts +313 -0
- package/core/schemas/index.ts +3 -0
- package/core/schemas/issues.ts +144 -0
- package/core/schemas/state.ts +3 -0
- package/core/services/sync-service.ts +71 -4
- package/core/utils/agent-stream.ts +138 -0
- package/core/utils/branding.ts +2 -3
- package/core/utils/next-steps.ts +95 -0
- package/core/utils/output.ts +26 -0
- package/core/workflow/index.ts +6 -0
- package/core/workflow/state-machine.ts +185 -0
- package/dist/bin/prjct.mjs +2382 -541
- package/package.json +1 -1
- package/templates/_bases/tracker-base.md +11 -0
- package/templates/commands/done.md +18 -13
- package/templates/commands/git.md +143 -54
- package/templates/commands/merge.md +121 -13
- package/templates/commands/review.md +1 -1
- package/templates/commands/ship.md +165 -20
- package/templates/commands/sync.md +17 -0
- package/templates/commands/task.md +123 -17
- package/templates/global/ANTIGRAVITY.md +2 -4
- package/templates/global/CLAUDE.md +115 -28
- package/templates/global/CURSOR.mdc +1 -3
- package/templates/global/GEMINI.md +2 -4
- package/templates/global/WINDSURF.md +1 -3
- package/templates/subagents/workflow/prjct-shipper.md +1 -2
|
@@ -283,45 +283,20 @@ export function hasProviderConfig(provider: AIProviderName): boolean {
|
|
|
283
283
|
* Get provider-specific branding
|
|
284
284
|
*/
|
|
285
285
|
export function getProviderBranding(provider: AIProviderName): ProviderBranding {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (provider === 'cursor') {
|
|
297
|
-
return {
|
|
298
|
-
commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
|
|
299
|
-
Built with [Cursor](${config.websiteUrl})`,
|
|
300
|
-
signature: '⚡ prjct + Cursor',
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (provider === 'antigravity') {
|
|
305
|
-
return {
|
|
306
|
-
commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
|
|
307
|
-
Powered by [Antigravity](${config.websiteUrl})`,
|
|
308
|
-
signature: '⚡ prjct + Antigravity',
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (provider === 'windsurf') {
|
|
313
|
-
return {
|
|
314
|
-
commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
|
|
315
|
-
Built with [Windsurf](${config.websiteUrl})`,
|
|
316
|
-
signature: '⚡ prjct + Windsurf',
|
|
317
|
-
}
|
|
286
|
+
// Generic commit footer for all providers
|
|
287
|
+
const commitFooter = `Generated with [p/](https://www.prjct.app/)`
|
|
288
|
+
|
|
289
|
+
const signatures: Record<AIProviderName, string> = {
|
|
290
|
+
claude: '⚡ prjct + Claude',
|
|
291
|
+
gemini: '⚡ prjct + Gemini',
|
|
292
|
+
cursor: '⚡ prjct + Cursor',
|
|
293
|
+
antigravity: '⚡ prjct + Antigravity',
|
|
294
|
+
windsurf: '⚡ prjct + Windsurf',
|
|
318
295
|
}
|
|
319
296
|
|
|
320
|
-
// Default: Claude
|
|
321
297
|
return {
|
|
322
|
-
commitFooter
|
|
323
|
-
|
|
324
|
-
signature: '⚡ prjct + Claude',
|
|
298
|
+
commitFooter,
|
|
299
|
+
signature: signatures[provider] || '⚡ prjct',
|
|
325
300
|
}
|
|
326
301
|
}
|
|
327
302
|
|
|
@@ -75,10 +75,16 @@ export interface CreateIssueInput {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* Update input for
|
|
78
|
+
* Update input for issues
|
|
79
79
|
*/
|
|
80
80
|
export interface UpdateIssueInput {
|
|
81
|
+
title?: string
|
|
81
82
|
description?: string
|
|
83
|
+
priority?: IssuePriority
|
|
84
|
+
assigneeId?: string | null // null to unassign
|
|
85
|
+
stateId?: string
|
|
86
|
+
projectId?: string
|
|
87
|
+
labels?: string[]
|
|
82
88
|
// Provider-specific: may update custom fields for AC, etc.
|
|
83
89
|
customFields?: Record<string, unknown>
|
|
84
90
|
}
|
|
@@ -84,7 +84,8 @@ export class LinearProvider implements IssueTrackerProvider {
|
|
|
84
84
|
// Verify connection
|
|
85
85
|
try {
|
|
86
86
|
const viewer = await this.sdk.viewer
|
|
87
|
-
|
|
87
|
+
// Use stderr for logs to not break JSON output
|
|
88
|
+
console.error(`[linear] Connected as ${viewer.name} (${viewer.email})`)
|
|
88
89
|
} catch (error) {
|
|
89
90
|
this.sdk = null
|
|
90
91
|
throw new Error(`Linear connection failed: ${(error as Error).message}`)
|
|
@@ -93,16 +94,28 @@ export class LinearProvider implements IssueTrackerProvider {
|
|
|
93
94
|
|
|
94
95
|
/**
|
|
95
96
|
* Get issues assigned to current user
|
|
97
|
+
* Filters by configured team if defaultTeamId is set
|
|
96
98
|
*/
|
|
97
99
|
async fetchAssignedIssues(options?: FetchOptions): Promise<Issue[]> {
|
|
98
100
|
if (!this.sdk) throw new Error('Linear not initialized')
|
|
99
101
|
|
|
100
102
|
const viewer = await this.sdk.viewer
|
|
103
|
+
|
|
104
|
+
// Build filter - always filter by team if configured
|
|
105
|
+
const filter: Record<string, unknown> = {}
|
|
106
|
+
|
|
107
|
+
if (!options?.includeCompleted) {
|
|
108
|
+
filter.state = { type: { nin: ['completed', 'canceled'] } }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter by configured team to only show relevant issues
|
|
112
|
+
if (this.config?.defaultTeamId) {
|
|
113
|
+
filter.team = { id: { eq: this.config.defaultTeamId } }
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
const assignedIssues = await viewer.assignedIssues({
|
|
102
117
|
first: options?.limit || 50,
|
|
103
|
-
filter:
|
|
104
|
-
? undefined
|
|
105
|
-
: { state: { type: { nin: ['completed', 'canceled'] } } },
|
|
118
|
+
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
|
106
119
|
})
|
|
107
120
|
|
|
108
121
|
return Promise.all(
|
|
@@ -128,28 +141,34 @@ export class LinearProvider implements IssueTrackerProvider {
|
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
/**
|
|
131
|
-
* Get a single issue by ID or identifier (e.g., "
|
|
144
|
+
* Get a single issue by ID or identifier (e.g., "PRJ-123")
|
|
132
145
|
*/
|
|
133
146
|
async fetchIssue(id: string): Promise<Issue | null> {
|
|
134
147
|
if (!this.sdk) throw new Error('Linear not initialized')
|
|
135
148
|
|
|
136
149
|
try {
|
|
137
|
-
// Check if it looks like an identifier (e.g., "
|
|
150
|
+
// Check if it looks like an identifier (e.g., "PRJ-123")
|
|
138
151
|
if (id.includes('-') && /^[A-Z]+-\d+$/.test(id)) {
|
|
139
|
-
//
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
// Parse identifier into team key and issue number
|
|
153
|
+
const match = id.match(/^([A-Z]+)-(\d+)$/)
|
|
154
|
+
if (!match) return null
|
|
155
|
+
|
|
156
|
+
const [, teamKey, numberStr] = match
|
|
157
|
+
const issueNumber = parseInt(numberStr, 10)
|
|
158
|
+
|
|
159
|
+
// Find team by key
|
|
160
|
+
const teams = await this.sdk.teams({ first: 50 })
|
|
161
|
+
const team = teams.nodes.find((t) => t.key === teamKey)
|
|
162
|
+
if (!team) return null
|
|
163
|
+
|
|
164
|
+
// Query issue by team and number
|
|
165
|
+
const issues = await team.issues({
|
|
166
|
+
first: 1,
|
|
167
|
+
filter: { number: { eq: issueNumber } },
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
if (issues.nodes.length > 0) {
|
|
171
|
+
return this.mapIssue(issues.nodes[0])
|
|
153
172
|
}
|
|
154
173
|
return null
|
|
155
174
|
}
|
|
@@ -197,15 +216,28 @@ export class LinearProvider implements IssueTrackerProvider {
|
|
|
197
216
|
async updateIssue(id: string, input: UpdateIssueInput): Promise<Issue> {
|
|
198
217
|
if (!this.sdk) throw new Error('Linear not initialized')
|
|
199
218
|
|
|
200
|
-
// Get the issue first to get UUID
|
|
219
|
+
// Get the issue first to get UUID (if identifier like PRJ-123 was passed)
|
|
201
220
|
const issue = await this.fetchIssue(id)
|
|
202
221
|
if (!issue) {
|
|
203
222
|
throw new Error(`Issue ${id} not found`)
|
|
204
223
|
}
|
|
205
224
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
225
|
+
// Build update payload with all supported fields
|
|
226
|
+
const updatePayload: Record<string, unknown> = {}
|
|
227
|
+
|
|
228
|
+
if (input.title !== undefined) updatePayload.title = input.title
|
|
229
|
+
if (input.description !== undefined) updatePayload.description = input.description
|
|
230
|
+
if (input.priority !== undefined) updatePayload.priority = PRIORITY_TO_LINEAR[input.priority]
|
|
231
|
+
if (input.assigneeId !== undefined) updatePayload.assigneeId = input.assigneeId
|
|
232
|
+
if (input.stateId !== undefined) updatePayload.stateId = input.stateId
|
|
233
|
+
if (input.projectId !== undefined) updatePayload.projectId = input.projectId
|
|
234
|
+
|
|
235
|
+
// Handle labels - need to resolve names to IDs
|
|
236
|
+
if (input.labels !== undefined && issue.team) {
|
|
237
|
+
updatePayload.labelIds = await this.resolveLabelIds(issue.team.id, input.labels)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await this.sdk.updateIssue(issue.id, updatePayload)
|
|
209
241
|
|
|
210
242
|
// Fetch updated issue
|
|
211
243
|
const updated = await this.fetchIssue(issue.id)
|
|
@@ -9,6 +9,9 @@ export { LinearProvider, linearProvider } from './client'
|
|
|
9
9
|
// Service layer with caching (preferred API)
|
|
10
10
|
export { LinearService, linearService } from './service'
|
|
11
11
|
|
|
12
|
+
// Sync layer for bidirectional sync with issues.json
|
|
13
|
+
export { LinearSync, linearSync } from './sync'
|
|
14
|
+
|
|
12
15
|
// Cache utilities
|
|
13
16
|
export {
|
|
14
17
|
issueCache,
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear Sync Layer
|
|
3
|
+
*
|
|
4
|
+
* Bidirectional sync between Linear and local prjct storage.
|
|
5
|
+
* Uses issues.json as local cache with 30-minute staleness.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* Linear (source of truth)
|
|
9
|
+
* ↕
|
|
10
|
+
* Sync Layer (this file)
|
|
11
|
+
* ↕
|
|
12
|
+
* storage/issues.json ← FULL COPY of Linear issues
|
|
13
|
+
* ↕
|
|
14
|
+
* state.json.currentTask.linearId ← DIRECT LINK
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile, writeFile, mkdir } from 'fs/promises'
|
|
18
|
+
import { existsSync } from 'fs'
|
|
19
|
+
import { join } from 'path'
|
|
20
|
+
import { linearService } from './service'
|
|
21
|
+
import { getProjectPath } from '../../schemas/schemas'
|
|
22
|
+
import {
|
|
23
|
+
type IssuesJson,
|
|
24
|
+
type CachedIssue,
|
|
25
|
+
type SyncResult,
|
|
26
|
+
createEmptyIssues,
|
|
27
|
+
parseIssues,
|
|
28
|
+
} from '../../schemas/issues'
|
|
29
|
+
import type { Issue } from '../issue-tracker/types'
|
|
30
|
+
|
|
31
|
+
// Default staleness threshold: 30 minutes
|
|
32
|
+
const DEFAULT_STALE_AFTER = 30 * 60 * 1000
|
|
33
|
+
|
|
34
|
+
export class LinearSync {
|
|
35
|
+
/**
|
|
36
|
+
* Pull all assigned issues from Linear and store in issues.json
|
|
37
|
+
* This is the main sync operation - call on `p. sync`
|
|
38
|
+
*/
|
|
39
|
+
async pullAll(projectId: string): Promise<SyncResult> {
|
|
40
|
+
const storagePath = join(getProjectPath(projectId), 'storage')
|
|
41
|
+
const issuesPath = join(storagePath, 'issues.json')
|
|
42
|
+
|
|
43
|
+
// Ensure storage directory exists
|
|
44
|
+
if (!existsSync(storagePath)) {
|
|
45
|
+
await mkdir(storagePath, { recursive: true })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const timestamp = new Date().toISOString()
|
|
49
|
+
const errors: Array<{ issueId: string; error: string }> = []
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Fetch all assigned issues from Linear
|
|
53
|
+
const issues = await linearService.fetchAssignedIssues({ limit: 100 })
|
|
54
|
+
|
|
55
|
+
// Convert to cached format
|
|
56
|
+
const issuesMap: Record<string, CachedIssue> = {}
|
|
57
|
+
for (const issue of issues) {
|
|
58
|
+
try {
|
|
59
|
+
issuesMap[issue.externalId] = this.toCachedIssue(issue, timestamp)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
errors.push({
|
|
62
|
+
issueId: issue.externalId || issue.id,
|
|
63
|
+
error: (err as Error).message,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Write to issues.json
|
|
69
|
+
const issuesJson: IssuesJson = {
|
|
70
|
+
provider: 'linear',
|
|
71
|
+
lastSync: timestamp,
|
|
72
|
+
staleAfter: DEFAULT_STALE_AFTER,
|
|
73
|
+
issues: issuesMap,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await writeFile(issuesPath, JSON.stringify(issuesJson, null, 2))
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
provider: 'linear',
|
|
80
|
+
fetched: issues.length,
|
|
81
|
+
updated: Object.keys(issuesMap).length,
|
|
82
|
+
errors,
|
|
83
|
+
timestamp,
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
errors.push({
|
|
87
|
+
issueId: 'all',
|
|
88
|
+
error: (err as Error).message,
|
|
89
|
+
})
|
|
90
|
+
return {
|
|
91
|
+
provider: 'linear',
|
|
92
|
+
fetched: 0,
|
|
93
|
+
updated: 0,
|
|
94
|
+
errors,
|
|
95
|
+
timestamp,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get issue from local cache, fetch from API if not found or stale
|
|
102
|
+
* Local-first approach for performance
|
|
103
|
+
*/
|
|
104
|
+
async getIssue(projectId: string, identifier: string): Promise<CachedIssue | null> {
|
|
105
|
+
const issuesJson = await this.loadIssues(projectId)
|
|
106
|
+
|
|
107
|
+
// Check local cache first
|
|
108
|
+
if (issuesJson && issuesJson.issues[identifier]) {
|
|
109
|
+
const cachedIssue = issuesJson.issues[identifier]
|
|
110
|
+
|
|
111
|
+
// Check if cached issue is still fresh (within fetchedAt + some grace period)
|
|
112
|
+
const fetchedAt = new Date(cachedIssue.fetchedAt).getTime()
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
const issueStaleness = 10 * 60 * 1000 // 10 minutes for individual issues
|
|
115
|
+
|
|
116
|
+
if (now - fetchedAt < issueStaleness) {
|
|
117
|
+
return cachedIssue
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Not in cache or stale - fetch from API and update cache
|
|
122
|
+
try {
|
|
123
|
+
const issue = await linearService.fetchIssue(identifier)
|
|
124
|
+
if (!issue) return null
|
|
125
|
+
|
|
126
|
+
const timestamp = new Date().toISOString()
|
|
127
|
+
const cachedIssue = this.toCachedIssue(issue, timestamp)
|
|
128
|
+
|
|
129
|
+
// Update cache with this single issue
|
|
130
|
+
await this.updateIssueInCache(projectId, identifier, cachedIssue)
|
|
131
|
+
|
|
132
|
+
return cachedIssue
|
|
133
|
+
} catch {
|
|
134
|
+
// API failed, return cached version if available (even if stale)
|
|
135
|
+
if (issuesJson?.issues[identifier]) {
|
|
136
|
+
return issuesJson.issues[identifier]
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get issue from local cache ONLY (no API call)
|
|
144
|
+
* Use for fast lookups when you know the issue should be cached
|
|
145
|
+
*/
|
|
146
|
+
async getIssueLocal(projectId: string, identifier: string): Promise<CachedIssue | null> {
|
|
147
|
+
const issuesJson = await this.loadIssues(projectId)
|
|
148
|
+
return issuesJson?.issues[identifier] || null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Push local status change to Linear
|
|
153
|
+
* Called when task status changes (in_progress, done)
|
|
154
|
+
*/
|
|
155
|
+
async pushStatus(
|
|
156
|
+
projectId: string,
|
|
157
|
+
identifier: string,
|
|
158
|
+
status: 'in_progress' | 'done'
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
// Update Linear
|
|
161
|
+
if (status === 'in_progress') {
|
|
162
|
+
await linearService.markInProgress(identifier)
|
|
163
|
+
} else if (status === 'done') {
|
|
164
|
+
await linearService.markDone(identifier)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Update local cache to reflect the change
|
|
168
|
+
const issuesJson = await this.loadIssues(projectId)
|
|
169
|
+
if (issuesJson?.issues[identifier]) {
|
|
170
|
+
const cachedStatus = status === 'done' ? 'done' : 'in_progress'
|
|
171
|
+
issuesJson.issues[identifier].status = cachedStatus
|
|
172
|
+
issuesJson.issues[identifier].fetchedAt = new Date().toISOString()
|
|
173
|
+
|
|
174
|
+
await this.saveIssues(projectId, issuesJson)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check if the local issues cache is stale
|
|
180
|
+
* Staleness = lastSync is older than staleAfter threshold
|
|
181
|
+
*/
|
|
182
|
+
async isStale(projectId: string): Promise<boolean> {
|
|
183
|
+
const issuesJson = await this.loadIssues(projectId)
|
|
184
|
+
|
|
185
|
+
if (!issuesJson || !issuesJson.lastSync) {
|
|
186
|
+
return true // No cache = stale
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lastSyncTime = new Date(issuesJson.lastSync).getTime()
|
|
190
|
+
const now = Date.now()
|
|
191
|
+
const staleAfter = issuesJson.staleAfter || DEFAULT_STALE_AFTER
|
|
192
|
+
|
|
193
|
+
return now - lastSyncTime > staleAfter
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get sync status for display
|
|
198
|
+
*/
|
|
199
|
+
async getSyncStatus(projectId: string): Promise<{
|
|
200
|
+
hasCache: boolean
|
|
201
|
+
lastSync: string | null
|
|
202
|
+
issueCount: number
|
|
203
|
+
isStale: boolean
|
|
204
|
+
}> {
|
|
205
|
+
const issuesJson = await this.loadIssues(projectId)
|
|
206
|
+
|
|
207
|
+
if (!issuesJson) {
|
|
208
|
+
return {
|
|
209
|
+
hasCache: false,
|
|
210
|
+
lastSync: null,
|
|
211
|
+
issueCount: 0,
|
|
212
|
+
isStale: true,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
hasCache: true,
|
|
218
|
+
lastSync: issuesJson.lastSync || null,
|
|
219
|
+
issueCount: Object.keys(issuesJson.issues).length,
|
|
220
|
+
isStale: await this.isStale(projectId),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* List all cached issues
|
|
226
|
+
*/
|
|
227
|
+
async listCachedIssues(projectId: string): Promise<CachedIssue[]> {
|
|
228
|
+
const issuesJson = await this.loadIssues(projectId)
|
|
229
|
+
if (!issuesJson) return []
|
|
230
|
+
|
|
231
|
+
return Object.values(issuesJson.issues)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// =============================================================================
|
|
235
|
+
// Private Helpers
|
|
236
|
+
// =============================================================================
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Load issues.json from disk
|
|
240
|
+
*/
|
|
241
|
+
private async loadIssues(projectId: string): Promise<IssuesJson | null> {
|
|
242
|
+
const issuesPath = join(getProjectPath(projectId), 'storage', 'issues.json')
|
|
243
|
+
|
|
244
|
+
if (!existsSync(issuesPath)) {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const content = await readFile(issuesPath, 'utf-8')
|
|
250
|
+
return parseIssues(JSON.parse(content))
|
|
251
|
+
} catch {
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Save issues.json to disk
|
|
258
|
+
*/
|
|
259
|
+
private async saveIssues(projectId: string, issuesJson: IssuesJson): Promise<void> {
|
|
260
|
+
const storagePath = join(getProjectPath(projectId), 'storage')
|
|
261
|
+
const issuesPath = join(storagePath, 'issues.json')
|
|
262
|
+
|
|
263
|
+
if (!existsSync(storagePath)) {
|
|
264
|
+
await mkdir(storagePath, { recursive: true })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await writeFile(issuesPath, JSON.stringify(issuesJson, null, 2))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Update a single issue in the cache
|
|
272
|
+
*/
|
|
273
|
+
private async updateIssueInCache(
|
|
274
|
+
projectId: string,
|
|
275
|
+
identifier: string,
|
|
276
|
+
issue: CachedIssue
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
let issuesJson = await this.loadIssues(projectId)
|
|
279
|
+
|
|
280
|
+
if (!issuesJson) {
|
|
281
|
+
issuesJson = createEmptyIssues('linear')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
issuesJson.issues[identifier] = issue
|
|
285
|
+
await this.saveIssues(projectId, issuesJson)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Convert API Issue to CachedIssue format
|
|
290
|
+
*/
|
|
291
|
+
private toCachedIssue(issue: Issue, timestamp: string): CachedIssue {
|
|
292
|
+
return {
|
|
293
|
+
id: issue.id,
|
|
294
|
+
identifier: issue.externalId,
|
|
295
|
+
title: issue.title,
|
|
296
|
+
description: issue.description,
|
|
297
|
+
status: issue.status,
|
|
298
|
+
priority: issue.priority,
|
|
299
|
+
type: issue.type,
|
|
300
|
+
assignee: issue.assignee,
|
|
301
|
+
labels: issue.labels,
|
|
302
|
+
team: issue.team,
|
|
303
|
+
project: issue.project,
|
|
304
|
+
url: issue.url,
|
|
305
|
+
createdAt: issue.createdAt,
|
|
306
|
+
updatedAt: issue.updatedAt,
|
|
307
|
+
fetchedAt: timestamp,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Singleton instance
|
|
313
|
+
export const linearSync = new LinearSync()
|
package/core/schemas/index.ts
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issues Schema
|
|
3
|
+
*
|
|
4
|
+
* Defines the structure for issues.json - local cache of issue tracker issues.
|
|
5
|
+
* Used for bidirectional sync with Linear/JIRA/etc.
|
|
6
|
+
*
|
|
7
|
+
* Location: ~/.prjct-cli/projects/{projectId}/storage/issues.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Issue Provider Types
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
export const IssueProviderSchema = z.enum(['linear', 'jira', 'github', 'monday', 'asana', 'none'])
|
|
17
|
+
export const IssueStatusSchema = z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done', 'cancelled'])
|
|
18
|
+
export const IssuePrioritySchema = z.enum(['none', 'urgent', 'high', 'medium', 'low'])
|
|
19
|
+
export const IssueTypeSchema = z.enum(['feature', 'bug', 'improvement', 'task', 'chore', 'epic'])
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Cached Issue Schema
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Single cached issue from provider
|
|
27
|
+
*/
|
|
28
|
+
export const CachedIssueSchema = z.object({
|
|
29
|
+
// Core identifiers
|
|
30
|
+
id: z.string(), // Provider UUID
|
|
31
|
+
identifier: z.string(), // Human-readable ID (e.g., "PRJ-123")
|
|
32
|
+
|
|
33
|
+
// Issue content
|
|
34
|
+
title: z.string(),
|
|
35
|
+
description: z.string().optional(),
|
|
36
|
+
|
|
37
|
+
// State
|
|
38
|
+
status: IssueStatusSchema,
|
|
39
|
+
priority: IssuePrioritySchema,
|
|
40
|
+
type: IssueTypeSchema.optional(),
|
|
41
|
+
|
|
42
|
+
// Metadata
|
|
43
|
+
assignee: z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
name: z.string(),
|
|
46
|
+
email: z.string().optional(),
|
|
47
|
+
}).optional(),
|
|
48
|
+
labels: z.array(z.string()).default([]),
|
|
49
|
+
team: z.object({
|
|
50
|
+
id: z.string(),
|
|
51
|
+
name: z.string(),
|
|
52
|
+
key: z.string().optional(),
|
|
53
|
+
}).optional(),
|
|
54
|
+
project: z.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
name: z.string(),
|
|
57
|
+
}).optional(),
|
|
58
|
+
|
|
59
|
+
// URLs and timestamps
|
|
60
|
+
url: z.string(),
|
|
61
|
+
createdAt: z.string(), // ISO8601 from provider
|
|
62
|
+
updatedAt: z.string(), // ISO8601 from provider
|
|
63
|
+
fetchedAt: z.string(), // ISO8601 when we cached it
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// Issues JSON Schema (Root)
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Root schema for issues.json
|
|
72
|
+
* Maps identifier -> CachedIssue for quick lookup
|
|
73
|
+
*/
|
|
74
|
+
export const IssuesJsonSchema = z.object({
|
|
75
|
+
// Provider info
|
|
76
|
+
provider: IssueProviderSchema,
|
|
77
|
+
|
|
78
|
+
// Sync metadata
|
|
79
|
+
lastSync: z.string(), // ISO8601 of last full sync
|
|
80
|
+
staleAfter: z.number().default(1800000), // 30 minutes in ms
|
|
81
|
+
|
|
82
|
+
// Issues map: identifier -> issue
|
|
83
|
+
issues: z.record(z.string(), CachedIssueSchema),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Sync Result Schema
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
export const SyncResultSchema = z.object({
|
|
91
|
+
provider: IssueProviderSchema,
|
|
92
|
+
fetched: z.number(),
|
|
93
|
+
updated: z.number(),
|
|
94
|
+
errors: z.array(z.object({
|
|
95
|
+
issueId: z.string(),
|
|
96
|
+
error: z.string(),
|
|
97
|
+
})),
|
|
98
|
+
timestamp: z.string(),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// =============================================================================
|
|
102
|
+
// Inferred Types
|
|
103
|
+
// =============================================================================
|
|
104
|
+
|
|
105
|
+
export type IssueProvider = z.infer<typeof IssueProviderSchema>
|
|
106
|
+
export type IssueStatus = z.infer<typeof IssueStatusSchema>
|
|
107
|
+
export type IssuePriority = z.infer<typeof IssuePrioritySchema>
|
|
108
|
+
export type IssueType = z.infer<typeof IssueTypeSchema>
|
|
109
|
+
export type CachedIssue = z.infer<typeof CachedIssueSchema>
|
|
110
|
+
export type IssuesJson = z.infer<typeof IssuesJsonSchema>
|
|
111
|
+
export type SyncResult = z.infer<typeof SyncResultSchema>
|
|
112
|
+
|
|
113
|
+
// =============================================================================
|
|
114
|
+
// Validation Helpers
|
|
115
|
+
// =============================================================================
|
|
116
|
+
|
|
117
|
+
/** Parse and validate issues.json content */
|
|
118
|
+
export const parseIssues = (data: unknown): IssuesJson => IssuesJsonSchema.parse(data)
|
|
119
|
+
|
|
120
|
+
/** Safe parse with error result */
|
|
121
|
+
export const safeParseIssues = (data: unknown) => IssuesJsonSchema.safeParse(data)
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// Defaults
|
|
125
|
+
// =============================================================================
|
|
126
|
+
|
|
127
|
+
export const DEFAULT_ISSUES: IssuesJson = {
|
|
128
|
+
provider: 'none',
|
|
129
|
+
lastSync: '',
|
|
130
|
+
staleAfter: 1800000, // 30 minutes
|
|
131
|
+
issues: {},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create empty issues.json for a provider
|
|
136
|
+
*/
|
|
137
|
+
export function createEmptyIssues(provider: IssueProvider): IssuesJson {
|
|
138
|
+
return {
|
|
139
|
+
provider,
|
|
140
|
+
lastSync: '',
|
|
141
|
+
staleAfter: 1800000,
|
|
142
|
+
issues: {},
|
|
143
|
+
}
|
|
144
|
+
}
|
package/core/schemas/state.ts
CHANGED
|
@@ -64,6 +64,9 @@ export const CurrentTaskSchema = z.object({
|
|
|
64
64
|
subtasks: z.array(SubtaskSchema).optional(),
|
|
65
65
|
currentSubtaskIndex: z.number().optional(),
|
|
66
66
|
subtaskProgress: SubtaskProgressSchema.optional(),
|
|
67
|
+
// Linear integration - bidirectional sync
|
|
68
|
+
linearId: z.string().optional(), // "PRJ-123" - Linear identifier
|
|
69
|
+
linearUuid: z.string().optional(), // Linear internal UUID for API calls
|
|
67
70
|
})
|
|
68
71
|
|
|
69
72
|
export const PreviousTaskSchema = z.object({
|