haitask 0.3.1 → 0.3.3

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 CHANGED
@@ -62,6 +62,7 @@ To try without creating a task: `haitask run --dry`.
62
62
  |---------|-------------|
63
63
  | `haitask init` | Interactive setup: target, AI, rules → writes `.haitaskrc` and optional `.env` |
64
64
  | `haitask init --quick` | Minimal prompts: target + required fields only; defaults for AI, branches, prefixes |
65
+ | `haitask check` | Validate `.haitaskrc` + required env keys without running the pipeline |
65
66
  | `haitask run` | Creates a task from the latest commit (Jira / Trello / Linear) |
66
67
  | `haitask run --dry` | Same flow, but does not create a task |
67
68
  | `haitask run --commits N` | Combine the last N commits into one task (e.g. `--commits 3`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haitask",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "HAITASK — 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",
@@ -44,4 +44,4 @@
44
44
  "dotenv": "^16.4.5",
45
45
  "execa": "^9.5.2"
46
46
  }
47
- }
47
+ }
@@ -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
- if (target === 'jira') return jiraAddComment(issueKey, message, config);
23
- if (target === 'trello') {
24
- const { addComment: trelloAddComment } = await import('../trello/client.js');
25
- return trelloAddComment(issueKey, message, config);
26
- }
27
- if (target === 'linear') {
28
- const { addComment: linearAddComment } = await import('../linear/client.js');
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
- if (target === 'jira') {
48
- const { key } = await createIssue(payload, config);
49
- return { key, url: buildJiraUrl(config, key) };
50
- }
51
-
52
- if (target === 'trello') {
53
- const { createTask: createTrelloTask } = await import('../trello/client.js');
54
- return createTrelloTask(payload, config);
55
- }
56
-
57
- if (target === 'linear') {
58
- const { createTask: createLinearTask } = await import('../linear/client.js');
59
- return createLinearTask(payload, config);
60
- }
61
-
62
- throw new Error(`Target "${target}" has no adapter.`);
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
+ }
@@ -55,7 +55,9 @@ export async function runRun(options = {}) {
55
55
  }
56
56
 
57
57
  const displayUrl = getDisplayUrl(config, result.key, result.url);
58
- if (result.commented) {
58
+ if (result.skipped) {
59
+ console.log('Already created for this commit:', result.key);
60
+ } else if (result.commented) {
59
61
  console.log('Added comment to:', result.key);
60
62
  } else {
61
63
  console.log('Created task:', result.key);
@@ -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 repo (root folder) name.
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 repo name.
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
 
@@ -22,6 +23,11 @@ program
22
23
  .option('--quick', 'Use defaults (target + minimal questions); one Enter for Jira/Trello/Linear choice')
23
24
  .action((opts) => runInit(opts));
24
25
 
26
+ program
27
+ .command('check')
28
+ .description('Validate .haitaskrc and required env without running pipeline')
29
+ .action(() => runCheck());
30
+
25
31
  program
26
32
  .command('run')
27
33
  .description('Run full pipeline: Git → AI → target (Jira, Trello, or Linear)')
@@ -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
- throw new Error(`Jira API error ${createRes.status}: ${errText || createRes.statusText}`);
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
- throw new Error(`Jira comment API ${res.status}: ${text || res.statusText}`);
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 };
@@ -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 = `
@@ -57,7 +59,10 @@ export async function createTask(payload, config) {
57
59
 
58
60
  if (!res.ok) {
59
61
  const text = await res.text();
60
- throw new Error(`Linear API error ${res.status}: ${text || res.statusText}`);
62
+ const hint = getHttpHint('linear', res.status);
63
+ const err = new Error(`Linear API error ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
64
+ err.status = res.status;
65
+ throw err;
61
66
  }
62
67
 
63
68
  const data = await res.json();
@@ -108,7 +113,13 @@ export async function addComment(identifier, bodyText, config) {
108
113
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: apiKey },
109
114
  body: JSON.stringify({ query: GET_ISSUE_QUERY, variables: { identifier: id } }),
110
115
  });
111
- if (!resIssue.ok) throw new Error(`Linear API ${resIssue.status}: ${await resIssue.text()}`);
116
+ if (!resIssue.ok) {
117
+ const text = await resIssue.text();
118
+ const hint = getHttpHint('linear', resIssue.status);
119
+ const e = new Error(`Linear API ${resIssue.status}: ${text}${hint ? ' ' + hint : ''}`);
120
+ e.status = resIssue.status;
121
+ throw e;
122
+ }
112
123
  const dataIssue = await resIssue.json();
113
124
  const issue = dataIssue?.data?.issue;
114
125
  if (!issue?.id) {
@@ -124,7 +135,13 @@ export async function addComment(identifier, bodyText, config) {
124
135
  variables: { input: { issueId: issue.id, body: (bodyText || '').trim() || '(no message)' } },
125
136
  }),
126
137
  });
127
- if (!resComment.ok) throw new Error(`Linear comment API ${resComment.status}: ${await resComment.text()}`);
138
+ if (!resComment.ok) {
139
+ const text = await resComment.text();
140
+ const hint = getHttpHint('linear', resComment.status);
141
+ const e = new Error(`Linear comment API ${resComment.status}: ${text}${hint ? ' ' + hint : ''}`);
142
+ e.status = resComment.status;
143
+ throw e;
144
+ }
128
145
  const dataComment = await resComment.json();
129
146
  if (dataComment?.errors?.[0]) throw new Error(`Linear comment: ${dataComment.errors[0].message}`);
130
147
 
@@ -4,6 +4,7 @@
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
 
@@ -61,7 +62,10 @@ export async function createTask(payload, config) {
61
62
 
62
63
  if (!res.ok) {
63
64
  const text = await res.text();
64
- throw new Error(`Trello API error ${res.status}: ${text || res.statusText}`);
65
+ const hint = getHttpHint('trello', res.status);
66
+ const err = new Error(`Trello API error ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
67
+ err.status = res.status;
68
+ throw err;
65
69
  }
66
70
 
67
71
  const card = await res.json();
@@ -95,7 +99,10 @@ export async function addComment(cardIdOrShortLink, bodyText, config) {
95
99
  });
96
100
  if (!res.ok) {
97
101
  const text = await res.text();
98
- throw new Error(`Trello comment API ${res.status}: ${text || res.statusText}`);
102
+ const hint = getHttpHint('trello', res.status);
103
+ const err = new Error(`Trello comment API ${res.status}: ${text || res.statusText}${hint ? ' ' + hint : ''}`);
104
+ err.status = res.status;
105
+ throw err;
99
106
  }
100
107
  return { key: id, url: `https://trello.com/c/${id}` };
101
108
  }
@@ -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
+ }