haitask 0.3.1 → 0.3.5
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/README.md +103 -1
- package/package.json +3 -3
- package/src/ai/deepseek.js +3 -3
- package/src/ai/groq.js +3 -3
- package/src/ai/openai.js +3 -3
- package/src/ai/utils.js +21 -9
- package/src/backend/index.js +25 -27
- package/src/commands/check.js +31 -0
- package/src/commands/init.js +95 -28
- package/src/commands/run.js +77 -29
- package/src/core/pipeline.js +12 -0
- package/src/git/commit.js +8 -6
- package/src/index.js +8 -0
- package/src/jira/client.js +11 -2
- package/src/linear/client.js +77 -3
- package/src/trello/client.js +62 -7
- package/src/utils/http-hints.js +34 -0
- package/src/utils/idempotency.js +46 -0
- package/src/utils/retry.js +50 -0
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ haitask init
|
|
|
33
33
|
|
|
34
34
|
Pick a target (1 = Jira, 2 = Trello, 3 = Linear) and answer the prompts. You get a `.haitaskrc` and an optional `.env` template.
|
|
35
35
|
**Quick mode:** `haitask init --quick` — fewer questions, sensible defaults.
|
|
36
|
+
**Preset mode:** `haitask init --preset jira|trello|linear` — non-interactive starter config.
|
|
36
37
|
|
|
37
38
|
**2. Add API keys**
|
|
38
39
|
In the generated `.env`, set the keys for your target and AI provider:
|
|
@@ -53,6 +54,7 @@ haitask run
|
|
|
53
54
|
```
|
|
54
55
|
|
|
55
56
|
To try without creating a task: `haitask run --dry`.
|
|
57
|
+
For machine-readable output: `haitask run --dry --json`.
|
|
56
58
|
|
|
57
59
|
---
|
|
58
60
|
|
|
@@ -62,14 +64,59 @@ To try without creating a task: `haitask run --dry`.
|
|
|
62
64
|
|---------|-------------|
|
|
63
65
|
| `haitask init` | Interactive setup: target, AI, rules → writes `.haitaskrc` and optional `.env` |
|
|
64
66
|
| `haitask init --quick` | Minimal prompts: target + required fields only; defaults for AI, branches, prefixes |
|
|
67
|
+
| `haitask init --preset <target>` | Non-interactive preset config for `jira`, `trello`, or `linear` |
|
|
68
|
+
| `haitask check` | Validate `.haitaskrc` + required env keys without running the pipeline |
|
|
65
69
|
| `haitask run` | Creates a task from the latest commit (Jira / Trello / Linear) |
|
|
66
70
|
| `haitask run --dry` | Same flow, but does not create a task |
|
|
71
|
+
| `haitask run --json` | Print machine-readable JSON output (works with `--dry`) |
|
|
67
72
|
| `haitask run --commits N` | Combine the last N commits into one task (e.g. `--commits 3`) |
|
|
68
73
|
| `haitask run --type <type>` | (Jira only) Override issue type for this run (Task, Bug, Story) |
|
|
69
74
|
| `haitask run --status <status>` | (Jira only) Override status after create (Done, "To Do", etc.) |
|
|
70
75
|
|
|
71
76
|
---
|
|
72
77
|
|
|
78
|
+
## JSON output (automation)
|
|
79
|
+
|
|
80
|
+
Use `--json` when integrating with CI/CD or scripts. This guarantees a parseable payload on both success and failure.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
haitask run --dry --json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Success shape:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"ok": true,
|
|
91
|
+
"dry": true,
|
|
92
|
+
"skipped": false,
|
|
93
|
+
"commented": false,
|
|
94
|
+
"key": null,
|
|
95
|
+
"url": null,
|
|
96
|
+
"payload": {
|
|
97
|
+
"title": "Add login validation",
|
|
98
|
+
"description": "..."
|
|
99
|
+
},
|
|
100
|
+
"commit": {
|
|
101
|
+
"branch": "main",
|
|
102
|
+
"commitHash": "abc123...",
|
|
103
|
+
"repoName": "my-repo",
|
|
104
|
+
"count": 1
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Failure shape:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"ok": false,
|
|
114
|
+
"error": "Config error: Config not found ..."
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
73
120
|
## Configuration
|
|
74
121
|
|
|
75
122
|
- **`.haitaskrc`** — `target` (jira / trello / linear), target-specific options, `ai` (provider, model), `rules` (allowedBranches, commitPrefixes).
|
|
@@ -93,7 +140,62 @@ To try without creating a task: `haitask run --dry`.
|
|
|
93
140
|
|
|
94
141
|
- **Global:** `npm install -g haitask` → run `haitask init` once per repo, then `haitask run` after commits.
|
|
95
142
|
- **Per project:** `npm install haitask --save-dev` → `npx haitask run`.
|
|
96
|
-
- **CI / scripts:** Run `npx haitask run` from the repo root
|
|
143
|
+
- **CI / scripts:** Run `npx haitask run --dry --json` from the repo root, parse `ok`, and fail pipeline when `ok === false`.
|
|
144
|
+
|
|
145
|
+
Example (bash + `jq`):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
out="$(npx haitask run --dry --json)"
|
|
149
|
+
echo "$out"
|
|
150
|
+
test "$(echo "$out" | jq -r '.ok')" = "true"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## GitHub Actions Integration
|
|
156
|
+
|
|
157
|
+
Add this workflow to `.github/workflows/haitask.yml` for automatic task creation:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
name: Create Task from Commit
|
|
161
|
+
|
|
162
|
+
on:
|
|
163
|
+
push:
|
|
164
|
+
branches: [ main, develop ]
|
|
165
|
+
|
|
166
|
+
jobs:
|
|
167
|
+
create-task:
|
|
168
|
+
runs-on: ubuntu-latest
|
|
169
|
+
steps:
|
|
170
|
+
- uses: actions/checkout@v4
|
|
171
|
+
with:
|
|
172
|
+
fetch-depth: 0
|
|
173
|
+
|
|
174
|
+
- name: Setup Node.js
|
|
175
|
+
uses: actions/setup-node@v4
|
|
176
|
+
with:
|
|
177
|
+
node-version: '18'
|
|
178
|
+
|
|
179
|
+
- name: Install HAITASK
|
|
180
|
+
run: npm install -g haitask
|
|
181
|
+
|
|
182
|
+
- name: Create task from latest commit
|
|
183
|
+
env:
|
|
184
|
+
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
|
185
|
+
# Add your target-specific keys:
|
|
186
|
+
# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
|
187
|
+
# TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }}
|
|
188
|
+
# LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
|
189
|
+
run: |
|
|
190
|
+
if [ -f ".haitaskrc" ]; then
|
|
191
|
+
haitask run
|
|
192
|
+
fi
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Setup:**
|
|
196
|
+
1. Copy the workflow to your repo
|
|
197
|
+
2. Add required secrets to GitHub repo settings
|
|
198
|
+
3. Configure `.haitaskrc` in your project root
|
|
97
199
|
|
|
98
200
|
---
|
|
99
201
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haitask",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "HAITASK
|
|
3
|
+
"version": "0.3.5",
|
|
4
|
+
"description": "HAITASK \u2014 AI-powered task creation from Git commits. Creates issues in Jira, Trello, or Linear from your latest commit message and branch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -44,4 +44,4 @@
|
|
|
44
44
|
"dotenv": "^16.4.5",
|
|
45
45
|
"execa": "^9.5.2"
|
|
46
46
|
}
|
|
47
|
-
}
|
|
47
|
+
}
|
package/src/ai/deepseek.js
CHANGED
|
@@ -9,7 +9,7 @@ import { buildPrompt, parseTaskPayload } from './utils.js';
|
|
|
9
9
|
const DEEPSEEK_API_URL = 'https://api.deepseek.com/v1/chat/completions';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Call Deepseek and return task payload for
|
|
12
|
+
* Call Deepseek and return task payload for the configured target.
|
|
13
13
|
* @param {{ message: string, branch: string, repoName: string }} commitData
|
|
14
14
|
* @param {{ ai: { model?: string } }} config
|
|
15
15
|
* @returns {Promise<{ title: string, description: string, labels: string[] }>}
|
|
@@ -21,7 +21,7 @@ export async function generateDeepseek(commitData, config) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const model = config?.ai?.model || 'deepseek-chat';
|
|
24
|
-
const { system, user } = buildPrompt(commitData);
|
|
24
|
+
const { system, user } = buildPrompt(commitData, config?.target);
|
|
25
25
|
|
|
26
26
|
const response = await fetch(DEEPSEEK_API_URL, {
|
|
27
27
|
method: 'POST',
|
|
@@ -47,7 +47,7 @@ export async function generateDeepseek(commitData, config) {
|
|
|
47
47
|
const data = await response.json();
|
|
48
48
|
const content = data?.choices?.[0]?.message?.content;
|
|
49
49
|
if (typeof content !== 'string') {
|
|
50
|
-
throw new
|
|
50
|
+
throw new TypeError('Deepseek response missing choices[0].message.content');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
return parseTaskPayload(content.trim());
|
package/src/ai/groq.js
CHANGED
|
@@ -9,7 +9,7 @@ import { buildPrompt, parseTaskPayload } from './utils.js';
|
|
|
9
9
|
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Call Groq and return task payload for
|
|
12
|
+
* Call Groq and return task payload for the configured target.
|
|
13
13
|
* @param {{ message: string, branch: string, repoName: string }} commitData
|
|
14
14
|
* @param {{ ai: { model?: string } }} config
|
|
15
15
|
* @returns {Promise<{ title: string, description: string, labels: string[] }>}
|
|
@@ -23,7 +23,7 @@ export async function generateGroq(commitData, config) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const model = config?.ai?.model || 'llama-3.1-8b-instant';
|
|
26
|
-
const { system, user } = buildPrompt(commitData);
|
|
26
|
+
const { system, user } = buildPrompt(commitData, config?.target);
|
|
27
27
|
|
|
28
28
|
const response = await fetch(GROQ_API_URL, {
|
|
29
29
|
method: 'POST',
|
|
@@ -49,7 +49,7 @@ export async function generateGroq(commitData, config) {
|
|
|
49
49
|
const data = await response.json();
|
|
50
50
|
const content = data?.choices?.[0]?.message?.content;
|
|
51
51
|
if (typeof content !== 'string') {
|
|
52
|
-
throw new
|
|
52
|
+
throw new TypeError('Groq response missing choices[0].message.content');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
return parseTaskPayload(content.trim());
|
package/src/ai/openai.js
CHANGED
|
@@ -7,7 +7,7 @@ import { buildPrompt, parseTaskPayload } from './utils.js';
|
|
|
7
7
|
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Call OpenAI and return task payload for
|
|
10
|
+
* Call OpenAI and return task payload for the configured target.
|
|
11
11
|
* @param {{ message: string, branch: string, repoName: string }} commitData
|
|
12
12
|
* @param {{ ai: { model?: string } }} config
|
|
13
13
|
* @returns {Promise<{ title: string, description: string, labels: string[] }>}
|
|
@@ -19,7 +19,7 @@ export async function generateOpenAI(commitData, config) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const model = config?.ai?.model || 'gpt-4o-mini';
|
|
22
|
-
const { system, user } = buildPrompt(commitData);
|
|
22
|
+
const { system, user } = buildPrompt(commitData, config?.target);
|
|
23
23
|
|
|
24
24
|
const response = await fetch(OPENAI_API_URL, {
|
|
25
25
|
method: 'POST',
|
|
@@ -45,7 +45,7 @@ export async function generateOpenAI(commitData, config) {
|
|
|
45
45
|
const data = await response.json();
|
|
46
46
|
const content = data?.choices?.[0]?.message?.content;
|
|
47
47
|
if (typeof content !== 'string') {
|
|
48
|
-
throw new
|
|
48
|
+
throw new TypeError('OpenAI response missing choices[0].message.content');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
return parseTaskPayload(content.trim());
|
package/src/ai/utils.js
CHANGED
|
@@ -7,27 +7,39 @@ const CONVENTIONAL_PREFIXES = /^(feat|fix|chore|docs|style|refactor|test|build|c
|
|
|
7
7
|
/**
|
|
8
8
|
* Build system + user prompt from commit data.
|
|
9
9
|
* @param {{ message: string, branch: string, repoName: string }} commitData
|
|
10
|
+
* @param {string} [target='jira']
|
|
10
11
|
* @returns {{ system: string, user: string }}
|
|
11
12
|
*/
|
|
12
13
|
const BATCH_SEP = '\n\n---\n\n';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
const TARGET_META = {
|
|
16
|
+
jira: { displayName: 'Jira', workItem: 'issue', priorityField: true },
|
|
17
|
+
trello: { displayName: 'Trello', workItem: 'card', priorityField: false },
|
|
18
|
+
linear: { displayName: 'Linear', workItem: 'issue', priorityField: true },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function buildPrompt(commitData, target = 'jira') {
|
|
15
22
|
const { message, branch, repoName } = commitData;
|
|
16
23
|
const isBatch = message.includes(BATCH_SEP);
|
|
24
|
+
const normalizedTarget = (target || 'jira').toLowerCase();
|
|
25
|
+
const meta = TARGET_META[normalizedTarget] || TARGET_META.jira;
|
|
17
26
|
const batchHint = isBatch
|
|
18
27
|
? ' The user input may contain multiple commits separated by "---"; produce one task that summarizes all of them.\n\n'
|
|
19
28
|
: '';
|
|
20
|
-
const
|
|
29
|
+
const priorityRule = meta.priorityField
|
|
30
|
+
? '- "priority": One of: "Highest", "High", "Medium", "Low", "Lowest". Infer from commit message (e.g. "urgent", "critical", "hotfix" → High; "minor", "tweak" → Low; unclear → "Medium"). Default to "Medium" if unsure.'
|
|
31
|
+
: '- "priority": Still provide one of "Highest", "High", "Medium", "Low", "Lowest". Some targets may not have a native priority field; it can be used for description context.';
|
|
32
|
+
const system = `You generate a ${meta.displayName} ${meta.workItem} from a Git commit. Reply with a single JSON object only, no markdown or extra text.
|
|
21
33
|
${batchHint}Keys:
|
|
22
|
-
- "title": Short, formal
|
|
23
|
-
- "description": Detailed description in plain language, suitable for
|
|
34
|
+
- "title": Short, formal ${meta.displayName} ${meta.workItem} summary (professional wording). Do NOT copy the commit message verbatim. Rewrite as a clear, formal title. Do NOT include prefixes like feat:, fix:, chore: in the title.
|
|
35
|
+
- "description": Detailed description in plain language, suitable for ${meta.displayName}. Expand and formalize the intent of the commit; do not just paste the commit message.
|
|
24
36
|
- "labels": Array of strings, e.g. ["auto", "commit"].
|
|
25
|
-
|
|
37
|
+
${priorityRule}`;
|
|
26
38
|
const user = `Repo: ${repoName}\nBranch: ${branch}\nCommit message:\n${message}\n\nGenerate the JSON object.`;
|
|
27
39
|
return { system, user };
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
const VALID_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'];
|
|
42
|
+
const VALID_PRIORITIES = new Set(['Highest', 'High', 'Medium', 'Low', 'Lowest']);
|
|
31
43
|
|
|
32
44
|
/**
|
|
33
45
|
* Parse and validate AI response. Expects { title, description, labels, priority? }.
|
|
@@ -43,15 +55,15 @@ export function parseTaskPayload(raw) {
|
|
|
43
55
|
throw new Error(`AI response is not valid JSON: ${err.message}`);
|
|
44
56
|
}
|
|
45
57
|
if (typeof obj.title !== 'string' || typeof obj.description !== 'string') {
|
|
46
|
-
throw new
|
|
58
|
+
throw new TypeError('AI response missing or invalid title/description (must be strings).');
|
|
47
59
|
}
|
|
48
60
|
if (!Array.isArray(obj.labels)) {
|
|
49
|
-
throw new
|
|
61
|
+
throw new TypeError('AI response labels must be an array of strings.');
|
|
50
62
|
}
|
|
51
63
|
const labels = obj.labels.filter((l) => typeof l === 'string');
|
|
52
64
|
const rawTitle = (obj.title || '').trim();
|
|
53
65
|
const title = rawTitle.replace(CONVENTIONAL_PREFIXES, '').trim() || rawTitle;
|
|
54
66
|
const rawPriority = (obj.priority || 'Medium').trim();
|
|
55
|
-
const priority = VALID_PRIORITIES.
|
|
67
|
+
const priority = VALID_PRIORITIES.has(rawPriority) ? rawPriority : 'Medium';
|
|
56
68
|
return { title, description: obj.description.trim(), labels, priority };
|
|
57
69
|
}
|
package/src/backend/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Backend abstraction: one createTask(payload, config) for all targets (Jira, Trello, …).
|
|
3
|
-
* Dispatches to the right adapter based on config.target.
|
|
3
|
+
* Dispatches to the right adapter based on config.target. Wraps adapter calls with retry (5xx, 429, network).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createIssue, addComment as jiraAddComment } from '../jira/client.js';
|
|
7
7
|
import { VALID_TARGETS } from '../config/constants.js';
|
|
8
8
|
import { buildJiraUrl } from '../utils/urls.js';
|
|
9
|
+
import { withRetry } from '../utils/retry.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Add a comment to an existing issue/card (link-to-existing feature).
|
|
@@ -19,16 +20,13 @@ export async function addComment(message, issueKey, config) {
|
|
|
19
20
|
if (!VALID_TARGETS.includes(target)) {
|
|
20
21
|
throw new Error(`Unknown target: "${target}". Supported: ${VALID_TARGETS.join(', ')}.`);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return linearAddComment(issueKey, message, config);
|
|
30
|
-
}
|
|
31
|
-
throw new Error(`Target "${target}" has no addComment adapter.`);
|
|
23
|
+
const run = () => {
|
|
24
|
+
if (target === 'jira') return jiraAddComment(issueKey, message, config);
|
|
25
|
+
if (target === 'trello') return import('../trello/client.js').then((m) => m.addComment(issueKey, message, config));
|
|
26
|
+
if (target === 'linear') return import('../linear/client.js').then((m) => m.addComment(issueKey, message, config));
|
|
27
|
+
throw new Error(`Target "${target}" has no addComment adapter.`);
|
|
28
|
+
};
|
|
29
|
+
return withRetry(run);
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
/**
|
|
@@ -44,20 +42,20 @@ export async function createTask(payload, config) {
|
|
|
44
42
|
throw new Error(`Unknown target: "${target}". Supported: ${VALID_TARGETS.join(', ')}.`);
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
const run = async () => {
|
|
46
|
+
if (target === 'jira') {
|
|
47
|
+
const { key } = await createIssue(payload, config);
|
|
48
|
+
return { key, url: buildJiraUrl(config, key) };
|
|
49
|
+
}
|
|
50
|
+
if (target === 'trello') {
|
|
51
|
+
const { createTask: createTrelloTask } = await import('../trello/client.js');
|
|
52
|
+
return createTrelloTask(payload, config);
|
|
53
|
+
}
|
|
54
|
+
if (target === 'linear') {
|
|
55
|
+
const { createTask: createLinearTask } = await import('../linear/client.js');
|
|
56
|
+
return createLinearTask(payload, config);
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Target "${target}" has no adapter.`);
|
|
59
|
+
};
|
|
60
|
+
return withRetry(run);
|
|
63
61
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haitask check — Validate config + env only (no pipeline).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadConfig } from '../config/load.js';
|
|
6
|
+
import { validateEnv } from '../config/init.js';
|
|
7
|
+
import { getEnvPaths } from '../config/env-loader.js';
|
|
8
|
+
|
|
9
|
+
export function runCheck() {
|
|
10
|
+
let config;
|
|
11
|
+
try {
|
|
12
|
+
config = loadConfig();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error('Config error:', err.message);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { valid, missing } = validateEnv(process.cwd(), config);
|
|
20
|
+
if (!valid) {
|
|
21
|
+
console.error('Missing env keys:', missing.join(', '));
|
|
22
|
+
console.log('Env is read from:', getEnvPaths().join(', '));
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const target = (config?.target || 'jira').toLowerCase();
|
|
28
|
+
const provider = (config?.ai?.provider || 'groq').toLowerCase();
|
|
29
|
+
console.log(`Config OK. Target: ${target}. AI: ${provider}.`);
|
|
30
|
+
console.log('Env OK.');
|
|
31
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
8
|
import { validateEnv } from '../config/init.js';
|
|
9
9
|
import { loadConfig } from '../config/load.js';
|
|
10
|
+
import { VALID_TARGETS } from '../config/constants.js';
|
|
10
11
|
|
|
11
12
|
const DEFAULT_MODELS = { groq: 'llama-3.1-8b-instant', deepseek: 'deepseek-chat', openai: 'gpt-4o-mini' };
|
|
12
13
|
|
|
@@ -136,8 +137,79 @@ const DEFAULT_RULES = {
|
|
|
136
137
|
};
|
|
137
138
|
const DEFAULT_AI = { provider: 'groq', model: DEFAULT_MODELS.groq };
|
|
138
139
|
|
|
140
|
+
function normalizePresetTarget(preset) {
|
|
141
|
+
const target = (preset || '').trim().toLowerCase();
|
|
142
|
+
if (!target) return null;
|
|
143
|
+
if (!VALID_TARGETS.includes(target)) return '__invalid__';
|
|
144
|
+
return target;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildPresetConfig(target) {
|
|
148
|
+
if (target === 'trello') {
|
|
149
|
+
return {
|
|
150
|
+
target: 'trello',
|
|
151
|
+
trello: { listId: 'YOUR_TRELLO_LIST_ID' },
|
|
152
|
+
ai: DEFAULT_AI,
|
|
153
|
+
rules: DEFAULT_RULES,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (target === 'linear') {
|
|
157
|
+
return {
|
|
158
|
+
target: 'linear',
|
|
159
|
+
linear: { teamId: 'YOUR_LINEAR_TEAM_ID' },
|
|
160
|
+
ai: DEFAULT_AI,
|
|
161
|
+
rules: DEFAULT_RULES,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
target: 'jira',
|
|
166
|
+
jira: {
|
|
167
|
+
baseUrl: 'https://your-domain.atlassian.net',
|
|
168
|
+
projectKey: 'PROJ',
|
|
169
|
+
issueType: 'Task',
|
|
170
|
+
transitionToStatus: 'Done',
|
|
171
|
+
},
|
|
172
|
+
ai: DEFAULT_AI,
|
|
173
|
+
rules: DEFAULT_RULES,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function buildInteractiveConfig(rl, quick) {
|
|
178
|
+
const targetAnswer = await question(rl, 'Target: 1 = Jira, 2 = Trello, 3 = Linear', '1');
|
|
179
|
+
const targetMap = { '1': 'jira', '2': 'trello', '3': 'linear' };
|
|
180
|
+
const target = targetMap[targetAnswer] || 'jira';
|
|
181
|
+
|
|
182
|
+
let rules;
|
|
183
|
+
let ai;
|
|
184
|
+
if (quick) {
|
|
185
|
+
rules = DEFAULT_RULES;
|
|
186
|
+
ai = DEFAULT_AI;
|
|
187
|
+
} else {
|
|
188
|
+
const aiProvider = await question(rl, 'AI provider (groq | deepseek | openai)', 'groq');
|
|
189
|
+
const allowedBranchesStr = await question(rl, 'Allowed branches (comma-separated)', 'main,develop,master');
|
|
190
|
+
const commitPrefixesStr = await question(rl, 'Commit prefixes (comma-separated)', 'feat,fix,chore');
|
|
191
|
+
rules = {
|
|
192
|
+
allowedBranches: parseList(allowedBranchesStr),
|
|
193
|
+
commitPrefixes: parseList(commitPrefixesStr),
|
|
194
|
+
};
|
|
195
|
+
ai = {
|
|
196
|
+
provider: aiProvider.toLowerCase(),
|
|
197
|
+
model: DEFAULT_MODELS[aiProvider.toLowerCase()] || DEFAULT_MODELS.groq,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let config;
|
|
202
|
+
if (target === 'jira') config = await askJiraConfig(rl, ai, rules);
|
|
203
|
+
else if (target === 'trello') config = quick ? await askTrelloConfigQuick(rl, ai, rules) : await askTrelloConfig(rl, ai, rules);
|
|
204
|
+
else config = await askLinearConfig(rl, ai, rules);
|
|
205
|
+
|
|
206
|
+
return { target, config };
|
|
207
|
+
}
|
|
208
|
+
|
|
139
209
|
export async function runInit(options = {}) {
|
|
140
210
|
const quick = options.quick === true;
|
|
211
|
+
const presetTarget = normalizePresetTarget(options.preset);
|
|
212
|
+
const usePreset = presetTarget && presetTarget !== '__invalid__';
|
|
141
213
|
const cwd = process.cwd();
|
|
142
214
|
const rcPath = resolve(cwd, '.haitaskrc');
|
|
143
215
|
|
|
@@ -147,38 +219,33 @@ export async function runInit(options = {}) {
|
|
|
147
219
|
return;
|
|
148
220
|
}
|
|
149
221
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
222
|
+
if (presetTarget === '__invalid__') {
|
|
223
|
+
console.error(`Invalid preset target "${options.preset}". Use one of: ${VALID_TARGETS.join(', ')}.`);
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
154
227
|
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
228
|
+
if (usePreset) {
|
|
229
|
+
const config = buildPresetConfig(presetTarget);
|
|
230
|
+
writeFileSync(rcPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
231
|
+
console.log(`Created .haitaskrc from preset: ${presetTarget}`);
|
|
232
|
+
writeEnvFile(cwd, presetTarget, '1');
|
|
158
233
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
} else {
|
|
165
|
-
const aiProvider = await question(rl, 'AI provider (groq | deepseek | openai)', 'groq');
|
|
166
|
-
const allowedBranchesStr = await question(rl, 'Allowed branches (comma-separated)', 'main,develop,master');
|
|
167
|
-
const commitPrefixesStr = await question(rl, 'Commit prefixes (comma-separated)', 'feat,fix,chore');
|
|
168
|
-
rules = {
|
|
169
|
-
allowedBranches: parseList(allowedBranchesStr),
|
|
170
|
-
commitPrefixes: parseList(commitPrefixesStr),
|
|
171
|
-
};
|
|
172
|
-
ai = {
|
|
173
|
-
provider: aiProvider.toLowerCase(),
|
|
174
|
-
model: DEFAULT_MODELS[aiProvider.toLowerCase()] || DEFAULT_MODELS.groq,
|
|
175
|
-
};
|
|
234
|
+
const { valid, missing } = validateEnv(cwd, config);
|
|
235
|
+
if (!valid) {
|
|
236
|
+
printEnvHints(config, missing);
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
return;
|
|
176
239
|
}
|
|
240
|
+
console.log('\nEnvironment looks good. Run "haitask run" after your next commit.');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
177
243
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
244
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
console.log(quick ? 'haitask init --quick (minimal questions, defaults for the rest).\n' : 'haitask init — answer the questions (Enter = use default).\n');
|
|
248
|
+
const { target, config } = await buildInteractiveConfig(rl, quick);
|
|
182
249
|
|
|
183
250
|
writeFileSync(rcPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
184
251
|
console.log('\nCreated .haitaskrc');
|
package/src/commands/run.js
CHANGED
|
@@ -13,18 +13,87 @@ function getDisplayUrl(config, key, resultUrl) {
|
|
|
13
13
|
return target === 'jira' ? buildJiraUrl(config, key) : key;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function printJson(data) {
|
|
17
|
+
console.log(JSON.stringify(data, null, 2));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function printFailure(json, message, prefix = null) {
|
|
21
|
+
if (json) {
|
|
22
|
+
printJson({ ok: false, error: prefix ? `${prefix}: ${message}` : message });
|
|
23
|
+
} else if (prefix) {
|
|
24
|
+
console.error(`${prefix}:`, message);
|
|
25
|
+
} else {
|
|
26
|
+
console.error(message);
|
|
27
|
+
}
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toJsonResult(result, config) {
|
|
32
|
+
const displayUrl = getDisplayUrl(config, result.key, result.url);
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
dry: !!result.dry,
|
|
36
|
+
skipped: !!result.skipped,
|
|
37
|
+
commented: !!result.commented,
|
|
38
|
+
key: result.key || null,
|
|
39
|
+
url: displayUrl || null,
|
|
40
|
+
payload: result.payload || null,
|
|
41
|
+
commit: result.commitData
|
|
42
|
+
? {
|
|
43
|
+
branch: result.commitData.branch,
|
|
44
|
+
commitHash: result.commitData.commitHash,
|
|
45
|
+
repoName: result.commitData.repoName,
|
|
46
|
+
count: result.commitData.count || 1,
|
|
47
|
+
}
|
|
48
|
+
: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function printDryResult(result) {
|
|
53
|
+
console.log('Dry run — no task created.');
|
|
54
|
+
if (result.commented) {
|
|
55
|
+
console.log('Would add comment to existing:', result.key);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const msg = result.commitData?.message || '';
|
|
59
|
+
const preview = msg.includes('\n\n---\n\n') ? `${result.commitData?.count ?? '?'} commits` : msg.split('\n')[0] || '';
|
|
60
|
+
console.log('Commit(s):', preview);
|
|
61
|
+
console.log('Would create task:', result.payload?.title || '');
|
|
62
|
+
if (result.payload?.description) {
|
|
63
|
+
const desc = result.payload.description;
|
|
64
|
+
console.log('Description (preview):', desc.slice(0, 120) + (desc.length > 120 ? '...' : ''));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function printSuccessResult(result, config) {
|
|
69
|
+
if (result.dry) {
|
|
70
|
+
printDryResult(result);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const displayUrl = getDisplayUrl(config, result.key, result.url);
|
|
75
|
+
if (result.skipped) {
|
|
76
|
+
console.log('Already created for this commit:', result.key);
|
|
77
|
+
} else if (result.commented) {
|
|
78
|
+
console.log('Added comment to:', result.key);
|
|
79
|
+
} else {
|
|
80
|
+
console.log('Created task:', result.key);
|
|
81
|
+
}
|
|
82
|
+
if (displayUrl && displayUrl !== result.key) console.log(displayUrl);
|
|
83
|
+
}
|
|
84
|
+
|
|
16
85
|
export async function runRun(options = {}) {
|
|
17
86
|
const dry = options.dry ?? false;
|
|
87
|
+
const json = options.json ?? false;
|
|
18
88
|
const type = options.type?.trim() || undefined;
|
|
19
89
|
const status = options.status?.trim() || undefined;
|
|
20
|
-
const commits =
|
|
90
|
+
const commits = Number(options.commits ?? 1);
|
|
21
91
|
|
|
22
92
|
let config;
|
|
23
93
|
try {
|
|
24
94
|
config = loadConfig();
|
|
25
95
|
} catch (err) {
|
|
26
|
-
|
|
27
|
-
process.exitCode = 1;
|
|
96
|
+
printFailure(json, err.message, 'Config error');
|
|
28
97
|
return;
|
|
29
98
|
}
|
|
30
99
|
|
|
@@ -32,37 +101,16 @@ export async function runRun(options = {}) {
|
|
|
32
101
|
const result = await runPipeline(config, { dry, issueType: type, transitionToStatus: status, commits });
|
|
33
102
|
|
|
34
103
|
if (!result.ok) {
|
|
35
|
-
|
|
36
|
-
process.exitCode = 1;
|
|
104
|
+
printFailure(json, result.error || 'Pipeline failed.');
|
|
37
105
|
return;
|
|
38
106
|
}
|
|
39
107
|
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
if (result.commented) {
|
|
43
|
-
console.log('Would add comment to existing:', result.key);
|
|
44
|
-
} else {
|
|
45
|
-
const msg = result.commitData?.message || '';
|
|
46
|
-
const preview = msg.includes('\n\n---\n\n') ? `${result.commitData?.count ?? '?'} commits` : msg.split('\n')[0] || '';
|
|
47
|
-
console.log('Commit(s):', preview);
|
|
48
|
-
console.log('Would create task:', result.payload?.title || '');
|
|
49
|
-
if (result.payload?.description) {
|
|
50
|
-
const desc = result.payload.description;
|
|
51
|
-
console.log('Description (preview):', desc.slice(0, 120) + (desc.length > 120 ? '...' : ''));
|
|
52
|
-
}
|
|
53
|
-
}
|
|
108
|
+
if (json) {
|
|
109
|
+
printJson(toJsonResult(result, config));
|
|
54
110
|
return;
|
|
55
111
|
}
|
|
56
|
-
|
|
57
|
-
const displayUrl = getDisplayUrl(config, result.key, result.url);
|
|
58
|
-
if (result.commented) {
|
|
59
|
-
console.log('Added comment to:', result.key);
|
|
60
|
-
} else {
|
|
61
|
-
console.log('Created task:', result.key);
|
|
62
|
-
}
|
|
63
|
-
if (displayUrl && displayUrl !== result.key) console.log(displayUrl);
|
|
112
|
+
printSuccessResult(result, config);
|
|
64
113
|
} catch (err) {
|
|
65
|
-
|
|
66
|
-
process.exitCode = 1;
|
|
114
|
+
printFailure(json, err.message, 'Error');
|
|
67
115
|
}
|
|
68
116
|
}
|
package/src/core/pipeline.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* Pipeline: Git → AI → target (Jira, Trello, …).
|
|
3
3
|
* Orchestrates steps. No direct I/O (no console.log).
|
|
4
4
|
* Returns structured result for CLI to display.
|
|
5
|
+
* Idempotency: same commit hash → skip create, return stored key/url.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { getLatestCommitData, getLatestCommitsData } from '../git/commit.js';
|
|
8
9
|
import { generateTaskPayload } from '../ai/index.js';
|
|
9
10
|
import { createTask, addComment } from '../backend/index.js';
|
|
10
11
|
import { extractIssueKey } from '../utils/issue-key.js';
|
|
12
|
+
import { readState, writeState } from '../utils/idempotency.js';
|
|
11
13
|
|
|
12
14
|
const BATCH_SEP = '\n\n---\n\n';
|
|
13
15
|
|
|
@@ -75,12 +77,22 @@ export async function runPipeline(config, options = {}) {
|
|
|
75
77
|
return { ok: true, dry: true, payload, commitData };
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
const commitHash = commitData.commitHash;
|
|
81
|
+
const repoRoot = commitData.repoRoot;
|
|
82
|
+
const state = repoRoot && commitHash ? readState(repoRoot) : null;
|
|
83
|
+
if (state?.commitHash === commitHash && state?.taskKey) {
|
|
84
|
+
return { ok: true, skipped: true, key: state.taskKey, url: state.taskUrl, payload, commitData };
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
const mergedConfig =
|
|
79
88
|
target === 'jira'
|
|
80
89
|
? mergeJiraOverrides(config, { issueType: typeOverride, transitionToStatus: statusOverride })
|
|
81
90
|
: config;
|
|
82
91
|
|
|
83
92
|
const { key, url } = await createTask(payload, mergedConfig);
|
|
93
|
+
if (repoRoot && commitHash && key) {
|
|
94
|
+
writeState(repoRoot, { commitHash, taskKey: key, taskUrl: url });
|
|
95
|
+
}
|
|
84
96
|
return { ok: true, key, url, payload, commitData };
|
|
85
97
|
}
|
|
86
98
|
|
package/src/git/commit.js
CHANGED
|
@@ -10,8 +10,8 @@ const CWD = process.cwd();
|
|
|
10
10
|
const BATCH_SEP = '\n\n---\n\n';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Get latest commit message, current branch, and
|
|
14
|
-
* @returns {Promise<{ message: string, branch: string, repoName: string }>}
|
|
13
|
+
* Get latest commit message, current branch, repo root, and commit hash (HEAD).
|
|
14
|
+
* @returns {Promise<{ message: string, branch: string, repoName: string, repoRoot: string, commitHash: string }>}
|
|
15
15
|
* @throws {Error} If not a git repo or git command fails
|
|
16
16
|
*/
|
|
17
17
|
export async function getLatestCommitData() {
|
|
@@ -19,24 +19,26 @@ export async function getLatestCommitData() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Get last N commit messages combined, current branch, and
|
|
22
|
+
* Get last N commit messages combined, current branch, repo name/root, and latest commit hash (HEAD).
|
|
23
23
|
* @param {number} n - Number of commits (>= 1)
|
|
24
|
-
* @returns {Promise<{ message: string, branch: string, repoName: string, count: number }>}
|
|
24
|
+
* @returns {Promise<{ message: string, branch: string, repoName: string, repoRoot: string, commitHash: string, count: number }>}
|
|
25
25
|
*/
|
|
26
26
|
export async function getLatestCommitsData(n = 1) {
|
|
27
27
|
const num = Math.max(1, Number(n) || 1);
|
|
28
|
-
const [logResult, branchResult, rootResult] = await Promise.all([
|
|
28
|
+
const [logResult, branchResult, rootResult, hashResult] = await Promise.all([
|
|
29
29
|
execa('git', ['log', `-${num}`, '--pretty=format:%B%x00'], { cwd: CWD }),
|
|
30
30
|
execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: CWD }),
|
|
31
31
|
execa('git', ['rev-parse', '--show-toplevel'], { cwd: CWD }),
|
|
32
|
+
execa('git', ['rev-parse', 'HEAD'], { cwd: CWD }),
|
|
32
33
|
]);
|
|
33
34
|
|
|
34
35
|
const raw = logResult.stdout || '';
|
|
35
36
|
const branch = (branchResult.stdout || '').trim();
|
|
36
37
|
const repoRoot = (rootResult.stdout || '').trim();
|
|
37
38
|
const repoName = repoRoot ? basename(repoRoot) : '';
|
|
39
|
+
const commitHash = (hashResult.stdout || '').trim();
|
|
38
40
|
|
|
39
41
|
const parts = raw.split('\0').map((s) => s.trim()).filter(Boolean).slice(0, num);
|
|
40
42
|
const message = parts.length > 1 ? parts.join(BATCH_SEP) : (parts[0] || raw.trim());
|
|
41
|
-
return { message, branch, repoName, count: parts.length };
|
|
43
|
+
return { message, branch, repoName, repoRoot, commitHash, count: parts.length };
|
|
42
44
|
}
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadEnvFiles } from './config/env-loader.js';
|
|
|
5
5
|
import { program } from 'commander';
|
|
6
6
|
import { runInit } from './commands/init.js';
|
|
7
7
|
import { runRun } from './commands/run.js';
|
|
8
|
+
import { runCheck } from './commands/check.js';
|
|
8
9
|
|
|
9
10
|
loadEnvFiles();
|
|
10
11
|
|
|
@@ -20,12 +21,19 @@ program
|
|
|
20
21
|
.command('init')
|
|
21
22
|
.description('Create .haitaskrc and validate environment')
|
|
22
23
|
.option('--quick', 'Use defaults (target + minimal questions); one Enter for Jira/Trello/Linear choice')
|
|
24
|
+
.option('--preset <target>', 'Preset target without target prompt (jira|trello|linear)')
|
|
23
25
|
.action((opts) => runInit(opts));
|
|
24
26
|
|
|
27
|
+
program
|
|
28
|
+
.command('check')
|
|
29
|
+
.description('Validate .haitaskrc and required env without running pipeline')
|
|
30
|
+
.action(() => runCheck());
|
|
31
|
+
|
|
25
32
|
program
|
|
26
33
|
.command('run')
|
|
27
34
|
.description('Run full pipeline: Git → AI → target (Jira, Trello, or Linear)')
|
|
28
35
|
.option('--dry', 'Skip creating task, run everything else')
|
|
36
|
+
.option('--json', 'Print machine-readable JSON output')
|
|
29
37
|
.option('-c, --commits <n>', 'Number of commits to combine into one task (default: 1)', '1')
|
|
30
38
|
.option('-t, --type <type>', 'Jira issue type for this run (e.g. Task, Bug, Story). Overrides .haitaskrc jira.issueType')
|
|
31
39
|
.option('-s, --status <status>', 'Jira transition-to status after create (e.g. Done, "To Do"). Overrides .haitaskrc jira.transitionToStatus')
|
package/src/jira/client.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Jira Cloud REST API v3.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { getHttpHint } from '../utils/http-hints.js';
|
|
7
|
+
|
|
6
8
|
const ASSIGN_DELAY_MS = 4000;
|
|
7
9
|
const DEFAULT_PROJECT_KEY = 'PROJ';
|
|
8
10
|
const DEFAULT_ISSUE_TYPE = 'Task';
|
|
@@ -207,7 +209,11 @@ export async function createIssue(payload, config) {
|
|
|
207
209
|
}
|
|
208
210
|
if (!createRes.ok) {
|
|
209
211
|
const errText = createRes.bodyUsed ? firstErrorText : await createRes.text();
|
|
210
|
-
|
|
212
|
+
const hint = getHttpHint('jira', createRes.status);
|
|
213
|
+
const msg = `Jira API error ${createRes.status}: ${errText || createRes.statusText}${hint ? ' ' + hint : ''}`;
|
|
214
|
+
const err = new Error(msg);
|
|
215
|
+
err.status = createRes.status;
|
|
216
|
+
throw err;
|
|
211
217
|
}
|
|
212
218
|
}
|
|
213
219
|
|
|
@@ -250,7 +256,10 @@ export async function addComment(issueKey, bodyText, config) {
|
|
|
250
256
|
});
|
|
251
257
|
if (!res.ok) {
|
|
252
258
|
const text = await res.text();
|
|
253
|
-
|
|
259
|
+
const hint = getHttpHint('jira', res.status);
|
|
260
|
+
const err = new Error(`Jira comment API ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
|
|
261
|
+
err.status = res.status;
|
|
262
|
+
throw err;
|
|
254
263
|
}
|
|
255
264
|
const url = `${baseUrl}/browse/${issueKey}`;
|
|
256
265
|
return { key: issueKey, url };
|
package/src/linear/client.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* API: https://api.linear.app/graphql
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { getHttpHint } from '../utils/http-hints.js';
|
|
7
|
+
|
|
6
8
|
const LINEAR_GRAPHQL = 'https://api.linear.app/graphql';
|
|
7
9
|
|
|
8
10
|
const CREATE_ISSUE_MUTATION = `
|
|
@@ -13,6 +15,59 @@ const CREATE_ISSUE_MUTATION = `
|
|
|
13
15
|
}
|
|
14
16
|
`;
|
|
15
17
|
|
|
18
|
+
const TEAM_LABELS_QUERY = `
|
|
19
|
+
query TeamLabels($teamId: String!) {
|
|
20
|
+
team(id: $teamId) {
|
|
21
|
+
labels {
|
|
22
|
+
nodes { id name }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function toLinearPriority(priority) {
|
|
29
|
+
const p = (priority || '').trim();
|
|
30
|
+
if (p === 'Highest') return 1;
|
|
31
|
+
if (p === 'High') return 2;
|
|
32
|
+
if (p === 'Low' || p === 'Lowest') return 4;
|
|
33
|
+
return 3;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resolveLinearLabelIds(apiKey, teamId, labels) {
|
|
37
|
+
const desired = (Array.isArray(labels) ? labels : [])
|
|
38
|
+
.filter((v) => typeof v === 'string' && v.trim())
|
|
39
|
+
.map((v) => v.trim().toLowerCase());
|
|
40
|
+
if (desired.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(LINEAR_GRAPHQL, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
Authorization: apiKey,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
query: TEAM_LABELS_QUERY,
|
|
52
|
+
variables: { teamId },
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) return [];
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
const nodes = data?.data?.team?.labels?.nodes;
|
|
58
|
+
if (!Array.isArray(nodes)) return [];
|
|
59
|
+
|
|
60
|
+
const byName = new Map();
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
const key = typeof node?.name === 'string' ? node.name.trim().toLowerCase() : '';
|
|
63
|
+
if (key && typeof node?.id === 'string') byName.set(key, node.id);
|
|
64
|
+
}
|
|
65
|
+
return desired.map((name) => byName.get(name)).filter(Boolean);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
16
71
|
/**
|
|
17
72
|
* Create a Linear issue from AI payload. Same interface as Jira/Trello: returns { key, url }.
|
|
18
73
|
* @param {object} payload - { title, description, labels, priority } from AI
|
|
@@ -35,6 +90,8 @@ export async function createTask(payload, config) {
|
|
|
35
90
|
|
|
36
91
|
const title = (payload?.title || '').trim() || 'Untitled';
|
|
37
92
|
const description = (payload?.description || '').trim() || '';
|
|
93
|
+
const priority = toLinearPriority(payload?.priority);
|
|
94
|
+
const labelIds = await resolveLinearLabelIds(apiKey, teamId, payload?.labels);
|
|
38
95
|
|
|
39
96
|
const res = await fetch(LINEAR_GRAPHQL, {
|
|
40
97
|
method: 'POST',
|
|
@@ -50,6 +107,8 @@ export async function createTask(payload, config) {
|
|
|
50
107
|
teamId,
|
|
51
108
|
title,
|
|
52
109
|
...(description && { description }),
|
|
110
|
+
...(Number.isFinite(priority) && { priority }),
|
|
111
|
+
...(labelIds.length > 0 && { labelIds }),
|
|
53
112
|
},
|
|
54
113
|
},
|
|
55
114
|
}),
|
|
@@ -57,7 +116,10 @@ export async function createTask(payload, config) {
|
|
|
57
116
|
|
|
58
117
|
if (!res.ok) {
|
|
59
118
|
const text = await res.text();
|
|
60
|
-
|
|
119
|
+
const hint = getHttpHint('linear', res.status);
|
|
120
|
+
const err = new Error(`Linear API error ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
|
|
121
|
+
err.status = res.status;
|
|
122
|
+
throw err;
|
|
61
123
|
}
|
|
62
124
|
|
|
63
125
|
const data = await res.json();
|
|
@@ -108,7 +170,13 @@ export async function addComment(identifier, bodyText, config) {
|
|
|
108
170
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: apiKey },
|
|
109
171
|
body: JSON.stringify({ query: GET_ISSUE_QUERY, variables: { identifier: id } }),
|
|
110
172
|
});
|
|
111
|
-
if (!resIssue.ok)
|
|
173
|
+
if (!resIssue.ok) {
|
|
174
|
+
const text = await resIssue.text();
|
|
175
|
+
const hint = getHttpHint('linear', resIssue.status);
|
|
176
|
+
const e = new Error(`Linear API ${resIssue.status}: ${text}${hint ? ' ' + hint : ''}`);
|
|
177
|
+
e.status = resIssue.status;
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
112
180
|
const dataIssue = await resIssue.json();
|
|
113
181
|
const issue = dataIssue?.data?.issue;
|
|
114
182
|
if (!issue?.id) {
|
|
@@ -124,7 +192,13 @@ export async function addComment(identifier, bodyText, config) {
|
|
|
124
192
|
variables: { input: { issueId: issue.id, body: (bodyText || '').trim() || '(no message)' } },
|
|
125
193
|
}),
|
|
126
194
|
});
|
|
127
|
-
if (!resComment.ok)
|
|
195
|
+
if (!resComment.ok) {
|
|
196
|
+
const text = await resComment.text();
|
|
197
|
+
const hint = getHttpHint('linear', resComment.status);
|
|
198
|
+
const e = new Error(`Linear comment API ${resComment.status}: ${text}${hint ? ' ' + hint : ''}`);
|
|
199
|
+
e.status = resComment.status;
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
128
202
|
const dataComment = await resComment.json();
|
|
129
203
|
if (dataComment?.errors?.[0]) throw new Error(`Linear comment: ${dataComment.errors[0].message}`);
|
|
130
204
|
|
package/src/trello/client.js
CHANGED
|
@@ -4,9 +4,54 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { TRELLO_ID_REGEX } from '../config/constants.js';
|
|
7
|
+
import { getHttpHint } from '../utils/http-hints.js';
|
|
7
8
|
|
|
8
9
|
const TRELLO_API = 'https://api.trello.com/1';
|
|
9
10
|
|
|
11
|
+
function withPriorityInDescription(description, priority) {
|
|
12
|
+
const p = (priority || '').trim();
|
|
13
|
+
if (!p) return description;
|
|
14
|
+
return `Priority: ${p}\n\n${description}`.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function resolveTrelloLabelIds(apiKey, token, listId, labels) {
|
|
18
|
+
const desired = (Array.isArray(labels) ? labels : [])
|
|
19
|
+
.filter((v) => typeof v === 'string' && v.trim())
|
|
20
|
+
.map((v) => v.trim().toLowerCase());
|
|
21
|
+
if (desired.length === 0) return [];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const query = new URLSearchParams({ key: apiKey, token });
|
|
25
|
+
const listRes = await fetch(`${TRELLO_API}/lists/${encodeURIComponent(listId)}?fields=idBoard&${query.toString()}`, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
headers: { Accept: 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
if (!listRes.ok) return [];
|
|
30
|
+
const listData = await listRes.json();
|
|
31
|
+
const boardId = listData?.idBoard;
|
|
32
|
+
if (!boardId || !TRELLO_ID_REGEX.test(boardId)) return [];
|
|
33
|
+
|
|
34
|
+
const labelsRes = await fetch(
|
|
35
|
+
`${TRELLO_API}/boards/${encodeURIComponent(boardId)}/labels?fields=id,name&limit=1000&${query.toString()}`,
|
|
36
|
+
{ method: 'GET', headers: { Accept: 'application/json' } }
|
|
37
|
+
);
|
|
38
|
+
if (!labelsRes.ok) return [];
|
|
39
|
+
const boardLabels = await labelsRes.json();
|
|
40
|
+
if (!Array.isArray(boardLabels)) return [];
|
|
41
|
+
|
|
42
|
+
const byName = new Map();
|
|
43
|
+
for (const label of boardLabels) {
|
|
44
|
+
const name = typeof label?.name === 'string' ? label.name.trim().toLowerCase() : '';
|
|
45
|
+
if (name && typeof label?.id === 'string' && TRELLO_ID_REGEX.test(label.id)) {
|
|
46
|
+
byName.set(name, label.id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return desired.map((name) => byName.get(name)).filter(Boolean);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
10
55
|
/**
|
|
11
56
|
* Create a Trello card from AI payload. Same interface as Jira adapter: returns { key, url }.
|
|
12
57
|
* @param {object} payload - { title, description, labels, priority } from AI
|
|
@@ -35,7 +80,7 @@ export async function createTask(payload, config) {
|
|
|
35
80
|
}
|
|
36
81
|
|
|
37
82
|
const name = (payload?.title || '').trim() || 'Untitled';
|
|
38
|
-
const desc = (payload?.description || '').trim() || '';
|
|
83
|
+
const desc = withPriorityInDescription((payload?.description || '').trim() || '', payload?.priority);
|
|
39
84
|
|
|
40
85
|
const query = new URLSearchParams({ key: apiKey, token });
|
|
41
86
|
const body = { idList: listId, name, desc };
|
|
@@ -44,12 +89,16 @@ export async function createTask(payload, config) {
|
|
|
44
89
|
const memberId = trello.memberId?.trim() || process.env.TRELLO_MEMBER_ID?.trim();
|
|
45
90
|
if (memberId && TRELLO_ID_REGEX.test(memberId)) body.idMembers = [memberId];
|
|
46
91
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
body.idLabels = labelIds
|
|
92
|
+
const configLabelIds = Array.isArray(trello.labelIds)
|
|
93
|
+
? trello.labelIds
|
|
50
94
|
.filter((id) => typeof id === 'string' && id.trim())
|
|
51
95
|
.map((id) => id.trim())
|
|
52
|
-
.filter((id) => TRELLO_ID_REGEX.test(id))
|
|
96
|
+
.filter((id) => TRELLO_ID_REGEX.test(id))
|
|
97
|
+
: [];
|
|
98
|
+
const aiLabelIds = await resolveTrelloLabelIds(apiKey, token, listId, payload?.labels);
|
|
99
|
+
const mergedLabelIds = [...new Set([...configLabelIds, ...aiLabelIds])];
|
|
100
|
+
if (mergedLabelIds.length > 0) {
|
|
101
|
+
body.idLabels = mergedLabelIds;
|
|
53
102
|
}
|
|
54
103
|
|
|
55
104
|
const url = `${TRELLO_API}/cards?${query.toString()}`;
|
|
@@ -61,7 +110,10 @@ export async function createTask(payload, config) {
|
|
|
61
110
|
|
|
62
111
|
if (!res.ok) {
|
|
63
112
|
const text = await res.text();
|
|
64
|
-
|
|
113
|
+
const hint = getHttpHint('trello', res.status);
|
|
114
|
+
const err = new Error(`Trello API error ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
|
|
115
|
+
err.status = res.status;
|
|
116
|
+
throw err;
|
|
65
117
|
}
|
|
66
118
|
|
|
67
119
|
const card = await res.json();
|
|
@@ -95,7 +147,10 @@ export async function addComment(cardIdOrShortLink, bodyText, config) {
|
|
|
95
147
|
});
|
|
96
148
|
if (!res.ok) {
|
|
97
149
|
const text = await res.text();
|
|
98
|
-
|
|
150
|
+
const hint = getHttpHint('trello', res.status);
|
|
151
|
+
const err = new Error(`Trello comment API ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
|
|
152
|
+
err.status = res.status;
|
|
153
|
+
throw err;
|
|
99
154
|
}
|
|
100
155
|
return { key: id, url: `https://trello.com/c/${id}` };
|
|
101
156
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short hints for API errors (401, 403, 404) to help users fix config.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const HINTS = {
|
|
6
|
+
jira: {
|
|
7
|
+
401: 'Check JIRA_EMAIL and JIRA_API_TOKEN in .env.',
|
|
8
|
+
403: 'Check project permissions and that the user is an assignable user.',
|
|
9
|
+
404: 'Check JIRA_BASE_URL and jira.projectKey in .haitaskrc.',
|
|
10
|
+
},
|
|
11
|
+
trello: {
|
|
12
|
+
401: 'Check TRELLO_API_KEY and TRELLO_TOKEN in .env. Get them at https://trello.com/app-key',
|
|
13
|
+
403: 'Check board and list access (token may not have write permission).',
|
|
14
|
+
404: 'Check trello.listId (list where cards go) or the card ID.',
|
|
15
|
+
},
|
|
16
|
+
linear: {
|
|
17
|
+
401: 'Check LINEAR_API_KEY in .env. Get a key at https://linear.app/settings/api',
|
|
18
|
+
403: 'Check team permissions and API key scope.',
|
|
19
|
+
404: 'Check linear.teamId in .haitaskrc or the issue identifier.',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} target - 'jira' | 'trello' | 'linear'
|
|
25
|
+
* @param {number} status - HTTP status
|
|
26
|
+
* @returns {string} Hint to append to error message, or ''
|
|
27
|
+
*/
|
|
28
|
+
export function getHttpHint(target, status) {
|
|
29
|
+
const s = Number(status);
|
|
30
|
+
if (!s || s < 400) return '';
|
|
31
|
+
const map = HINTS[target];
|
|
32
|
+
if (!map) return '';
|
|
33
|
+
return map[s] || '';
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency: remember last created task per commit hash so we don't create duplicates.
|
|
3
|
+
* State file: .git/haitask-state.json (inside repo, not committed).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const STATE_FILENAME = 'haitask-state.json';
|
|
10
|
+
|
|
11
|
+
function statePath(repoRoot) {
|
|
12
|
+
return join(repoRoot || '', '.git', STATE_FILENAME);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read last stored result for idempotency check.
|
|
17
|
+
* @param {string} repoRoot - Git repo root path (e.g. from git rev-parse --show-toplevel)
|
|
18
|
+
* @returns {{ commitHash?: string, taskKey?: string, taskUrl?: string } | null}
|
|
19
|
+
*/
|
|
20
|
+
export function readState(repoRoot) {
|
|
21
|
+
if (!repoRoot?.trim()) return null;
|
|
22
|
+
const path = statePath(repoRoot);
|
|
23
|
+
if (!existsSync(path)) return null;
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(path, 'utf-8');
|
|
26
|
+
const data = JSON.parse(raw);
|
|
27
|
+
return data && typeof data === 'object' ? data : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write state after successfully creating a task (so next run with same commit skips).
|
|
35
|
+
* @param {string} repoRoot - Git repo root path
|
|
36
|
+
* @param {{ commitHash: string, taskKey: string, taskUrl?: string }} state
|
|
37
|
+
*/
|
|
38
|
+
export function writeState(repoRoot, state) {
|
|
39
|
+
if (!repoRoot?.trim() || !state?.commitHash || !state?.taskKey) return;
|
|
40
|
+
const path = statePath(repoRoot);
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(path, JSON.stringify(state, null, 0), 'utf-8');
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore write errors (e.g. read-only .git)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry a promise-returning function on retryable failures (5xx, 429, network).
|
|
3
|
+
* Non-retryable: 4xx (except 429). Errors with .status are treated as HTTP status.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RETRIES = 2;
|
|
7
|
+
const DEFAULT_DELAY_MS = 1000;
|
|
8
|
+
const DEFAULT_BACKOFF = 2;
|
|
9
|
+
|
|
10
|
+
function isRetryable(err) {
|
|
11
|
+
if (!err) return false;
|
|
12
|
+
const status = err.status;
|
|
13
|
+
if (status != null) {
|
|
14
|
+
if (status === 429) return true; // rate limit
|
|
15
|
+
if (status >= 500 && status < 600) return true; // server error
|
|
16
|
+
return false; // 4xx (except 429) — don't retry
|
|
17
|
+
}
|
|
18
|
+
// Network / timeout / DNS
|
|
19
|
+
if (err instanceof TypeError && err.message?.includes?.('fetch')) return true;
|
|
20
|
+
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sleep(ms) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call fn(); on reject, retry up to retries times if error is retryable (5xx, 429, network).
|
|
30
|
+
* @param {() => Promise<T>} fn
|
|
31
|
+
* @param {{ retries?: number, delayMs?: number, backoff?: number }} options
|
|
32
|
+
* @returns {Promise<T>}
|
|
33
|
+
*/
|
|
34
|
+
export async function withRetry(fn, options = {}) {
|
|
35
|
+
const retries = options.retries ?? DEFAULT_RETRIES;
|
|
36
|
+
let delayMs = options.delayMs ?? DEFAULT_DELAY_MS;
|
|
37
|
+
const backoff = options.backoff ?? DEFAULT_BACKOFF;
|
|
38
|
+
let lastError;
|
|
39
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
lastError = err;
|
|
44
|
+
if (attempt === retries || !isRetryable(err)) throw err;
|
|
45
|
+
await sleep(delayMs);
|
|
46
|
+
delayMs *= backoff;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw lastError;
|
|
50
|
+
}
|