prjct-cli 0.30.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.3] - 2026-01-13
4
+
5
+ ### Fix: Enrichment Not Enabled by Default
6
+
7
+ **Problem:** `CONFIG_ENRICHMENT_ENABLED` was not set in statusline config, so enrichment feature was not active by default when Claude Code started.
8
+
9
+ **Solution:**
10
+ - Added `enrichment.enabled: true` to `default-config.json`
11
+ - Added `DEFAULT_ENRICHMENT_ENABLED="true"` to `config.sh`
12
+ - Enrichment setting now parses from config with `true` as default
13
+
14
+ **Files:**
15
+ - `assets/statusline/default-config.json` - Added enrichment component
16
+ - `assets/statusline/lib/config.sh` - Added enrichment config parsing
17
+
18
+ ---
19
+
3
20
  ## [0.30.2] - 2026-01-13
4
21
 
5
22
  ### Feature: PM Expert Auto-Enrichment
@@ -0,0 +1,108 @@
1
+ #!/bin/bash
2
+ # prjct statusline - JIRA integration component
3
+ # Displays the linked JIRA issue key and priority
4
+
5
+ component_jira() {
6
+ component_enabled "jira" || return
7
+ [[ "${CONFIG_JIRA_ENABLED}" != "true" ]] && return
8
+
9
+ local cache_file="${CACHE_DIR}/jira.cache"
10
+ local issue_data=""
11
+
12
+ # Check cache first
13
+ if cache_valid "$cache_file" "$CONFIG_CACHE_TTL_JIRA"; then
14
+ issue_data=$(cat "$cache_file")
15
+ else
16
+ # Get project ID
17
+ local project_id=$(get_project_id)
18
+ [[ -z "$project_id" ]] && return
19
+
20
+ local global_path="${HOME}/.prjct-cli/projects/${project_id}"
21
+ local state_file="${global_path}/storage/state.json"
22
+ local issues_file="${global_path}/storage/issues.json"
23
+
24
+ # Check if state file exists
25
+ [[ ! -f "$state_file" ]] && return
26
+
27
+ # Get linked issue from current task
28
+ local linked_issue=""
29
+
30
+ # First check if current task has a linked JIRA issue
31
+ local linked_provider=$(jq -r '.currentTask.linkedIssue.provider // ""' "$state_file" 2>/dev/null)
32
+ if [[ "$linked_provider" == "jira" ]]; then
33
+ linked_issue=$(jq -r '.currentTask.linkedIssue.id // ""' "$state_file" 2>/dev/null)
34
+ fi
35
+
36
+ # If no linked issue, try to get from issues.json (last worked)
37
+ if [[ -z "$linked_issue" ]] && [[ -f "$issues_file" ]]; then
38
+ # Check if issues.json is for JIRA
39
+ local provider=$(jq -r '.provider // ""' "$issues_file" 2>/dev/null)
40
+ if [[ "$provider" == "jira" ]]; then
41
+ # Get most recently updated issue that's in_progress
42
+ linked_issue=$(jq -r '
43
+ .issues // {} | to_entries
44
+ | map(select(.value.status == "in_progress"))
45
+ | sort_by(.value.updatedAt) | last
46
+ | .key // ""
47
+ ' "$issues_file" 2>/dev/null)
48
+ fi
49
+ fi
50
+
51
+ [[ -z "$linked_issue" ]] && return
52
+
53
+ # Get issue details from issues cache
54
+ if [[ -f "$issues_file" ]]; then
55
+ issue_data=$(jq -r --arg id "$linked_issue" '
56
+ .issues[$id] // {} |
57
+ "\(.externalId // "")|\(.priority // "none")|\(.status // "")"
58
+ ' "$issues_file" 2>/dev/null)
59
+
60
+ # Cache the result
61
+ write_cache "$cache_file" "$issue_data"
62
+ fi
63
+ fi
64
+
65
+ # Return empty if no data
66
+ [[ -z "$issue_data" || "$issue_data" == "||" ]] && return
67
+
68
+ # Parse issue data
69
+ local issue_key=$(echo "$issue_data" | cut -d'|' -f1)
70
+ local priority=$(echo "$issue_data" | cut -d'|' -f2)
71
+ local status=$(echo "$issue_data" | cut -d'|' -f3)
72
+
73
+ [[ -z "$issue_key" ]] && return
74
+
75
+ # Format output with JIRA-style coloring
76
+ local output=""
77
+
78
+ # Add status indicator if enabled
79
+ if [[ "${CONFIG_JIRA_SHOW_STATUS}" == "true" ]] && [[ -n "$status" ]]; then
80
+ local status_icon=$(get_jira_status_icon "$status")
81
+ [[ -n "$status_icon" ]] && output+="${status_icon} "
82
+ fi
83
+
84
+ # Issue key
85
+ output+="${ACCENT}${issue_key}${NC}"
86
+
87
+ # Add priority icon if enabled and priority is significant
88
+ if [[ "${CONFIG_JIRA_SHOW_PRIORITY}" == "true" ]]; then
89
+ local priority_icon=$(get_priority_icon "$priority")
90
+ [[ -n "$priority_icon" ]] && output+=" ${priority_icon}"
91
+ fi
92
+
93
+ echo -e "$output"
94
+ }
95
+
96
+ # Get JIRA-specific status icon
97
+ get_jira_status_icon() {
98
+ local status="$1"
99
+ case "$status" in
100
+ backlog) echo "📋" ;;
101
+ todo) echo "📝" ;;
102
+ in_progress) echo "🔄" ;;
103
+ in_review) echo "👀" ;;
104
+ done) echo "✅" ;;
105
+ cancelled) echo "❌" ;;
106
+ *) echo "" ;;
107
+ esac
108
+ }
@@ -4,7 +4,8 @@
4
4
  "cacheTTL": {
5
5
  "prjct": 30,
6
6
  "git": 5,
7
- "linear": 60
7
+ "linear": 60,
8
+ "jira": 60
8
9
  },
9
10
  "components": {
10
11
  "prjct_icon": {
@@ -21,6 +22,12 @@
21
22
  "position": 2,
22
23
  "showPriority": true
23
24
  },
25
+ "jira": {
26
+ "enabled": false,
27
+ "position": 2,
28
+ "showPriority": true,
29
+ "showStatus": false
30
+ },
24
31
  "dir": {
25
32
  "enabled": true,
26
33
  "position": 3
@@ -42,6 +49,9 @@
42
49
  "model": {
43
50
  "enabled": false,
44
51
  "position": 7
52
+ },
53
+ "enrichment": {
54
+ "enabled": true
45
55
  }
46
56
  }
47
57
  }
@@ -10,8 +10,10 @@ DEFAULT_THEME="default"
10
10
  DEFAULT_CACHE_TTL_PRJCT=30
11
11
  DEFAULT_CACHE_TTL_GIT=5
12
12
  DEFAULT_CACHE_TTL_LINEAR=60
13
+ DEFAULT_CACHE_TTL_JIRA=60
13
14
  DEFAULT_TASK_MAX_LENGTH=25
14
15
  DEFAULT_CONTEXT_MIN_PERCENT=30
16
+ DEFAULT_ENRICHMENT_ENABLED="true"
15
17
 
16
18
  # Component configuration (will be populated by load_config)
17
19
  declare -A COMPONENT_ENABLED
@@ -25,15 +27,21 @@ load_config() {
25
27
  CONFIG_CACHE_TTL_PRJCT="$DEFAULT_CACHE_TTL_PRJCT"
26
28
  CONFIG_CACHE_TTL_GIT="$DEFAULT_CACHE_TTL_GIT"
27
29
  CONFIG_CACHE_TTL_LINEAR="$DEFAULT_CACHE_TTL_LINEAR"
30
+ CONFIG_CACHE_TTL_JIRA="$DEFAULT_CACHE_TTL_JIRA"
28
31
  CONFIG_TASK_MAX_LENGTH="$DEFAULT_TASK_MAX_LENGTH"
29
32
  CONFIG_CONTEXT_MIN_PERCENT="$DEFAULT_CONTEXT_MIN_PERCENT"
30
33
  CONFIG_LINEAR_ENABLED="true"
31
34
  CONFIG_LINEAR_SHOW_PRIORITY="true"
35
+ CONFIG_JIRA_ENABLED="false"
36
+ CONFIG_JIRA_SHOW_PRIORITY="true"
37
+ CONFIG_JIRA_SHOW_STATUS="false"
38
+ CONFIG_ENRICHMENT_ENABLED="$DEFAULT_ENRICHMENT_ENABLED"
32
39
 
33
40
  # Default component configuration
34
41
  COMPONENT_ENABLED["prjct_icon"]="true"
35
42
  COMPONENT_ENABLED["task"]="true"
36
43
  COMPONENT_ENABLED["linear"]="true"
44
+ COMPONENT_ENABLED["jira"]="false"
37
45
  COMPONENT_ENABLED["dir"]="true"
38
46
  COMPONENT_ENABLED["git"]="true"
39
47
  COMPONENT_ENABLED["changes"]="true"
@@ -43,6 +51,7 @@ load_config() {
43
51
  COMPONENT_POSITION["prjct_icon"]=0
44
52
  COMPONENT_POSITION["task"]=1
45
53
  COMPONENT_POSITION["linear"]=2
54
+ COMPONENT_POSITION["jira"]=2
46
55
  COMPONENT_POSITION["dir"]=3
47
56
  COMPONENT_POSITION["git"]=4
48
57
  COMPONENT_POSITION["changes"]=5
@@ -61,20 +70,26 @@ load_config() {
61
70
  (.cacheTTL.prjct // 30),
62
71
  (.cacheTTL.git // 5),
63
72
  (.cacheTTL.linear // 60),
73
+ (.cacheTTL.jira // 60),
64
74
  (.components.task.maxLength // 25),
65
75
  (.components.context.minPercent // 30),
66
76
  (if .components.linear.showPriority == null then true else .components.linear.showPriority end),
77
+ (if .components.jira.showPriority == null then true else .components.jira.showPriority end),
78
+ (if .components.jira.showStatus == null then false else .components.jira.showStatus end),
67
79
  (if .components.prjct_icon.enabled == null then true else .components.prjct_icon.enabled end),
68
80
  (if .components.task.enabled == null then true else .components.task.enabled end),
69
81
  (if .components.linear.enabled == null then true else .components.linear.enabled end),
82
+ (if .components.jira.enabled == null then false else .components.jira.enabled end),
70
83
  (if .components.dir.enabled == null then true else .components.dir.enabled end),
71
84
  (if .components.git.enabled == null then true else .components.git.enabled end),
72
85
  (if .components.changes.enabled == null then true else .components.changes.enabled end),
73
86
  (if .components.context.enabled == null then true else .components.context.enabled end),
74
87
  (if .components.model.enabled == null then false else .components.model.enabled end),
88
+ (if .components.enrichment.enabled == null then true else .components.enrichment.enabled end),
75
89
  (.components.prjct_icon.position // 0),
76
90
  (.components.task.position // 1),
77
91
  (.components.linear.position // 2),
92
+ (.components.jira.position // 2),
78
93
  (.components.dir.position // 3),
79
94
  (.components.git.position // 4),
80
95
  (.components.changes.position // 5),
@@ -89,10 +104,11 @@ load_config() {
89
104
  local old_ifs="$IFS"
90
105
  IFS=$'\t' read -r \
91
106
  CONFIG_THEME \
92
- CONFIG_CACHE_TTL_PRJCT CONFIG_CACHE_TTL_GIT CONFIG_CACHE_TTL_LINEAR \
93
- CONFIG_TASK_MAX_LENGTH CONFIG_CONTEXT_MIN_PERCENT CONFIG_LINEAR_SHOW_PRIORITY \
94
- E_PRJCT_ICON E_TASK E_LINEAR E_DIR E_GIT E_CHANGES E_CONTEXT E_MODEL \
95
- P_PRJCT_ICON P_TASK P_LINEAR P_DIR P_GIT P_CHANGES P_CONTEXT P_MODEL \
107
+ CONFIG_CACHE_TTL_PRJCT CONFIG_CACHE_TTL_GIT CONFIG_CACHE_TTL_LINEAR CONFIG_CACHE_TTL_JIRA \
108
+ CONFIG_TASK_MAX_LENGTH CONFIG_CONTEXT_MIN_PERCENT \
109
+ CONFIG_LINEAR_SHOW_PRIORITY CONFIG_JIRA_SHOW_PRIORITY CONFIG_JIRA_SHOW_STATUS \
110
+ E_PRJCT_ICON E_TASK E_LINEAR E_JIRA E_DIR E_GIT E_CHANGES E_CONTEXT E_MODEL E_ENRICHMENT \
111
+ P_PRJCT_ICON P_TASK P_LINEAR P_JIRA P_DIR P_GIT P_CHANGES P_CONTEXT P_MODEL \
96
112
  <<< "$config_data"
97
113
  IFS="$old_ifs"
98
114
 
@@ -100,6 +116,7 @@ load_config() {
100
116
  COMPONENT_ENABLED["prjct_icon"]="$E_PRJCT_ICON"
101
117
  COMPONENT_ENABLED["task"]="$E_TASK"
102
118
  COMPONENT_ENABLED["linear"]="$E_LINEAR"
119
+ COMPONENT_ENABLED["jira"]="$E_JIRA"
103
120
  COMPONENT_ENABLED["dir"]="$E_DIR"
104
121
  COMPONENT_ENABLED["git"]="$E_GIT"
105
122
  COMPONENT_ENABLED["changes"]="$E_CHANGES"
@@ -110,14 +127,19 @@ load_config() {
110
127
  COMPONENT_POSITION["prjct_icon"]="$P_PRJCT_ICON"
111
128
  COMPONENT_POSITION["task"]="$P_TASK"
112
129
  COMPONENT_POSITION["linear"]="$P_LINEAR"
130
+ COMPONENT_POSITION["jira"]="$P_JIRA"
113
131
  COMPONENT_POSITION["dir"]="$P_DIR"
114
132
  COMPONENT_POSITION["git"]="$P_GIT"
115
133
  COMPONENT_POSITION["changes"]="$P_CHANGES"
116
134
  COMPONENT_POSITION["context"]="$P_CONTEXT"
117
135
  COMPONENT_POSITION["model"]="$P_MODEL"
118
136
 
119
- # Update linear enabled based on component config
137
+ # Update linear/jira enabled based on component config
120
138
  CONFIG_LINEAR_ENABLED="${COMPONENT_ENABLED["linear"]}"
139
+ CONFIG_JIRA_ENABLED="${COMPONENT_ENABLED["jira"]}"
140
+
141
+ # Update enrichment enabled from config
142
+ [[ -n "$E_ENRICHMENT" ]] && CONFIG_ENRICHMENT_ENABLED="$E_ENRICHMENT"
121
143
  }
122
144
 
123
145
  # Check if a component is enabled
@@ -22,6 +22,7 @@ import {
22
22
  type EnrichmentResult,
23
23
  } from './enricher'
24
24
  import { linearProvider } from '../linear/client'
25
+ import { jiraProvider } from '../jira/client'
25
26
 
26
27
  // =============================================================================
27
28
  // Manager Class
@@ -35,7 +36,7 @@ export class IssueTrackerManager {
35
36
  constructor() {
36
37
  // Register available providers
37
38
  this.providers.set('linear', linearProvider)
38
- // Future: this.providers.set('jira', jiraProvider)
39
+ this.providers.set('jira', jiraProvider)
39
40
  // Future: this.providers.set('monday', mondayProvider)
40
41
  }
41
42
 
@@ -0,0 +1,692 @@
1
+ /**
2
+ * JIRA Client
3
+ * Implements IssueTrackerProvider for Atlassian JIRA using REST API v3
4
+ *
5
+ * Authentication: Basic Auth with API Token
6
+ * - JIRA_BASE_URL: Your JIRA instance (e.g., https://company.atlassian.net)
7
+ * - JIRA_EMAIL: Your Atlassian account email
8
+ * - JIRA_API_TOKEN: API token from https://id.atlassian.com/manage-profile/security/api-tokens
9
+ */
10
+
11
+ import type {
12
+ IssueTrackerProvider,
13
+ Issue,
14
+ CreateIssueInput,
15
+ UpdateIssueInput,
16
+ FetchOptions,
17
+ JiraConfig,
18
+ IssueStatus,
19
+ IssuePriority,
20
+ IssueType,
21
+ } from '../issue-tracker/types'
22
+
23
+ // =============================================================================
24
+ // JIRA API Types
25
+ // =============================================================================
26
+
27
+ interface JiraIssue {
28
+ id: string
29
+ key: string
30
+ self: string
31
+ fields: {
32
+ summary: string
33
+ description?: {
34
+ type: string
35
+ content: Array<{
36
+ type: string
37
+ content?: Array<{ type: string; text?: string }>
38
+ }>
39
+ } | string | null
40
+ status: {
41
+ id: string
42
+ name: string
43
+ statusCategory: {
44
+ key: string // 'new', 'indeterminate', 'done'
45
+ name: string
46
+ }
47
+ }
48
+ priority?: {
49
+ id: string
50
+ name: string
51
+ }
52
+ issuetype: {
53
+ id: string
54
+ name: string
55
+ subtask: boolean
56
+ }
57
+ assignee?: {
58
+ accountId: string
59
+ displayName: string
60
+ emailAddress?: string
61
+ }
62
+ reporter?: {
63
+ accountId: string
64
+ displayName: string
65
+ emailAddress?: string
66
+ }
67
+ project: {
68
+ id: string
69
+ key: string
70
+ name: string
71
+ }
72
+ labels: string[]
73
+ created: string
74
+ updated: string
75
+ }
76
+ }
77
+
78
+ interface JiraSearchResponse {
79
+ issues: JiraIssue[]
80
+ total: number
81
+ maxResults: number
82
+ startAt: number
83
+ }
84
+
85
+ interface JiraProject {
86
+ id: string
87
+ key: string
88
+ name: string
89
+ }
90
+
91
+ // =============================================================================
92
+ // Status/Priority Mapping
93
+ // =============================================================================
94
+
95
+ /**
96
+ * Map JIRA status categories to normalized status
97
+ * JIRA uses statusCategory.key: 'new', 'indeterminate', 'done'
98
+ */
99
+ const JIRA_STATUS_CATEGORY_MAP: Record<string, IssueStatus> = {
100
+ new: 'todo',
101
+ indeterminate: 'in_progress',
102
+ done: 'done',
103
+ }
104
+
105
+ /**
106
+ * Common JIRA status names to normalized status
107
+ */
108
+ const JIRA_STATUS_NAME_MAP: Record<string, IssueStatus> = {
109
+ // Backlog states
110
+ backlog: 'backlog',
111
+ 'open': 'backlog',
112
+ 'to do': 'todo',
113
+ todo: 'todo',
114
+ new: 'todo',
115
+
116
+ // In Progress states
117
+ 'in progress': 'in_progress',
118
+ 'in development': 'in_progress',
119
+ 'in review': 'in_review',
120
+ 'code review': 'in_review',
121
+ 'review': 'in_review',
122
+
123
+ // Done states
124
+ done: 'done',
125
+ closed: 'done',
126
+ resolved: 'done',
127
+ complete: 'done',
128
+ completed: 'done',
129
+
130
+ // Cancelled states
131
+ cancelled: 'cancelled',
132
+ canceled: 'cancelled',
133
+ "won't do": 'cancelled',
134
+ 'wont do': 'cancelled',
135
+ rejected: 'cancelled',
136
+ }
137
+
138
+ /**
139
+ * JIRA priorities: 1 = Highest, 5 = Lowest
140
+ */
141
+ const JIRA_PRIORITY_MAP: Record<string, IssuePriority> = {
142
+ highest: 'urgent',
143
+ high: 'high',
144
+ medium: 'medium',
145
+ low: 'low',
146
+ lowest: 'low',
147
+ // Numeric fallbacks
148
+ '1': 'urgent',
149
+ '2': 'high',
150
+ '3': 'medium',
151
+ '4': 'low',
152
+ '5': 'low',
153
+ }
154
+
155
+ const PRIORITY_TO_JIRA: Record<IssuePriority, string> = {
156
+ urgent: 'Highest',
157
+ high: 'High',
158
+ medium: 'Medium',
159
+ low: 'Low',
160
+ none: 'Medium',
161
+ }
162
+
163
+ // =============================================================================
164
+ // JIRA Provider Implementation
165
+ // =============================================================================
166
+
167
+ export class JiraProvider implements IssueTrackerProvider {
168
+ readonly name = 'jira' as const
169
+ readonly displayName = 'JIRA'
170
+
171
+ private baseUrl: string = ''
172
+ private auth: string = ''
173
+ private config: JiraConfig | null = null
174
+ private currentUser: { accountId: string; displayName: string; email?: string } | null = null
175
+
176
+ /**
177
+ * Check if provider is configured
178
+ */
179
+ isConfigured(): boolean {
180
+ return this.baseUrl !== '' && this.auth !== '' && this.config?.enabled === true
181
+ }
182
+
183
+ /**
184
+ * Initialize with config
185
+ */
186
+ async initialize(config: JiraConfig): Promise<void> {
187
+ this.config = config
188
+
189
+ // Get credentials from config or environment
190
+ const baseUrl = config.baseUrl || process.env.JIRA_BASE_URL
191
+ const email = process.env.JIRA_EMAIL
192
+ const apiToken = config.apiKey || process.env.JIRA_API_TOKEN
193
+
194
+ if (!baseUrl) {
195
+ throw new Error('JIRA_BASE_URL not configured')
196
+ }
197
+ if (!email) {
198
+ throw new Error('JIRA_EMAIL not configured')
199
+ }
200
+ if (!apiToken) {
201
+ throw new Error('JIRA_API_TOKEN not configured')
202
+ }
203
+
204
+ // Normalize base URL (remove trailing slash)
205
+ this.baseUrl = baseUrl.replace(/\/$/, '')
206
+
207
+ // Create Basic Auth header
208
+ this.auth = Buffer.from(`${email}:${apiToken}`).toString('base64')
209
+
210
+ // Verify connection by fetching current user
211
+ try {
212
+ const response = await this.request<{ accountId: string; displayName: string; emailAddress?: string }>('/rest/api/3/myself')
213
+ this.currentUser = {
214
+ accountId: response.accountId,
215
+ displayName: response.displayName,
216
+ email: response.emailAddress,
217
+ }
218
+ console.log(`[jira] Connected as ${this.currentUser.displayName} (${this.currentUser.email || 'no email'})`)
219
+ } catch (error) {
220
+ this.baseUrl = ''
221
+ this.auth = ''
222
+ throw new Error(`JIRA connection failed: ${(error as Error).message}`)
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Get issues assigned to current user
228
+ */
229
+ async fetchAssignedIssues(options?: FetchOptions): Promise<Issue[]> {
230
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
231
+
232
+ const maxResults = options?.limit || 50
233
+ const jql = options?.includeCompleted
234
+ ? 'assignee = currentUser() ORDER BY updated DESC'
235
+ : 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'
236
+
237
+ const response = await this.request<JiraSearchResponse>(
238
+ `/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=*all`
239
+ )
240
+
241
+ return response.issues.map((issue) => this.mapIssue(issue))
242
+ }
243
+
244
+ /**
245
+ * Get issues from a team/project
246
+ */
247
+ async fetchTeamIssues(projectKey: string, options?: FetchOptions): Promise<Issue[]> {
248
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
249
+
250
+ const maxResults = options?.limit || 50
251
+ const jql = options?.includeCompleted
252
+ ? `project = ${projectKey} ORDER BY updated DESC`
253
+ : `project = ${projectKey} AND statusCategory != Done ORDER BY updated DESC`
254
+
255
+ const response = await this.request<JiraSearchResponse>(
256
+ `/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=*all`
257
+ )
258
+
259
+ return response.issues.map((issue) => this.mapIssue(issue))
260
+ }
261
+
262
+ /**
263
+ * Get a single issue by key (e.g., "ENG-123") or ID
264
+ */
265
+ async fetchIssue(id: string): Promise<Issue | null> {
266
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
267
+
268
+ try {
269
+ const issue = await this.request<JiraIssue>(`/rest/api/3/issue/${id}?fields=*all`)
270
+ return this.mapIssue(issue)
271
+ } catch (error) {
272
+ // Issue not found
273
+ if ((error as Error).message.includes('404')) {
274
+ return null
275
+ }
276
+ throw error
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Create a new issue
282
+ */
283
+ async createIssue(input: CreateIssueInput): Promise<Issue> {
284
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
285
+
286
+ const projectKey = input.teamId || this.config?.projectKey || this.config?.defaultTeamId
287
+ if (!projectKey) {
288
+ throw new Error('Project key required for creating issues')
289
+ }
290
+
291
+ // Build issue payload
292
+ const payload: Record<string, unknown> = {
293
+ fields: {
294
+ project: { key: projectKey },
295
+ summary: input.title,
296
+ issuetype: { name: this.mapTypeToJira(input.type) },
297
+ },
298
+ }
299
+
300
+ // Add optional fields
301
+ if (input.description) {
302
+ (payload.fields as Record<string, unknown>).description = {
303
+ type: 'doc',
304
+ version: 1,
305
+ content: [
306
+ {
307
+ type: 'paragraph',
308
+ content: [{ type: 'text', text: input.description }],
309
+ },
310
+ ],
311
+ }
312
+ }
313
+
314
+ if (input.priority) {
315
+ (payload.fields as Record<string, unknown>).priority = {
316
+ name: PRIORITY_TO_JIRA[input.priority],
317
+ }
318
+ }
319
+
320
+ if (input.labels?.length) {
321
+ (payload.fields as Record<string, unknown>).labels = input.labels
322
+ }
323
+
324
+ if (input.assigneeId) {
325
+ (payload.fields as Record<string, unknown>).assignee = {
326
+ accountId: input.assigneeId,
327
+ }
328
+ }
329
+
330
+ const created = await this.request<{ id: string; key: string }>(
331
+ '/rest/api/3/issue',
332
+ {
333
+ method: 'POST',
334
+ body: JSON.stringify(payload),
335
+ }
336
+ )
337
+
338
+ // Fetch the full issue
339
+ const issue = await this.fetchIssue(created.key)
340
+ if (!issue) {
341
+ throw new Error('Failed to fetch created issue')
342
+ }
343
+
344
+ return issue
345
+ }
346
+
347
+ /**
348
+ * Update an issue (for enrichment - updates description)
349
+ */
350
+ async updateIssue(id: string, input: UpdateIssueInput): Promise<Issue> {
351
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
352
+
353
+ const payload: Record<string, unknown> = { fields: {} }
354
+
355
+ if (input.description) {
356
+ (payload.fields as Record<string, unknown>).description = {
357
+ type: 'doc',
358
+ version: 1,
359
+ content: this.markdownToADF(input.description),
360
+ }
361
+ }
362
+
363
+ await this.request(`/rest/api/3/issue/${id}`, {
364
+ method: 'PUT',
365
+ body: JSON.stringify(payload),
366
+ })
367
+
368
+ // Fetch updated issue
369
+ const updated = await this.fetchIssue(id)
370
+ if (!updated) {
371
+ throw new Error('Failed to fetch updated issue')
372
+ }
373
+
374
+ return updated
375
+ }
376
+
377
+ /**
378
+ * Mark issue as in progress
379
+ */
380
+ async markInProgress(id: string): Promise<void> {
381
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
382
+
383
+ // Get available transitions
384
+ const transitions = await this.request<{ transitions: Array<{ id: string; name: string; to: { statusCategory: { key: string } } }> }>(
385
+ `/rest/api/3/issue/${id}/transitions`
386
+ )
387
+
388
+ // Find transition to "in progress" state
389
+ const inProgressTransition = transitions.transitions.find(
390
+ (t) =>
391
+ t.to.statusCategory.key === 'indeterminate' ||
392
+ t.name.toLowerCase().includes('progress') ||
393
+ t.name.toLowerCase().includes('start')
394
+ )
395
+
396
+ if (inProgressTransition) {
397
+ await this.request(`/rest/api/3/issue/${id}/transitions`, {
398
+ method: 'POST',
399
+ body: JSON.stringify({ transition: { id: inProgressTransition.id } }),
400
+ })
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Mark issue as done
406
+ */
407
+ async markDone(id: string): Promise<void> {
408
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
409
+
410
+ // Get available transitions
411
+ const transitions = await this.request<{ transitions: Array<{ id: string; name: string; to: { statusCategory: { key: string } } }> }>(
412
+ `/rest/api/3/issue/${id}/transitions`
413
+ )
414
+
415
+ // Find transition to "done" state
416
+ const doneTransition = transitions.transitions.find(
417
+ (t) =>
418
+ t.to.statusCategory.key === 'done' ||
419
+ t.name.toLowerCase().includes('done') ||
420
+ t.name.toLowerCase().includes('complete') ||
421
+ t.name.toLowerCase().includes('resolve')
422
+ )
423
+
424
+ if (doneTransition) {
425
+ await this.request(`/rest/api/3/issue/${id}/transitions`, {
426
+ method: 'POST',
427
+ body: JSON.stringify({ transition: { id: doneTransition.id } }),
428
+ })
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Get available projects (teams in JIRA context)
434
+ */
435
+ async getTeams(): Promise<Array<{ id: string; name: string; key?: string }>> {
436
+ if (!this.isConfigured()) throw new Error('JIRA not initialized')
437
+
438
+ const projects = await this.request<JiraProject[]>('/rest/api/3/project')
439
+
440
+ return projects.map((project) => ({
441
+ id: project.id,
442
+ name: project.name,
443
+ key: project.key,
444
+ }))
445
+ }
446
+
447
+ /**
448
+ * Get available projects
449
+ */
450
+ async getProjects(): Promise<Array<{ id: string; name: string }>> {
451
+ // In JIRA, teams = projects, so return same data
452
+ return this.getTeams()
453
+ }
454
+
455
+ // =============================================================================
456
+ // Private Helpers
457
+ // =============================================================================
458
+
459
+ /**
460
+ * Make authenticated request to JIRA API
461
+ */
462
+ private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
463
+ const url = `${this.baseUrl}${endpoint}`
464
+
465
+ const response = await fetch(url, {
466
+ ...options,
467
+ headers: {
468
+ Authorization: `Basic ${this.auth}`,
469
+ 'Content-Type': 'application/json',
470
+ Accept: 'application/json',
471
+ ...options?.headers,
472
+ },
473
+ })
474
+
475
+ if (!response.ok) {
476
+ const errorText = await response.text()
477
+ throw new Error(`JIRA API error ${response.status}: ${errorText}`)
478
+ }
479
+
480
+ // Handle empty responses (like successful PUT)
481
+ const text = await response.text()
482
+ if (!text) {
483
+ return {} as T
484
+ }
485
+
486
+ return JSON.parse(text) as T
487
+ }
488
+
489
+ /**
490
+ * Map JIRA issue to normalized Issue
491
+ */
492
+ private mapIssue(jiraIssue: JiraIssue): Issue {
493
+ const statusName = jiraIssue.fields.status.name.toLowerCase()
494
+ const statusCategory = jiraIssue.fields.status.statusCategory.key
495
+
496
+ // Try exact status name match first, then category
497
+ const status: IssueStatus =
498
+ JIRA_STATUS_NAME_MAP[statusName] ||
499
+ JIRA_STATUS_CATEGORY_MAP[statusCategory] ||
500
+ 'backlog'
501
+
502
+ const priorityName = jiraIssue.fields.priority?.name?.toLowerCase() || 'medium'
503
+ const priority: IssuePriority = JIRA_PRIORITY_MAP[priorityName] || 'medium'
504
+
505
+ return {
506
+ id: jiraIssue.id,
507
+ externalId: jiraIssue.key,
508
+ provider: 'jira',
509
+ title: jiraIssue.fields.summary,
510
+ description: this.extractDescription(jiraIssue.fields.description),
511
+ status,
512
+ priority,
513
+ type: this.inferType(jiraIssue.fields.issuetype.name, jiraIssue.fields.labels),
514
+ assignee: jiraIssue.fields.assignee
515
+ ? {
516
+ id: jiraIssue.fields.assignee.accountId,
517
+ name: jiraIssue.fields.assignee.displayName,
518
+ email: jiraIssue.fields.assignee.emailAddress,
519
+ }
520
+ : undefined,
521
+ labels: jiraIssue.fields.labels || [],
522
+ team: {
523
+ id: jiraIssue.fields.project.id,
524
+ name: jiraIssue.fields.project.name,
525
+ key: jiraIssue.fields.project.key,
526
+ },
527
+ project: {
528
+ id: jiraIssue.fields.project.id,
529
+ name: jiraIssue.fields.project.name,
530
+ },
531
+ url: `${this.baseUrl}/browse/${jiraIssue.key}`,
532
+ createdAt: jiraIssue.fields.created,
533
+ updatedAt: jiraIssue.fields.updated,
534
+ raw: jiraIssue,
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Extract plain text from JIRA description (ADF or string)
540
+ */
541
+ private extractDescription(
542
+ description: JiraIssue['fields']['description']
543
+ ): string | undefined {
544
+ if (!description) return undefined
545
+
546
+ // Handle string descriptions (older JIRA versions)
547
+ if (typeof description === 'string') {
548
+ return description
549
+ }
550
+
551
+ // Handle ADF (Atlassian Document Format)
552
+ try {
553
+ const texts: string[] = []
554
+ const extractText = (node: unknown): void => {
555
+ if (!node || typeof node !== 'object') return
556
+ const n = node as Record<string, unknown>
557
+
558
+ if (n.type === 'text' && typeof n.text === 'string') {
559
+ texts.push(n.text)
560
+ }
561
+ if (Array.isArray(n.content)) {
562
+ n.content.forEach(extractText)
563
+ }
564
+ }
565
+
566
+ if (Array.isArray(description.content)) {
567
+ description.content.forEach(extractText)
568
+ }
569
+
570
+ return texts.join('\n') || undefined
571
+ } catch {
572
+ return undefined
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Convert markdown to ADF (simplified)
578
+ */
579
+ private markdownToADF(markdown: string): Array<Record<string, unknown>> {
580
+ const lines = markdown.split('\n')
581
+ const content: Array<Record<string, unknown>> = []
582
+
583
+ for (const line of lines) {
584
+ if (line.startsWith('## ')) {
585
+ // Heading 2
586
+ content.push({
587
+ type: 'heading',
588
+ attrs: { level: 2 },
589
+ content: [{ type: 'text', text: line.slice(3) }],
590
+ })
591
+ } else if (line.startsWith('### ')) {
592
+ // Heading 3
593
+ content.push({
594
+ type: 'heading',
595
+ attrs: { level: 3 },
596
+ content: [{ type: 'text', text: line.slice(4) }],
597
+ })
598
+ } else if (line.startsWith('- [ ] ')) {
599
+ // Checkbox unchecked
600
+ content.push({
601
+ type: 'taskList',
602
+ attrs: { localId: crypto.randomUUID() },
603
+ content: [{
604
+ type: 'taskItem',
605
+ attrs: { localId: crypto.randomUUID(), state: 'TODO' },
606
+ content: [{ type: 'text', text: line.slice(6) }],
607
+ }],
608
+ })
609
+ } else if (line.startsWith('- [x] ')) {
610
+ // Checkbox checked
611
+ content.push({
612
+ type: 'taskList',
613
+ attrs: { localId: crypto.randomUUID() },
614
+ content: [{
615
+ type: 'taskItem',
616
+ attrs: { localId: crypto.randomUUID(), state: 'DONE' },
617
+ content: [{ type: 'text', text: line.slice(6) }],
618
+ }],
619
+ })
620
+ } else if (line.startsWith('- ')) {
621
+ // Bullet point
622
+ content.push({
623
+ type: 'bulletList',
624
+ content: [{
625
+ type: 'listItem',
626
+ content: [{
627
+ type: 'paragraph',
628
+ content: [{ type: 'text', text: line.slice(2) }],
629
+ }],
630
+ }],
631
+ })
632
+ } else if (line.trim()) {
633
+ // Regular paragraph
634
+ content.push({
635
+ type: 'paragraph',
636
+ content: [{ type: 'text', text: line }],
637
+ })
638
+ }
639
+ }
640
+
641
+ return content
642
+ }
643
+
644
+ /**
645
+ * Infer issue type from JIRA issue type and labels
646
+ */
647
+ private inferType(issueTypeName: string, labels: string[]): IssueType {
648
+ const typeLower = issueTypeName.toLowerCase()
649
+ const labelsLower = labels.map((l) => l.toLowerCase())
650
+
651
+ if (typeLower === 'bug' || labelsLower.includes('bug')) {
652
+ return 'bug'
653
+ }
654
+ if (typeLower === 'story' || typeLower === 'feature' || labelsLower.includes('feature')) {
655
+ return 'feature'
656
+ }
657
+ if (typeLower === 'improvement' || labelsLower.includes('improvement')) {
658
+ return 'improvement'
659
+ }
660
+ if (typeLower === 'epic') {
661
+ return 'epic'
662
+ }
663
+ if (typeLower === 'sub-task' || typeLower === 'subtask') {
664
+ return 'task'
665
+ }
666
+
667
+ return 'task'
668
+ }
669
+
670
+ /**
671
+ * Map prjct type to JIRA issue type name
672
+ */
673
+ private mapTypeToJira(type?: IssueType): string {
674
+ switch (type) {
675
+ case 'bug':
676
+ return 'Bug'
677
+ case 'feature':
678
+ return 'Story'
679
+ case 'improvement':
680
+ return 'Improvement'
681
+ case 'epic':
682
+ return 'Epic'
683
+ case 'chore':
684
+ case 'task':
685
+ default:
686
+ return 'Task'
687
+ }
688
+ }
689
+ }
690
+
691
+ // Singleton instance
692
+ export const jiraProvider = new JiraProvider()
@@ -0,0 +1,12 @@
1
+ /**
2
+ * JIRA Integration
3
+ *
4
+ * Provides JIRA issue tracking integration for prjct-cli.
5
+ *
6
+ * Environment Variables:
7
+ * - JIRA_BASE_URL: Your JIRA instance URL (e.g., https://company.atlassian.net)
8
+ * - JIRA_EMAIL: Your Atlassian account email
9
+ * - JIRA_API_TOKEN: API token from https://id.atlassian.com/manage-profile/security/api-tokens
10
+ */
11
+
12
+ export { JiraProvider, jiraProvider } from './client'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.30.2",
3
+ "version": "0.31.0",
4
4
  "description": "Built for Claude - Ship fast, track progress, stay focused. Developer momentum tool for indie hackers.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -0,0 +1,376 @@
1
+ ---
2
+ allowed-tools: [Read, Write, Bash, Task, Glob, Grep, AskUserQuestion]
3
+ description: 'Sync and enrich JIRA issues with AI-generated context'
4
+ ---
5
+
6
+ # p. jira - JIRA Issue Tracker Integration
7
+
8
+ Sync issues from Atlassian JIRA and enrich them with AI-generated context.
9
+
10
+ ## Context Variables
11
+
12
+ - `{projectId}`: From `.prjct/prjct.config.json`
13
+ - `{globalPath}`: `~/.prjct-cli/projects/{projectId}`
14
+ - `{args}`: User-provided arguments (subcommand)
15
+
16
+ ---
17
+
18
+ ## Subcommands
19
+
20
+ | Command | Description |
21
+ |---------|-------------|
22
+ | `p. jira` | Show status + **your** assigned issues |
23
+ | `p. jira sync` | Fetch and enrich **your** assigned issues |
24
+ | `p. jira enrich <KEY>` | Enrich specific issue (e.g., PROJ-123) |
25
+ | `p. jira setup` | Configure JIRA integration |
26
+ | `p. jira start <KEY>` | Start working on issue → creates prjct task |
27
+
28
+ ### Filter Options
29
+
30
+ | Flag | Description |
31
+ |------|-------------|
32
+ | (default) | Only issues assigned to you |
33
+ | `--project <KEY>` | All issues in a specific project |
34
+ | `--unassigned` | Unassigned issues (for picking up work) |
35
+
36
+ ---
37
+
38
+ ## Step 1: Validate Project
39
+
40
+ ```
41
+ READ: .prjct/prjct.config.json
42
+ EXTRACT: projectId
43
+ SET: globalPath = ~/.prjct-cli/projects/{projectId}
44
+
45
+ IF file not found:
46
+ OUTPUT: "No prjct project. Run `p. init` first."
47
+ STOP
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Step 2: Check Configuration
53
+
54
+ ```
55
+ READ: {globalPath}/project.json
56
+ EXTRACT: integrations.jira
57
+
58
+ IF not configured:
59
+ OUTPUT: "JIRA not configured."
60
+ OUTPUT: "Run `p. jira setup` to configure."
61
+ STOP
62
+
63
+ IF JIRA_API_TOKEN not set:
64
+ OUTPUT: "JIRA credentials not configured."
65
+ OUTPUT: "Required environment variables:"
66
+ OUTPUT: " JIRA_BASE_URL - Your JIRA instance (e.g., https://company.atlassian.net)"
67
+ OUTPUT: " JIRA_EMAIL - Your Atlassian account email"
68
+ OUTPUT: " JIRA_API_TOKEN - API token from https://id.atlassian.com/manage-profile/security/api-tokens"
69
+ STOP
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Step 3: Route Subcommand
75
+
76
+ ### No args / status
77
+
78
+ ```
79
+ SHOW:
80
+ - Connection status
81
+ - Project configured
82
+ - Assigned issues (first 10)
83
+
84
+ OUTPUT:
85
+ JIRA: Connected ✓
86
+ Instance: {baseUrl}
87
+ Project: {projectKey}
88
+ Issues assigned: {count}
89
+
90
+ Recent:
91
+ - {PROJ-123} {title} ({status})
92
+ - ...
93
+ ```
94
+
95
+ ### sync
96
+
97
+ ```
98
+ 1. Fetch assigned issues from JIRA
99
+ 2. For each issue without enrichment:
100
+ a. Use Task(Explore) to analyze codebase
101
+ b. Generate enrichment using enricher.ts prompts
102
+ c. Update issue description in JIRA
103
+ d. Save enriched data locally
104
+ 3. Output summary
105
+
106
+ OUTPUT:
107
+ Synced {count} issues from JIRA.
108
+ Enriched: {enrichedCount}
109
+ Updated in JIRA: {updatedCount}
110
+ ```
111
+
112
+ ### enrich <KEY>
113
+
114
+ ```
115
+ 1. Fetch issue by key (e.g., PROJ-123)
116
+ 2. Analyze codebase for context
117
+ 3. Generate full enrichment:
118
+ - Enhanced description
119
+ - Acceptance criteria
120
+ - Affected files
121
+ - Technical notes
122
+ - Complexity estimate
123
+ 4. Ask user to confirm before updating JIRA
124
+ 5. Update issue in JIRA
125
+ 6. Save locally
126
+
127
+ OUTPUT:
128
+ ## {KEY}: {title}
129
+
130
+ ### Generated Enrichment
131
+
132
+ **Description**:
133
+ {enrichedDescription}
134
+
135
+ **Acceptance Criteria**:
136
+ - [ ] {ac1}
137
+ - [ ] {ac2}
138
+ ...
139
+
140
+ **Affected Files**:
141
+ - `{file1}` - {reason}
142
+ ...
143
+
144
+ **Complexity**: {estimate}
145
+
146
+ ---
147
+ Update in JIRA? [Y/n]
148
+ ```
149
+
150
+ ### setup
151
+
152
+ ```
153
+ 1. Check JIRA environment variables:
154
+ - JIRA_BASE_URL (required)
155
+ - JIRA_EMAIL (required)
156
+ - JIRA_API_TOKEN (required)
157
+
158
+ 2. Connect to JIRA and verify credentials
159
+
160
+ 3. List available projects
161
+
162
+ 4. Ask user to select default project
163
+
164
+ 5. Save config to {globalPath}/project.json
165
+
166
+ OUTPUT:
167
+ JIRA Setup
168
+
169
+ Connected as: {displayName}
170
+ Instance: {baseUrl}
171
+
172
+ Available projects:
173
+ 1. {PROJ1} - {Project Name 1}
174
+ 2. {PROJ2} - {Project Name 2}
175
+ ...
176
+
177
+ Select default project for new issues:
178
+ > [user selects]
179
+
180
+ Config saved!
181
+ ```
182
+
183
+ ### start <KEY>
184
+
185
+ ```
186
+ 1. Fetch issue from JIRA
187
+ 2. Enrich if not already enriched
188
+ 3. Transition issue to "In Progress" in JIRA
189
+ 4. Create prjct task with enrichment data
190
+ 5. Create git branch: {type}/{issueKey}-{slug}
191
+
192
+ OUTPUT:
193
+ Started: {KEY} - {title}
194
+
195
+ Branch: feature/PROJ-123-add-user-auth
196
+ JIRA status: In Progress
197
+
198
+ Next: Work on the task, then `p. done`
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Enrichment Process
204
+
205
+ When enriching an issue:
206
+
207
+ ### 1. Gather Project Context
208
+
209
+ ```
210
+ READ: {globalPath}/project.json → techStack
211
+ READ: package.json → dependencies
212
+ BASH: git log --oneline -10 → recent commits
213
+ ```
214
+
215
+ ### 2. Analyze Codebase
216
+
217
+ Use Task(Explore) to find:
218
+ - Similar existing features
219
+ - Related code patterns
220
+ - Key files that might be affected
221
+
222
+ ### 3. Generate Enrichment
223
+
224
+ Based on analysis, generate:
225
+
226
+ **Enhanced Description**:
227
+ - What the user/stakeholder wants to achieve
228
+ - Why this change is needed
229
+ - Context from similar features
230
+
231
+ **Acceptance Criteria** (3-7 items):
232
+ - [ ] When [action], then [expected result]
233
+ - Specific, testable criteria
234
+
235
+ **Affected Files**:
236
+ - `src/components/Auth.tsx` - Main auth component
237
+ - `src/api/users.ts` - User API calls
238
+
239
+ **Technical Notes**:
240
+ - Follow existing pattern in `src/auth/`
241
+ - Consider edge case: expired sessions
242
+ - Reuse `useAuth` hook
243
+
244
+ **Complexity**: small | medium | large
245
+ - Based on affected files and scope
246
+
247
+ ---
248
+
249
+ ## Storage
250
+
251
+ ### Issue Cache: `{globalPath}/storage/issues.json`
252
+
253
+ ```json
254
+ {
255
+ "provider": "jira",
256
+ "lastSync": "2024-01-15T10:30:00Z",
257
+ "issues": {
258
+ "PROJ-123": {
259
+ "id": "10001",
260
+ "externalId": "PROJ-123",
261
+ "title": "Add user authentication",
262
+ "status": "in_progress",
263
+ "enrichment": {
264
+ "description": "...",
265
+ "acceptanceCriteria": [...],
266
+ "affectedFiles": [...],
267
+ "technicalNotes": "...",
268
+ "estimatedComplexity": "medium",
269
+ "generatedAt": "2024-01-15T10:30:00Z"
270
+ }
271
+ }
272
+ }
273
+ }
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Configuration Storage
279
+
280
+ ### In `{globalPath}/project.json`
281
+
282
+ ```json
283
+ {
284
+ "integrations": {
285
+ "jira": {
286
+ "enabled": true,
287
+ "provider": "jira",
288
+ "baseUrl": "https://company.atlassian.net",
289
+ "projectKey": "PROJ",
290
+ "projectName": "My Project",
291
+ "userId": "account-id",
292
+ "setupAt": "2024-01-15T10:30:00Z"
293
+ }
294
+ }
295
+ }
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Error Handling
301
+
302
+ | Error | Action |
303
+ |-------|--------|
304
+ | Missing JIRA_BASE_URL | "Set JIRA_BASE_URL environment variable" |
305
+ | Missing JIRA_EMAIL | "Set JIRA_EMAIL environment variable" |
306
+ | Missing JIRA_API_TOKEN | "Set JIRA_API_TOKEN. Get token: https://id.atlassian.com/manage-profile/security/api-tokens" |
307
+ | Connection failed | Show error, suggest checking credentials |
308
+ | Issue not found | "Issue {KEY} not found in JIRA" |
309
+ | Rate limited | "JIRA API rate limited. Try again in {time}" |
310
+ | No transition available | "Cannot transition issue - check workflow permissions" |
311
+
312
+ ---
313
+
314
+ ## Authentication
315
+
316
+ JIRA uses Basic Auth with API tokens:
317
+
318
+ ```bash
319
+ # Add to ~/.zshrc or ~/.bashrc
320
+
321
+ # Your JIRA Cloud instance URL
322
+ export JIRA_BASE_URL="https://company.atlassian.net"
323
+
324
+ # Your Atlassian account email
325
+ export JIRA_EMAIL="you@company.com"
326
+
327
+ # API token (NOT your password)
328
+ # Generate at: https://id.atlassian.com/manage-profile/security/api-tokens
329
+ export JIRA_API_TOKEN="your-api-token-here"
330
+ ```
331
+
332
+ ### For JIRA Server/Data Center
333
+
334
+ Same environment variables work, just use your server URL:
335
+ ```bash
336
+ export JIRA_BASE_URL="https://jira.internal.company.com"
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Output Format
342
+
343
+ ```
344
+ {action} {count} issues
345
+
346
+ {issueKey}: {title}
347
+ Status: {status} → {newStatus}
348
+ Enriched: ✓
349
+
350
+ Next: {suggested action}
351
+ ```
352
+
353
+ ---
354
+
355
+ ## JQL Quick Reference
356
+
357
+ The JIRA client uses JQL (JIRA Query Language) internally:
358
+
359
+ | Filter | JQL |
360
+ |--------|-----|
361
+ | My open issues | `assignee = currentUser() AND statusCategory != Done` |
362
+ | Project issues | `project = PROJ AND statusCategory != Done` |
363
+ | Unassigned | `assignee IS EMPTY AND statusCategory != Done` |
364
+ | Recent updates | `updated >= -7d ORDER BY updated DESC` |
365
+
366
+ ---
367
+
368
+ ## Comparison with Linear
369
+
370
+ | Feature | Linear | JIRA |
371
+ |---------|--------|------|
372
+ | Auth | API Key | Basic Auth (email + token) |
373
+ | Issue ID | ENG-123 | PROJ-123 |
374
+ | Teams | Teams | Projects |
375
+ | Status | State types | Status categories |
376
+ | Description | Markdown | ADF (converted from markdown) |