haitask 0.1.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/.env.example ADDED
@@ -0,0 +1,18 @@
1
+ # HAITASK — copy to .env and fill in
2
+ # Set only the key for the provider you use in .haitaskrc (ai.provider)
3
+
4
+ # Groq (FREE, fast): https://console.groq.com/keys
5
+ GROQ_API_KEY=your-groq-api-key
6
+
7
+ # Deepseek (FREE): https://platform.deepseek.com/
8
+ DEEPSEEK_API_KEY=your-deepseek-api-key
9
+
10
+ # OpenAI (paid): https://platform.openai.com/api-keys
11
+ OPENAI_API_KEY=your-openai-api-key
12
+
13
+ # Jira (required for creating issues)
14
+ JIRA_BASE_URL=https://your-domain.atlassian.net
15
+ JIRA_EMAIL=your@email.com
16
+ JIRA_API_TOKEN=your-jira-api-token
17
+ # Optional: assign new issues to you. Jira Cloud account ID: Profile → Account ID, or admin.atlassian.com → Directory → Users. If value contains ":", use quotes: JIRA_ACCOUNT_ID="id:uuid"
18
+ JIRA_ACCOUNT_ID=your-account-id
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # HAITASK
2
+
3
+ Generate Jira issues from your latest Git commit using AI. Reads commit message and branch, produces a structured task (title, description, labels) via an AI provider, and creates the issue in Jira. Framework- and language-agnostic: works with any Git repo.
4
+
5
+ **Requirements:** Node.js >= 18
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g haitask
13
+ ```
14
+
15
+ Or run without installing:
16
+
17
+ ```bash
18
+ npx haitask
19
+ ```
20
+
21
+ From source (clone then link):
22
+
23
+ ```bash
24
+ git clone https://github.com/HidayetHidayetov/haitask.git && cd haitask && npm install && npm link
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Quick start
30
+
31
+ 1. **Credentials (one-time)**
32
+ Create a `.env` (e.g. in your home dir or a shared config folder) with:
33
+ - One AI provider key: `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, or `OPENAI_API_KEY`
34
+ - Jira: `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`
35
+ Copy from `.env.example` in the repo.
36
+
37
+ 2. **Per project (one-time)**
38
+ In the Git repo where you want to create Jira issues:
39
+ ```bash
40
+ cd /path/to/your/repo
41
+ haitask init
42
+ ```
43
+ This creates `.haitaskrc`. Edit it: set `jira.projectKey`, `jira.baseUrl`, and optionally `rules.allowedBranches` and `rules.commitPrefixes`.
44
+
45
+ 3. **Create a Jira issue from the latest commit**
46
+ After committing:
47
+ ```bash
48
+ haitask run
49
+ ```
50
+ Pipeline: read latest commit → validate branch/prefix from `.haitaskrc` → call AI → create Jira issue.
51
+ To test without creating an issue: `haitask run --dry`.
52
+
53
+ ---
54
+
55
+ ## Commands
56
+
57
+ | Command | Description |
58
+ |--------|-------------|
59
+ | `haitask init` | Create `.haitaskrc` in cwd (no overwrite if present). Validates env. |
60
+ | `haitask run` | Run pipeline: Git → AI → Jira (create issue). |
61
+ | `haitask run --dry` | Same as above but skips the Jira API call. |
62
+
63
+ ---
64
+
65
+ ## Configuration
66
+
67
+ - **`.haitaskrc`** (project root): Jira `baseUrl`, `projectKey`, `issueType`; AI `provider` and `model`; `rules.allowedBranches` and `rules.commitPrefixes`. Single source of truth for behaviour.
68
+ - **`.env`**: API keys only. Prefer loading it from the directory where you run `haitask` (or the same folder as the cloned repo when using `npm link`).
69
+
70
+ **AI providers** (set `ai.provider` in `.haitaskrc`): `groq` (default, free), `deepseek` (free), `openai` (paid). Set the corresponding key in `.env`.
71
+
72
+ **Rules:** If `allowedBranches` is non-empty, the current branch must be in the list. If `commitPrefixes` is non-empty, the commit message must start with one of them (e.g. `feat:`, `fix:`). Adjust to match your workflow.
73
+
74
+ **Jira assignee:** Optional. Set `JIRA_ACCOUNT_ID` in `.env` (Jira Cloud account ID). If the value contains a colon, use quotes: `JIRA_ACCOUNT_ID="id:uuid"`. The created issue will be assigned via a separate API call after create.
75
+
76
+ **Task title:** The AI is instructed not to include commit-type prefixes (e.g. `feat:`) in the Jira title; the code also strips them from the AI output so the issue summary stays clean.
77
+
78
+ ---
79
+
80
+ ## Usage patterns
81
+
82
+ - **Global install:** `npm install -g haitask` → in any repo: `haitask init` once, then `haitask run` after commits.
83
+ - **Per-project dev dependency:** `npm install haitask --save-dev` → `npx haitask run`.
84
+ - **CI / scripts:** Run `npx haitask run` (or `haitask run` if installed) from the repo root; ensure `.haitaskrc` and env vars are available in that environment.
85
+
86
+ No framework-specific setup (e.g. Laravel, React, etc.); the tool only depends on Git and the config files above.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "haitask",
3
+ "version": "0.1.0",
4
+ "description": "Hidayet AI Task — Generate Jira tasks from Git commits using AI",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "haitask": "src/index.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ ".env.example"
17
+ ],
18
+ "scripts": {
19
+ "start": "node src/index.js",
20
+ "init": "node src/index.js init",
21
+ "run": "node src/index.js run"
22
+ },
23
+ "keywords": [
24
+ "cli",
25
+ "jira",
26
+ "git",
27
+ "ai",
28
+ "automation",
29
+ "commit",
30
+ "task"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/HidayetHidayetov/haitask.git"
36
+ },
37
+ "homepage": "https://github.com/HidayetHidayetov/haitask#readme",
38
+ "bugs": "https://github.com/HidayetHidayetov/haitask/issues",
39
+ "dependencies": {
40
+ "commander": "^12.0.0",
41
+ "dotenv": "^16.4.5",
42
+ "execa": "^9.5.2"
43
+ }
44
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Deepseek Chat provider (free tier, JSON mode supported).
3
+ * API: https://api.deepseek.com/v1/chat/completions
4
+ * Docs: https://platform.deepseek.com/api-docs/
5
+ */
6
+
7
+ import { buildPrompt, parseTaskPayload } from './utils.js';
8
+
9
+ const DEEPSEEK_API_URL = 'https://api.deepseek.com/v1/chat/completions';
10
+
11
+ /**
12
+ * Call Deepseek and return task payload for Jira.
13
+ * @param {{ message: string, branch: string, repoName: string }} commitData
14
+ * @param {{ ai: { model?: string } }} config
15
+ * @returns {Promise<{ title: string, description: string, labels: string[] }>}
16
+ */
17
+ export async function generateDeepseek(commitData, config) {
18
+ const apiKey = process.env.DEEPSEEK_API_KEY;
19
+ if (!apiKey?.trim()) {
20
+ throw new Error('DEEPSEEK_API_KEY is not set. Add it to .env. Get free key at https://platform.deepseek.com/');
21
+ }
22
+
23
+ const model = config?.ai?.model || 'deepseek-chat';
24
+ const { system, user } = buildPrompt(commitData);
25
+
26
+ const response = await fetch(DEEPSEEK_API_URL, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Content-Type': 'application/json',
30
+ Authorization: `Bearer ${apiKey}`,
31
+ },
32
+ body: JSON.stringify({
33
+ model,
34
+ messages: [
35
+ { role: 'system', content: system },
36
+ { role: 'user', content: user },
37
+ ],
38
+ response_format: { type: 'json_object' },
39
+ }),
40
+ });
41
+
42
+ if (!response.ok) {
43
+ const body = await response.text();
44
+ throw new Error(`Deepseek API error ${response.status}: ${body || response.statusText}`);
45
+ }
46
+
47
+ const data = await response.json();
48
+ const content = data?.choices?.[0]?.message?.content;
49
+ if (typeof content !== 'string') {
50
+ throw new Error('Deepseek response missing choices[0].message.content');
51
+ }
52
+
53
+ return parseTaskPayload(content.trim());
54
+ }
package/src/ai/groq.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Groq provider (free tier, fast inference).
3
+ * API: https://api.groq.com/openai/v1/chat/completions (OpenAI-compatible)
4
+ * Docs: https://console.groq.com/docs
5
+ */
6
+
7
+ import { buildPrompt, parseTaskPayload } from './utils.js';
8
+
9
+ const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
10
+
11
+ /**
12
+ * Call Groq and return task payload for Jira.
13
+ * @param {{ message: string, branch: string, repoName: string }} commitData
14
+ * @param {{ ai: { model?: string } }} config
15
+ * @returns {Promise<{ title: string, description: string, labels: string[] }>}
16
+ */
17
+ export async function generateGroq(commitData, config) {
18
+ const apiKey = process.env.GROQ_API_KEY;
19
+ if (!apiKey?.trim()) {
20
+ throw new Error(
21
+ 'GROQ_API_KEY is not set. Add it to .env. Get free key at https://console.groq.com/keys'
22
+ );
23
+ }
24
+
25
+ const model = config?.ai?.model || 'llama-3.1-8b-instant';
26
+ const { system, user } = buildPrompt(commitData);
27
+
28
+ const response = await fetch(GROQ_API_URL, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ Authorization: `Bearer ${apiKey}`,
33
+ },
34
+ body: JSON.stringify({
35
+ model,
36
+ messages: [
37
+ { role: 'system', content: system },
38
+ { role: 'user', content: user },
39
+ ],
40
+ response_format: { type: 'json_object' },
41
+ }),
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const body = await response.text();
46
+ throw new Error(`Groq API error ${response.status}: ${body || response.statusText}`);
47
+ }
48
+
49
+ const data = await response.json();
50
+ const content = data?.choices?.[0]?.message?.content;
51
+ if (typeof content !== 'string') {
52
+ throw new Error('Groq response missing choices[0].message.content');
53
+ }
54
+
55
+ return parseTaskPayload(content.trim());
56
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * AI provider abstraction: commit + context → strict JSON task payload.
3
+ * Supports: OpenAI, Deepseek, Groq (free). Switch via .haitaskrc ai.provider.
4
+ */
5
+
6
+ import { generateOpenAI } from './openai.js';
7
+ import { generateDeepseek } from './deepseek.js';
8
+ import { generateGroq } from './groq.js';
9
+
10
+ // Re-export utils for convenience
11
+ export { buildPrompt, parseTaskPayload } from './utils.js';
12
+
13
+ const PROVIDERS = ['openai', 'deepseek', 'groq'];
14
+
15
+ /**
16
+ * Main entry: generate task payload based on provider in config.
17
+ * @param {{ message: string, branch: string, repoName: string }} commitData
18
+ * @param {{ ai: { provider?: string, model?: string } }} config
19
+ * @returns {Promise<{ title: string, description: string, labels: string[] }>}
20
+ */
21
+ export async function generateTaskPayload(commitData, config) {
22
+ const provider = (config?.ai?.provider || 'groq').toLowerCase();
23
+
24
+ switch (provider) {
25
+ case 'openai':
26
+ return generateOpenAI(commitData, config);
27
+ case 'deepseek':
28
+ return generateDeepseek(commitData, config);
29
+ case 'groq':
30
+ return generateGroq(commitData, config);
31
+ default:
32
+ throw new Error(`Unknown AI provider: ${provider}. Supported: ${PROVIDERS.join(', ')}`);
33
+ }
34
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * OpenAI provider implementation.
3
+ */
4
+
5
+ import { buildPrompt, parseTaskPayload } from './utils.js';
6
+
7
+ const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
8
+
9
+ /**
10
+ * Call OpenAI and return task payload for Jira.
11
+ * @param {{ message: string, branch: string, repoName: string }} commitData
12
+ * @param {{ ai: { model?: string } }} config
13
+ * @returns {Promise<{ title: string, description: string, labels: string[] }>}
14
+ */
15
+ export async function generateOpenAI(commitData, config) {
16
+ const apiKey = process.env.OPENAI_API_KEY;
17
+ if (!apiKey?.trim()) {
18
+ throw new Error('OPENAI_API_KEY is not set. Add it to .env.');
19
+ }
20
+
21
+ const model = config?.ai?.model || 'gpt-4o-mini';
22
+ const { system, user } = buildPrompt(commitData);
23
+
24
+ const response = await fetch(OPENAI_API_URL, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ Authorization: `Bearer ${apiKey}`,
29
+ },
30
+ body: JSON.stringify({
31
+ model,
32
+ messages: [
33
+ { role: 'system', content: system },
34
+ { role: 'user', content: user },
35
+ ],
36
+ response_format: { type: 'json_object' },
37
+ }),
38
+ });
39
+
40
+ if (!response.ok) {
41
+ const body = await response.text();
42
+ throw new Error(`OpenAI API error ${response.status}: ${body || response.statusText}`);
43
+ }
44
+
45
+ const data = await response.json();
46
+ const content = data?.choices?.[0]?.message?.content;
47
+ if (typeof content !== 'string') {
48
+ throw new Error('OpenAI response missing choices[0].message.content');
49
+ }
50
+
51
+ return parseTaskPayload(content.trim());
52
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared AI utilities: prompt building and response parsing.
3
+ */
4
+
5
+ /**
6
+ * Build system + user prompt from commit data.
7
+ * @param {{ message: string, branch: string, repoName: string }} commitData
8
+ * @returns {{ system: string, user: string }}
9
+ */
10
+ export function buildPrompt(commitData) {
11
+ const { message, branch, repoName } = commitData;
12
+ const system = `You generate a Jira task from a Git commit. Reply with a single JSON object only, no markdown or extra text.
13
+ Keys: "title" (short Jira task title, plain language — do NOT include prefixes like feat:, fix:, chore: in the title), "description" (detailed description, string), "labels" (array of strings, e.g. ["auto", "commit"]).`;
14
+ const user = `Repo: ${repoName}\nBranch: ${branch}\nCommit message:\n${message}\n\nGenerate the JSON object.`;
15
+ return { system, user };
16
+ }
17
+
18
+ /**
19
+ * Parse and validate AI response. Expects { title, description, labels }.
20
+ * @param {string} raw
21
+ * @returns {{ title: string, description: string, labels: string[] }}
22
+ * @throws {Error} If invalid JSON or missing/ wrong types
23
+ */
24
+ export function parseTaskPayload(raw) {
25
+ let obj;
26
+ try {
27
+ obj = JSON.parse(raw);
28
+ } catch (err) {
29
+ throw new Error(`AI response is not valid JSON: ${err.message}`);
30
+ }
31
+ if (typeof obj.title !== 'string' || typeof obj.description !== 'string') {
32
+ throw new Error('AI response missing or invalid title/description (must be strings).');
33
+ }
34
+ if (!Array.isArray(obj.labels)) {
35
+ throw new Error('AI response labels must be an array of strings.');
36
+ }
37
+ const labels = obj.labels.filter((l) => typeof l === 'string');
38
+ // Strip conventional commit prefix from title so Jira gets a plain task title
39
+ const rawTitle = (obj.title || '').trim();
40
+ const title = rawTitle.replace(/^(feat|fix|chore|docs|style|refactor|test|build|ci):\s*/i, '').trim() || rawTitle;
41
+ return { title, description: obj.description.trim(), labels };
42
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * haitask init — Create .haitaskrc, validate .env
3
+ * Thin handler: delegates to config layer, handles CLI output only.
4
+ */
5
+
6
+ import { createDefaultConfigFile, validateEnv } from '../config/init.js';
7
+ import { loadConfig } from '../config/load.js';
8
+
9
+ export async function runInit() {
10
+ const { created } = createDefaultConfigFile();
11
+
12
+ if (!created) {
13
+ console.warn('haitask init: .haitaskrc already exists. Not overwriting.');
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+
18
+ console.log('Created .haitaskrc');
19
+
20
+ // Load config to check AI provider key
21
+ let config;
22
+ try {
23
+ config = loadConfig();
24
+ } catch (err) {
25
+ // Config just created, might not be readable yet, skip AI key check
26
+ config = null;
27
+ }
28
+
29
+ const { valid, missing } = validateEnv(process.cwd(), config);
30
+ if (!valid) {
31
+ console.warn('Add these to .env before running "haitask run":', missing.join(', '));
32
+ const provider = config?.ai?.provider?.toLowerCase();
33
+ if (provider === 'groq') console.log('Get free Groq API key at: https://console.groq.com/keys');
34
+ if (provider === 'deepseek') console.log('Get free Deepseek API key at: https://platform.deepseek.com/');
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+
39
+ console.log('Environment (.env) looks good.');
40
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * haitask run [--dry] — Execute full pipeline
3
+ * Thin handler: load config, call pipeline, output result.
4
+ */
5
+
6
+ import { loadConfig } from '../config/load.js';
7
+ import { runPipeline } from '../core/pipeline.js';
8
+
9
+ export async function runRun(options = {}) {
10
+ const dry = options.dry ?? false;
11
+
12
+ let config;
13
+ try {
14
+ config = loadConfig();
15
+ } catch (err) {
16
+ console.error('Config error:', err.message);
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+
21
+ try {
22
+ const result = await runPipeline(config, { dry });
23
+
24
+ if (!result.ok) {
25
+ console.error(result.error || 'Pipeline failed.');
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+
30
+ if (result.dry) {
31
+ console.log('Dry run — no Jira issue created.');
32
+ console.log('Commit:', result.commitData?.message?.split('\n')[0] || '');
33
+ console.log('Would create Jira task:', result.payload?.title || '');
34
+ if (result.payload?.description) {
35
+ console.log('Description (preview):', result.payload.description.slice(0, 120) + (result.payload.description.length > 120 ? '...' : ''));
36
+ }
37
+ return;
38
+ }
39
+
40
+ // Keep output URL consistent with Jira client behavior (prefer .haitaskrc baseUrl).
41
+ const baseUrl = (config?.jira?.baseUrl || process.env.JIRA_BASE_URL || '').replace(/\/$/, '');
42
+ const issueUrl = baseUrl ? `${baseUrl}/browse/${result.key}` : result.key;
43
+ console.log('Created Jira issue:', result.key);
44
+ if (issueUrl !== result.key) {
45
+ console.log(issueUrl);
46
+ }
47
+ } catch (err) {
48
+ console.error('Error:', err.message);
49
+ process.exitCode = 1;
50
+ }
51
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Config init: create default .haitaskrc, validate .env.
3
+ * No I/O assumptions — caller can use for messaging.
4
+ */
5
+
6
+ import { writeFileSync, existsSync } from 'fs';
7
+ import { resolve } from 'path';
8
+ import { config as loadEnv } from 'dotenv';
9
+
10
+ const DEFAULT_RC = {
11
+ jira: {
12
+ baseUrl: 'https://your-domain.atlassian.net',
13
+ projectKey: 'PROJ',
14
+ issueType: 'Task',
15
+ },
16
+ ai: {
17
+ provider: 'groq',
18
+ model: 'llama-3.1-8b-instant',
19
+ },
20
+ rules: {
21
+ allowedBranches: ['main', 'develop', 'master'],
22
+ commitPrefixes: ['feat', 'fix', 'chore'],
23
+ },
24
+ };
25
+
26
+ // Note: Only Jira keys are required. AI key depends on provider (DEEPSEEK_API_KEY or OPENAI_API_KEY)
27
+ const REQUIRED_ENV_KEYS = ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'];
28
+
29
+ /**
30
+ * Create .haitaskrc in dir if it does not exist.
31
+ * @param {string} [dir] - Directory (default: process.cwd())
32
+ * @returns {{ created: boolean }} created true if file was written, false if already existed
33
+ */
34
+ export function createDefaultConfigFile(dir = process.cwd()) {
35
+ const path = resolve(dir, '.haitaskrc');
36
+ if (existsSync(path)) {
37
+ return { created: false };
38
+ }
39
+ writeFileSync(path, JSON.stringify(DEFAULT_RC, null, 2), 'utf-8');
40
+ return { created: true };
41
+ }
42
+
43
+ /**
44
+ * Load .env from dir and check required keys.
45
+ * If config is provided, also validates AI provider key.
46
+ * @param {string} [dir] - Directory (default: process.cwd())
47
+ * @param {object} [config] - Optional config to check AI provider key
48
+ * @returns {{ valid: boolean, missing: string[] }}
49
+ */
50
+ export function validateEnv(dir = process.cwd(), config = null) {
51
+ const envPath = resolve(dir, '.env');
52
+ loadEnv({ path: envPath });
53
+ const missing = [...REQUIRED_ENV_KEYS];
54
+
55
+ // Check AI provider key if config provided
56
+ if (config?.ai?.provider) {
57
+ const provider = config.ai.provider.toLowerCase();
58
+ const aiKeys = {
59
+ openai: 'OPENAI_API_KEY',
60
+ deepseek: 'DEEPSEEK_API_KEY',
61
+ groq: 'GROQ_API_KEY',
62
+ };
63
+ const aiKey = aiKeys[provider];
64
+ if (aiKey && !process.env[aiKey]?.trim()) {
65
+ missing.push(aiKey);
66
+ }
67
+ }
68
+
69
+ const filtered = missing.filter((key) => !process.env[key]?.trim());
70
+ return { valid: filtered.length === 0, missing: filtered };
71
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Load .haitaskrc (single source of truth).
3
+ * No hardcoded defaults — file must exist and be valid.
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { resolve } from 'path';
8
+
9
+ const DEFAULT_CONFIG_PATH = '.haitaskrc';
10
+
11
+ const REQUIRED_KEYS = ['jira', 'ai', 'rules'];
12
+
13
+ /**
14
+ * @param {string} [configPath] - Absolute or cwd-relative path to .haitaskrc
15
+ * @returns {object} Parsed config
16
+ * @throws {Error} If file missing, invalid JSON, or missing required sections
17
+ */
18
+ export function loadConfig(configPath) {
19
+ const path = configPath
20
+ ? resolve(configPath)
21
+ : resolve(process.cwd(), DEFAULT_CONFIG_PATH);
22
+
23
+ if (!existsSync(path)) {
24
+ throw new Error(`Config not found: ${path}. Run "haitask init" first.`);
25
+ }
26
+
27
+ let raw;
28
+ try {
29
+ raw = readFileSync(path, 'utf-8');
30
+ } catch (err) {
31
+ throw new Error(`Cannot read config: ${path}. ${err.message}`);
32
+ }
33
+
34
+ let config;
35
+ try {
36
+ config = JSON.parse(raw);
37
+ } catch (err) {
38
+ throw new Error(`Invalid JSON in ${path}. ${err.message}`);
39
+ }
40
+
41
+ const missing = REQUIRED_KEYS.filter((key) => !config[key] || typeof config[key] !== 'object');
42
+ if (missing.length > 0) {
43
+ throw new Error(`Config missing required sections: ${missing.join(', ')}. Check .haitaskrc.`);
44
+ }
45
+
46
+ return config;
47
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pipeline: Git → AI → Jira
3
+ * Orchestrates steps. No direct I/O (no console.log).
4
+ * Returns structured result for CLI to display.
5
+ */
6
+
7
+ import { getLatestCommitData } from '../git/commit.js';
8
+ import { generateTaskPayload } from '../ai/index.js';
9
+ import { createIssue } from '../jira/client.js';
10
+
11
+ /**
12
+ * Validate branch and commit prefix from config.rules.
13
+ * @param {{ message: string, branch: string }} commitData
14
+ * @param {{ rules?: { allowedBranches?: string[], commitPrefixes?: string[] } }} config
15
+ * @throws {Error} If validation fails
16
+ */
17
+ function validateRules(commitData, config) {
18
+ const { message, branch } = commitData;
19
+ const rules = config?.rules || {};
20
+ const allowedBranches = rules.allowedBranches;
21
+ const commitPrefixes = rules.commitPrefixes;
22
+
23
+ if (Array.isArray(allowedBranches) && allowedBranches.length > 0) {
24
+ if (!allowedBranches.includes(branch)) {
25
+ throw new Error(
26
+ `Branch "${branch}" is not allowed. Allowed: ${allowedBranches.join(', ')}. Update .haitaskrc rules.allowedBranches.`
27
+ );
28
+ }
29
+ }
30
+
31
+ if (Array.isArray(commitPrefixes) && commitPrefixes.length > 0) {
32
+ const trimmed = (message || '').trim();
33
+ const hasPrefix = commitPrefixes.some(
34
+ (p) => trimmed.startsWith(p + ':') || trimmed.startsWith(p + ' ')
35
+ );
36
+ if (!hasPrefix) {
37
+ throw new Error(
38
+ `Commit message must start with one of: ${commitPrefixes.map((p) => p + ':').join(', ')}. Update .haitaskrc rules.commitPrefixes or change the commit message.`
39
+ );
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Run full pipeline: Git → validate → AI → Jira (unless dry).
46
+ * @param {object} config - Loaded .haitaskrc
47
+ * @param {{ dry?: boolean }} options - dry: skip Jira API call
48
+ * @returns {Promise<{ ok: boolean, dry?: boolean, key?: string, payload?: object, commitData?: object, error?: string }>}
49
+ */
50
+ export async function runPipeline(config, options = {}) {
51
+ const { dry = false } = options;
52
+
53
+ const commitData = await getLatestCommitData();
54
+ validateRules(commitData, config);
55
+
56
+ const payload = await generateTaskPayload(commitData, config);
57
+
58
+ if (dry) {
59
+ return { ok: true, dry: true, payload, commitData };
60
+ }
61
+
62
+ const { key } = await createIssue(payload, config);
63
+ return { ok: true, key, payload, commitData };
64
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Git data extraction via execa (git CLI).
3
+ * Returns: { message, branch, repoName } or throws.
4
+ */
5
+
6
+ import { execa } from 'execa';
7
+ import { basename } from 'path';
8
+
9
+ const CWD = process.cwd();
10
+
11
+ /**
12
+ * Get latest commit message, current branch, and repo (root folder) name.
13
+ * @returns {Promise<{ message: string, branch: string, repoName: string }>}
14
+ * @throws {Error} If not a git repo or git command fails
15
+ */
16
+ export async function getLatestCommitData() {
17
+ const [messageResult, branchResult, rootResult] = await Promise.all([
18
+ execa('git', ['log', '-1', '--pretty=%B'], { cwd: CWD }),
19
+ execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: CWD }),
20
+ execa('git', ['rev-parse', '--show-toplevel'], { cwd: CWD }),
21
+ ]);
22
+
23
+ const message = (messageResult.stdout || '').trim();
24
+ const branch = (branchResult.stdout || '').trim();
25
+ const repoRoot = (rootResult.stdout || '').trim();
26
+ const repoName = repoRoot ? basename(repoRoot) : '';
27
+
28
+ return { message, branch, repoName };
29
+ }
package/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+
3
+ import 'dotenv/config';
4
+ import { program } from 'commander';
5
+ import { runInit } from './commands/init.js';
6
+ import { runRun } from './commands/run.js';
7
+
8
+ program
9
+ .name('haitask')
10
+ .description('HAITASK — Generate Jira tasks from Git commits using AI')
11
+ .version('0.1.0');
12
+
13
+ program
14
+ .command('init')
15
+ .description('Create .haitaskrc and validate environment')
16
+ .action(runInit);
17
+
18
+ program
19
+ .command('run')
20
+ .description('Run full pipeline: Git → AI → Jira')
21
+ .option('--dry', 'Skip Jira API call, run everything else')
22
+ .action((opts) => runRun(opts));
23
+
24
+ program.parse();
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Jira REST client: create issue + assign.
3
+ * Jira Cloud REST API v3. Assignee via dedicated endpoint or issue PUT.
4
+ */
5
+
6
+ /**
7
+ * Convert plain text to Atlassian Document Format (ADF) for description field.
8
+ */
9
+ function plainTextToAdf(text) {
10
+ const paragraphs = (text || '').trim().split(/\n+/).filter(Boolean);
11
+ const content = paragraphs.length
12
+ ? paragraphs.map((p) => ({ type: 'paragraph', content: [{ type: 'text', text: p }] }))
13
+ : [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }];
14
+ return { type: 'doc', version: 1, content };
15
+ }
16
+
17
+ /**
18
+ * Assign issue to user. Tries two methods for compatibility.
19
+ * 1) PUT /rest/api/3/issue/{key}/assignee body: { accountId }
20
+ * 2) PUT /rest/api/3/issue/{key} body: { fields: { assignee: { accountId } } }
21
+ * If accountId looks like "number:uuid", also tries uuid-only (some instances expect that).
22
+ * @returns {{ ok: boolean, message?: string }}
23
+ */
24
+ async function assignIssue(baseUrl, issueKey, accountId, auth) {
25
+ const headers = {
26
+ 'Content-Type': 'application/json',
27
+ Accept: 'application/json',
28
+ Authorization: `Basic ${auth}`,
29
+ };
30
+
31
+ const candidates = [accountId];
32
+ if (accountId.includes(':')) {
33
+ candidates.push(accountId.split(':').slice(-1)[0]);
34
+ }
35
+
36
+ let lastError = '';
37
+ for (const id of candidates) {
38
+ if (!id?.trim()) continue;
39
+
40
+ const assignUrl = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/assignee`;
41
+ let res = await fetch(assignUrl, {
42
+ method: 'PUT',
43
+ headers,
44
+ body: JSON.stringify({ accountId: id }),
45
+ });
46
+ if (res.ok) return { ok: true };
47
+ const body1 = await res.text();
48
+ lastError = `assignee endpoint ${res.status}: ${body1 || res.statusText}`;
49
+
50
+ if (res.status === 400 || res.status === 404) {
51
+ const issueUrl = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}`;
52
+ res = await fetch(issueUrl, {
53
+ method: 'PUT',
54
+ headers,
55
+ body: JSON.stringify({ fields: { assignee: { accountId: id } } }),
56
+ });
57
+ if (res.ok) return { ok: true };
58
+ const body2 = await res.text();
59
+ lastError = `assignee endpoint: ${body1}; issue PUT ${res.status}: ${body2 || res.statusText}`;
60
+ }
61
+ }
62
+
63
+ return {
64
+ ok: false,
65
+ message: `Assign failed. ${lastError} Use JIRA_ACCOUNT_ID from Jira: Profile → Account ID, or admin.atlassian.com → Directory → Users → user → ID in URL. In .env use quotes if value has colon: JIRA_ACCOUNT_ID="...".`,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Read assignee accountId: config first, then .env.
71
+ * .env: use quotes if value contains colon, e.g. JIRA_ACCOUNT_ID="712020:ffdf70f7-..."
72
+ */
73
+ function getAssigneeAccountId(config) {
74
+ const fromConfig = (config?.jira?.assigneeAccountId || '').trim();
75
+ if (fromConfig) return fromConfig;
76
+ const fromEnv = (process.env.JIRA_ACCOUNT_ID || '').trim();
77
+ return fromEnv;
78
+ }
79
+
80
+ /**
81
+ * Create a Jira issue and optionally assign to JIRA_ACCOUNT_ID.
82
+ */
83
+ export async function createIssue(payload, config) {
84
+ const baseUrl = (config?.jira?.baseUrl || process.env.JIRA_BASE_URL || '').replace(/\/$/, '');
85
+ const email = process.env.JIRA_EMAIL;
86
+ const token = process.env.JIRA_API_TOKEN;
87
+
88
+ if (!baseUrl || !email?.trim() || !token?.trim()) {
89
+ throw new Error('Jira credentials missing. Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN in .env.');
90
+ }
91
+
92
+ const projectKey = config?.jira?.projectKey || 'PROJ';
93
+ const issueType = config?.jira?.issueType || 'Task';
94
+ const assigneeAccountId = getAssigneeAccountId(config);
95
+
96
+ const fields = {
97
+ project: { key: projectKey },
98
+ summary: (payload.title || '').trim() || 'Untitled',
99
+ description: plainTextToAdf(payload.description || ''),
100
+ issuetype: { name: issueType },
101
+ labels: Array.isArray(payload.labels) ? payload.labels.filter((l) => typeof l === 'string') : [],
102
+ };
103
+
104
+ const auth = Buffer.from(`${email}:${token}`, 'utf-8').toString('base64');
105
+ const createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Basic ${auth}` },
108
+ body: JSON.stringify({ fields }),
109
+ });
110
+
111
+ if (!createRes.ok) {
112
+ const text = await createRes.text();
113
+ throw new Error(`Jira API error ${createRes.status}: ${text || createRes.statusText}`);
114
+ }
115
+
116
+ const data = await createRes.json();
117
+ const key = data?.key;
118
+ if (!key) throw new Error('Jira API response missing issue key.');
119
+
120
+ if (assigneeAccountId) {
121
+ const assignResult = await assignIssue(baseUrl, key, assigneeAccountId, auth);
122
+ if (!assignResult.ok) {
123
+ throw new Error(`Issue ${key} created but assign failed. ${assignResult.message || ''}`);
124
+ }
125
+ }
126
+
127
+ return { key, self: data.self };
128
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared utilities (if any).
3
+ */
4
+
5
+ export function noop() { }