haitask 0.3.3 → 0.4.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/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,12 +64,72 @@ 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` |
65
68
  | `haitask check` | Validate `.haitaskrc` + required env keys without running the pipeline |
66
69
  | `haitask run` | Creates a task from the latest commit (Jira / Trello / Linear) |
67
70
  | `haitask run --dry` | Same flow, but does not create a task |
71
+ | `haitask run --json` | Print machine-readable JSON output (works with `--dry`) |
68
72
  | `haitask run --commits N` | Combine the last N commits into one task (e.g. `--commits 3`) |
69
73
  | `haitask run --type <type>` | (Jira only) Override issue type for this run (Task, Bug, Story) |
70
74
  | `haitask run --status <status>` | (Jira only) Override status after create (Done, "To Do", etc.) |
75
+ | `haitask run --lang <lang>` | Language for task title and description (en, az, tr, ru). Default: en |
76
+
77
+ ---
78
+
79
+ ## Language Support
80
+
81
+ By default, task titles and descriptions are generated in English. Use `--lang` to generate them in a different language:
82
+
83
+ ```bash
84
+ haitask run --lang az # Azerbaijani
85
+ haitask run --lang tr # Turkish
86
+ haitask run --lang ru # Russian
87
+ haitask run --lang en # English (default)
88
+ ```
89
+
90
+ Supported languages: `en` (English), `az` (Azerbaijani), `tr` (Turkish), `ru` (Russian).
91
+
92
+ ---
93
+
94
+ ## JSON output (automation)
95
+
96
+ Use `--json` when integrating with CI/CD or scripts. This guarantees a parseable payload on both success and failure.
97
+
98
+ ```bash
99
+ haitask run --dry --json
100
+ ```
101
+
102
+ Success shape:
103
+
104
+ ```json
105
+ {
106
+ "ok": true,
107
+ "dry": true,
108
+ "skipped": false,
109
+ "commented": false,
110
+ "key": null,
111
+ "url": null,
112
+ "payload": {
113
+ "title": "Add login validation",
114
+ "description": "..."
115
+ },
116
+ "commit": {
117
+ "branch": "main",
118
+ "commitHash": "abc123...",
119
+ "repoName": "my-repo",
120
+ "count": 1
121
+ }
122
+ }
123
+ ```
124
+
125
+ Failure shape:
126
+
127
+ ```json
128
+ {
129
+ "ok": false,
130
+ "error": "Config error: Config not found ..."
131
+ }
132
+ ```
71
133
 
72
134
  ---
73
135
 
@@ -94,7 +156,62 @@ To try without creating a task: `haitask run --dry`.
94
156
 
95
157
  - **Global:** `npm install -g haitask` → run `haitask init` once per repo, then `haitask run` after commits.
96
158
  - **Per project:** `npm install haitask --save-dev` → `npx haitask run`.
97
- - **CI / scripts:** Run `npx haitask run` from the repo root; ensure `.haitaskrc` and env vars are available.
159
+ - **CI / scripts:** Run `npx haitask run --dry --json` from the repo root, parse `ok`, and fail pipeline when `ok === false`.
160
+
161
+ Example (bash + `jq`):
162
+
163
+ ```bash
164
+ out="$(npx haitask run --dry --json)"
165
+ echo "$out"
166
+ test "$(echo "$out" | jq -r '.ok')" = "true"
167
+ ```
168
+
169
+ ---
170
+
171
+ ## GitHub Actions Integration
172
+
173
+ Add this workflow to `.github/workflows/haitask.yml` for automatic task creation:
174
+
175
+ ```yaml
176
+ name: Create Task from Commit
177
+
178
+ on:
179
+ push:
180
+ branches: [ main, develop ]
181
+
182
+ jobs:
183
+ create-task:
184
+ runs-on: ubuntu-latest
185
+ steps:
186
+ - uses: actions/checkout@v4
187
+ with:
188
+ fetch-depth: 0
189
+
190
+ - name: Setup Node.js
191
+ uses: actions/setup-node@v4
192
+ with:
193
+ node-version: '18'
194
+
195
+ - name: Install HAITASK
196
+ run: npm install -g haitask
197
+
198
+ - name: Create task from latest commit
199
+ env:
200
+ GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
201
+ # Add your target-specific keys:
202
+ # JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
203
+ # TRELLO_TOKEN: ${{ secrets.TRELLO_TOKEN }}
204
+ # LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
205
+ run: |
206
+ if [ -f ".haitaskrc" ]; then
207
+ haitask run
208
+ fi
209
+ ```
210
+
211
+ **Setup:**
212
+ 1. Copy the workflow to your repo
213
+ 2. Add required secrets to GitHub repo settings
214
+ 3. Configure `.haitaskrc` in your project root
98
215
 
99
216
  ---
100
217
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "haitask",
3
- "version": "0.3.3",
4
- "description": "HAITASK AI-powered task creation from Git commits. Creates issues in Jira, Trello, or Linear from your latest commit message and branch.",
3
+ "version": "0.4.0",
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": {
@@ -9,19 +9,21 @@ 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 Jira.
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
+ * @param {{ lang?: string }} options
15
16
  * @returns {Promise<{ title: string, description: string, labels: string[] }>}
16
17
  */
17
- export async function generateDeepseek(commitData, config) {
18
+ export async function generateDeepseek(commitData, config, options = {}) {
18
19
  const apiKey = process.env.DEEPSEEK_API_KEY;
19
20
  if (!apiKey?.trim()) {
20
21
  throw new Error('DEEPSEEK_API_KEY is not set. Add it to .env. Get free key at https://platform.deepseek.com/');
21
22
  }
22
23
 
23
24
  const model = config?.ai?.model || 'deepseek-chat';
24
- const { system, user } = buildPrompt(commitData);
25
+ const { lang } = options || {};
26
+ const { system, user } = buildPrompt(commitData, config?.target, { lang });
25
27
 
26
28
  const response = await fetch(DEEPSEEK_API_URL, {
27
29
  method: 'POST',
@@ -47,7 +49,7 @@ export async function generateDeepseek(commitData, config) {
47
49
  const data = await response.json();
48
50
  const content = data?.choices?.[0]?.message?.content;
49
51
  if (typeof content !== 'string') {
50
- throw new Error('Deepseek response missing choices[0].message.content');
52
+ throw new TypeError('Deepseek response missing choices[0].message.content');
51
53
  }
52
54
 
53
55
  return parseTaskPayload(content.trim());
package/src/ai/groq.js CHANGED
@@ -9,12 +9,13 @@ 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 Jira.
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
+ * @param {{ lang?: string }} options
15
16
  * @returns {Promise<{ title: string, description: string, labels: string[] }>}
16
17
  */
17
- export async function generateGroq(commitData, config) {
18
+ export async function generateGroq(commitData, config, options = {}) {
18
19
  const apiKey = process.env.GROQ_API_KEY;
19
20
  if (!apiKey?.trim()) {
20
21
  throw new Error(
@@ -23,7 +24,8 @@ export async function generateGroq(commitData, config) {
23
24
  }
24
25
 
25
26
  const model = config?.ai?.model || 'llama-3.1-8b-instant';
26
- const { system, user } = buildPrompt(commitData);
27
+ const { lang } = options || {};
28
+ const { system, user } = buildPrompt(commitData, config?.target, { lang });
27
29
 
28
30
  const response = await fetch(GROQ_API_URL, {
29
31
  method: 'POST',
@@ -49,7 +51,7 @@ export async function generateGroq(commitData, config) {
49
51
  const data = await response.json();
50
52
  const content = data?.choices?.[0]?.message?.content;
51
53
  if (typeof content !== 'string') {
52
- throw new Error('Groq response missing choices[0].message.content');
54
+ throw new TypeError('Groq response missing choices[0].message.content');
53
55
  }
54
56
 
55
57
  return parseTaskPayload(content.trim());
package/src/ai/index.js CHANGED
@@ -16,18 +16,20 @@ const PROVIDERS = ['openai', 'deepseek', 'groq'];
16
16
  * Main entry: generate task payload based on provider in config.
17
17
  * @param {{ message: string, branch: string, repoName: string }} commitData
18
18
  * @param {{ ai: { provider?: string, model?: string } }} config
19
+ * @param {{ lang?: string }} options
19
20
  * @returns {Promise<{ title: string, description: string, labels: string[] }>}
20
21
  */
21
- export async function generateTaskPayload(commitData, config) {
22
+ export async function generateTaskPayload(commitData, config, options = {}) {
22
23
  const provider = (config?.ai?.provider || 'groq').toLowerCase();
24
+ const { lang } = options || {};
23
25
 
24
26
  switch (provider) {
25
27
  case 'openai':
26
- return generateOpenAI(commitData, config);
28
+ return generateOpenAI(commitData, config, { lang });
27
29
  case 'deepseek':
28
- return generateDeepseek(commitData, config);
30
+ return generateDeepseek(commitData, config, { lang });
29
31
  case 'groq':
30
- return generateGroq(commitData, config);
32
+ return generateGroq(commitData, config, { lang });
31
33
  default:
32
34
  throw new Error(`Unknown AI provider: ${provider}. Supported: ${PROVIDERS.join(', ')}`);
33
35
  }
package/src/ai/openai.js CHANGED
@@ -7,19 +7,21 @@ 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 Jira.
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
+ * @param {{ lang?: string }} options
13
14
  * @returns {Promise<{ title: string, description: string, labels: string[] }>}
14
15
  */
15
- export async function generateOpenAI(commitData, config) {
16
+ export async function generateOpenAI(commitData, config, options = {}) {
16
17
  const apiKey = process.env.OPENAI_API_KEY;
17
18
  if (!apiKey?.trim()) {
18
19
  throw new Error('OPENAI_API_KEY is not set. Add it to .env.');
19
20
  }
20
21
 
21
22
  const model = config?.ai?.model || 'gpt-4o-mini';
22
- const { system, user } = buildPrompt(commitData);
23
+ const { lang } = options || {};
24
+ const { system, user } = buildPrompt(commitData, config?.target, { lang });
23
25
 
24
26
  const response = await fetch(OPENAI_API_URL, {
25
27
  method: 'POST',
@@ -45,7 +47,7 @@ export async function generateOpenAI(commitData, config) {
45
47
  const data = await response.json();
46
48
  const content = data?.choices?.[0]?.message?.content;
47
49
  if (typeof content !== 'string') {
48
- throw new Error('OpenAI response missing choices[0].message.content');
50
+ throw new TypeError('OpenAI response missing choices[0].message.content');
49
51
  }
50
52
 
51
53
  return parseTaskPayload(content.trim());
package/src/ai/utils.js CHANGED
@@ -4,30 +4,75 @@
4
4
 
5
5
  const CONVENTIONAL_PREFIXES = /^(feat|fix|chore|docs|style|refactor|test|build|ci):\s*/i;
6
6
 
7
+ const LANG_ALIASES = {
8
+ en: 'en',
9
+ english: 'en',
10
+ az: 'az',
11
+ azerbaijani: 'az',
12
+ 'az-az': 'az',
13
+ tr: 'tr',
14
+ turkish: 'tr',
15
+ 'tr-tr': 'tr',
16
+ ru: 'ru',
17
+ russian: 'ru',
18
+ 'ru-ru': 'ru',
19
+ };
20
+
21
+ const LANG_INSTRUCTIONS = {
22
+ en: 'Write the title and description in English.',
23
+ az: 'Write the title and description in Azerbaijani (Azərbaycan) language.',
24
+ tr: 'Write the title and description in Turkish language.',
25
+ ru: 'Write the title and description in Russian language.',
26
+ };
27
+
28
+ function normalizeLang(lang) {
29
+ if (!lang) return 'en';
30
+ const normalized = lang.toLowerCase().trim();
31
+ return LANG_ALIASES[normalized] || 'en';
32
+ }
33
+
7
34
  /**
8
35
  * Build system + user prompt from commit data.
9
36
  * @param {{ message: string, branch: string, repoName: string }} commitData
37
+ * @param {string} [target='jira']
38
+ * @param {{ lang?: string }} options
10
39
  * @returns {{ system: string, user: string }}
11
40
  */
12
41
  const BATCH_SEP = '\n\n---\n\n';
13
42
 
14
- export function buildPrompt(commitData) {
43
+ const TARGET_META = {
44
+ jira: { displayName: 'Jira', workItem: 'issue', priorityField: true },
45
+ trello: { displayName: 'Trello', workItem: 'card', priorityField: false },
46
+ linear: { displayName: 'Linear', workItem: 'issue', priorityField: true },
47
+ };
48
+
49
+ export function buildPrompt(commitData, target = 'jira', options = {}) {
15
50
  const { message, branch, repoName } = commitData;
16
51
  const isBatch = message.includes(BATCH_SEP);
52
+ const normalizedTarget = (target || 'jira').toLowerCase();
53
+ const meta = TARGET_META[normalizedTarget] || TARGET_META.jira;
17
54
  const batchHint = isBatch
18
55
  ? ' The user input may contain multiple commits separated by "---"; produce one task that summarizes all of them.\n\n'
19
56
  : '';
20
- const system = `You generate a Jira task from a Git commit. Reply with a single JSON object only, no markdown or extra text.
21
- ${batchHint}Keys:
22
- - "title": Short, formal Jira task summary (professional wording). Do NOT copy the commit message verbatim. Rewrite as a clear, formal task title suitable for Jira (e.g. "Add user login validation" not "test login stuff"). Do NOT include prefixes like feat:, fix:, chore: in the title.
23
- - "description": Detailed description in plain language, suitable for Jira. Expand and formalize the intent of the commit; do not just paste the commit message.
57
+ const priorityRule = meta.priorityField
58
+ ? '- "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.'
59
+ : '- "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.';
60
+
61
+ const lang = normalizeLang(options?.lang);
62
+ const langInstruction = LANG_INSTRUCTIONS[lang] || LANG_INSTRUCTIONS.en;
63
+
64
+ 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.
65
+ ${batchHint}${langInstruction}
66
+ Keys:
67
+ - "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.
68
+ - "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
69
  - "labels": Array of strings, e.g. ["auto", "commit"].
25
- - "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.`;
70
+ ${priorityRule}`;
26
71
  const user = `Repo: ${repoName}\nBranch: ${branch}\nCommit message:\n${message}\n\nGenerate the JSON object.`;
27
72
  return { system, user };
28
73
  }
29
74
 
30
- const VALID_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'];
75
+ const VALID_PRIORITIES = new Set(['Highest', 'High', 'Medium', 'Low', 'Lowest']);
31
76
 
32
77
  /**
33
78
  * Parse and validate AI response. Expects { title, description, labels, priority? }.
@@ -43,15 +88,15 @@ export function parseTaskPayload(raw) {
43
88
  throw new Error(`AI response is not valid JSON: ${err.message}`);
44
89
  }
45
90
  if (typeof obj.title !== 'string' || typeof obj.description !== 'string') {
46
- throw new Error('AI response missing or invalid title/description (must be strings).');
91
+ throw new TypeError('AI response missing or invalid title/description (must be strings).');
47
92
  }
48
93
  if (!Array.isArray(obj.labels)) {
49
- throw new Error('AI response labels must be an array of strings.');
94
+ throw new TypeError('AI response labels must be an array of strings.');
50
95
  }
51
96
  const labels = obj.labels.filter((l) => typeof l === 'string');
52
97
  const rawTitle = (obj.title || '').trim();
53
98
  const title = rawTitle.replace(CONVENTIONAL_PREFIXES, '').trim() || rawTitle;
54
99
  const rawPriority = (obj.priority || 'Medium').trim();
55
- const priority = VALID_PRIORITIES.includes(rawPriority) ? rawPriority : 'Medium';
100
+ const priority = VALID_PRIORITIES.has(rawPriority) ? rawPriority : 'Medium';
56
101
  return { title, description: obj.description.trim(), labels, priority };
57
102
  }
@@ -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
- const rl = createInterface({ input: process.stdin, output: process.stdout });
151
-
152
- try {
153
- console.log(quick ? 'haitask init --quick (minimal questions, defaults for the rest).\n' : 'haitask init — answer the questions (Enter = use default).\n');
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
- const targetAnswer = await question(rl, 'Target: 1 = Jira, 2 = Trello, 3 = Linear', '1');
156
- const targetMap = { '1': 'jira', '2': 'trello', '3': 'linear' };
157
- const target = targetMap[targetAnswer] || 'jira';
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
- let rules;
160
- let ai;
161
- if (quick) {
162
- rules = DEFAULT_RULES;
163
- ai = DEFAULT_AI;
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
- let config;
179
- if (target === 'jira') config = await askJiraConfig(rl, ai, rules);
180
- else if (target === 'trello') config = quick ? await askTrelloConfigQuick(rl, ai, rules) : await askTrelloConfig(rl, ai, rules);
181
- else config = await askLinearConfig(rl, ai, rules);
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');
@@ -13,58 +13,105 @@ 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 = options.commits != null ? Number(options.commits) : 1;
90
+ const commits = Number(options.commits ?? 1);
91
+ const lang = options.lang?.trim() || undefined;
21
92
 
22
93
  let config;
23
94
  try {
24
95
  config = loadConfig();
25
96
  } catch (err) {
26
- console.error('Config error:', err.message);
27
- process.exitCode = 1;
97
+ printFailure(json, err.message, 'Config error');
28
98
  return;
29
99
  }
30
100
 
31
101
  try {
32
- const result = await runPipeline(config, { dry, issueType: type, transitionToStatus: status, commits });
102
+ const result = await runPipeline(config, { dry, issueType: type, transitionToStatus: status, commits, lang });
33
103
 
34
104
  if (!result.ok) {
35
- console.error(result.error || 'Pipeline failed.');
36
- process.exitCode = 1;
105
+ printFailure(json, result.error || 'Pipeline failed.');
37
106
  return;
38
107
  }
39
108
 
40
- if (result.dry) {
41
- console.log('Dry run — no task created.');
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
- }
109
+ if (json) {
110
+ printJson(toJsonResult(result, config));
54
111
  return;
55
112
  }
56
-
57
- const displayUrl = getDisplayUrl(config, result.key, result.url);
58
- if (result.skipped) {
59
- console.log('Already created for this commit:', result.key);
60
- } else if (result.commented) {
61
- console.log('Added comment to:', result.key);
62
- } else {
63
- console.log('Created task:', result.key);
64
- }
65
- if (displayUrl && displayUrl !== result.key) console.log(displayUrl);
113
+ printSuccessResult(result, config);
66
114
  } catch (err) {
67
- console.error('Error:', err.message);
68
- process.exitCode = 1;
115
+ printFailure(json, err.message, 'Error');
69
116
  }
70
117
  }
@@ -51,11 +51,11 @@ export function validateRules(commitData, config) {
51
51
  /**
52
52
  * Run full pipeline: Git → validate → AI → target (unless dry).
53
53
  * @param {object} config - Loaded .haitaskrc
54
- * @param {{ dry?: boolean, issueType?: string, transitionToStatus?: string, commits?: number }} options
54
+ * @param {{ dry?: boolean, issueType?: string, transitionToStatus?: string, commits?: number, lang?: string }} options
55
55
  * @returns {Promise<{ ok: boolean, dry?: boolean, key?: string, url?: string, payload?: object, commitData?: object, error?: string }>}
56
56
  */
57
57
  export async function runPipeline(config, options = {}) {
58
- const { dry = false, issueType: typeOverride, transitionToStatus: statusOverride, commits: commitsOpt } = options;
58
+ const { dry = false, issueType: typeOverride, transitionToStatus: statusOverride, commits: commitsOpt, lang } = options;
59
59
  const numCommits = Math.max(1, Number(commitsOpt) || 1);
60
60
 
61
61
  const commitData = numCommits > 1 ? await getLatestCommitsData(numCommits) : await getLatestCommitData();
@@ -71,7 +71,7 @@ export async function runPipeline(config, options = {}) {
71
71
  return { ok: true, commented: true, key, url, commitData };
72
72
  }
73
73
 
74
- const payload = await generateTaskPayload(commitData, config);
74
+ const payload = await generateTaskPayload(commitData, config, { lang });
75
75
 
76
76
  if (dry) {
77
77
  return { ok: true, dry: true, payload, commitData };
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ program
21
21
  .command('init')
22
22
  .description('Create .haitaskrc and validate environment')
23
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)')
24
25
  .action((opts) => runInit(opts));
25
26
 
26
27
  program
@@ -32,9 +33,11 @@ program
32
33
  .command('run')
33
34
  .description('Run full pipeline: Git → AI → target (Jira, Trello, or Linear)')
34
35
  .option('--dry', 'Skip creating task, run everything else')
36
+ .option('--json', 'Print machine-readable JSON output')
35
37
  .option('-c, --commits <n>', 'Number of commits to combine into one task (default: 1)', '1')
36
38
  .option('-t, --type <type>', 'Jira issue type for this run (e.g. Task, Bug, Story). Overrides .haitaskrc jira.issueType')
37
39
  .option('-s, --status <status>', 'Jira transition-to status after create (e.g. Done, "To Do"). Overrides .haitaskrc jira.transitionToStatus')
40
+ .option('-l, --lang <language>', 'Language for task title and description (en, az, tr, ru). Default: en')
38
41
  .action((opts) => runRun(opts));
39
42
 
40
43
  program.parse();
@@ -15,6 +15,59 @@ const CREATE_ISSUE_MUTATION = `
15
15
  }
16
16
  `;
17
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
+
18
71
  /**
19
72
  * Create a Linear issue from AI payload. Same interface as Jira/Trello: returns { key, url }.
20
73
  * @param {object} payload - { title, description, labels, priority } from AI
@@ -37,6 +90,8 @@ export async function createTask(payload, config) {
37
90
 
38
91
  const title = (payload?.title || '').trim() || 'Untitled';
39
92
  const description = (payload?.description || '').trim() || '';
93
+ const priority = toLinearPriority(payload?.priority);
94
+ const labelIds = await resolveLinearLabelIds(apiKey, teamId, payload?.labels);
40
95
 
41
96
  const res = await fetch(LINEAR_GRAPHQL, {
42
97
  method: 'POST',
@@ -52,6 +107,8 @@ export async function createTask(payload, config) {
52
107
  teamId,
53
108
  title,
54
109
  ...(description && { description }),
110
+ ...(Number.isFinite(priority) && { priority }),
111
+ ...(labelIds.length > 0 && { labelIds }),
55
112
  },
56
113
  },
57
114
  }),
@@ -8,6 +8,50 @@ import { getHttpHint } from '../utils/http-hints.js';
8
8
 
9
9
  const TRELLO_API = 'https://api.trello.com/1';
10
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
+
11
55
  /**
12
56
  * Create a Trello card from AI payload. Same interface as Jira adapter: returns { key, url }.
13
57
  * @param {object} payload - { title, description, labels, priority } from AI
@@ -36,7 +80,7 @@ export async function createTask(payload, config) {
36
80
  }
37
81
 
38
82
  const name = (payload?.title || '').trim() || 'Untitled';
39
- const desc = (payload?.description || '').trim() || '';
83
+ const desc = withPriorityInDescription((payload?.description || '').trim() || '', payload?.priority);
40
84
 
41
85
  const query = new URLSearchParams({ key: apiKey, token });
42
86
  const body = { idList: listId, name, desc };
@@ -45,12 +89,16 @@ export async function createTask(payload, config) {
45
89
  const memberId = trello.memberId?.trim() || process.env.TRELLO_MEMBER_ID?.trim();
46
90
  if (memberId && TRELLO_ID_REGEX.test(memberId)) body.idMembers = [memberId];
47
91
 
48
- const labelIds = trello.labelIds;
49
- if (Array.isArray(labelIds) && labelIds.length > 0) {
50
- body.idLabels = labelIds
92
+ const configLabelIds = Array.isArray(trello.labelIds)
93
+ ? trello.labelIds
51
94
  .filter((id) => typeof id === 'string' && id.trim())
52
95
  .map((id) => id.trim())
53
- .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;
54
102
  }
55
103
 
56
104
  const url = `${TRELLO_API}/cards?${query.toString()}`;