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 +17 -0
- package/assets/statusline/components/jira.sh +108 -0
- package/assets/statusline/default-config.json +11 -1
- package/assets/statusline/lib/config.sh +27 -5
- package/core/integrations/issue-tracker/manager.ts +2 -1
- package/core/integrations/jira/client.ts +692 -0
- package/core/integrations/jira/index.ts +12 -0
- package/package.json +1 -1
- package/templates/commands/jira.md +376 -0
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
@@ -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) |
|