haitask 0.1.2 → 0.1.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 CHANGED
@@ -34,7 +34,7 @@ git clone https://github.com/HidayetHidayetov/haitask.git && cd haitask && npm i
34
34
  cd /path/to/your/repo
35
35
  haitask init
36
36
  ```
37
- **Interactive setup:** you’ll be asked for Jira base URL, project key, issue type, AI provider (groq / deepseek / openai), allowed branches, and commit prefixes. A `.haitaskrc` file is created from your answers. You’ll then choose where to store API keys: **this project** (`.env` in the repo) or **global** (`~/.haitask/.env`, shared across projects). A template `.env` is created in the chosen place — add your own keys there (never commit real keys).
37
+ **Interactive setup:** you’ll be asked for Jira base URL, project key, issue type, transition-to status after create (e.g. Done, To Do), AI provider (groq / deepseek / openai), allowed branches, and commit prefixes. A `.haitaskrc` file is created from your answers. You’ll then choose where to store API keys: **this project** (`.env` in the repo) or **global** (`~/.haitask/.env`, shared across projects). A template `.env` is created in the chosen place — add your own keys there (never commit real keys).
38
38
 
39
39
  2. **Add your API keys**
40
40
  Edit the `.env` that was created (project or `~/.haitask/.env`): set one AI key (`GROQ_API_KEY`, `DEEPSEEK_API_KEY`, or `OPENAI_API_KEY`) and Jira keys (`JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`). Optional: `JIRA_ACCOUNT_ID` to auto-assign issues.
@@ -56,12 +56,14 @@ git clone https://github.com/HidayetHidayetov/haitask.git && cd haitask && npm i
56
56
  | `haitask init` | Interactive setup: prompts for Jira/AI/rules → writes `.haitaskrc`, optional `.env` (project or ~/.haitask/.env). |
57
57
  | `haitask run` | Run pipeline: Git → AI → Jira (create issue). |
58
58
  | `haitask run --dry` | Same as above but skips the Jira API call. |
59
+ | `haitask run --type <type>` | Same as `run` but use this Jira issue type (e.g. `Task`, `Bug`, `Story`, `Sub-task`). Overrides `jira.issueType` in `.haitaskrc` for this run only. |
60
+ | `haitask run --status <status>` | Same as `run` but transition the issue to this status after create (e.g. `Done`, `To Do`, `In Progress`). Overrides `jira.transitionToStatus` in `.haitaskrc` for this run only. |
59
61
 
60
62
  ---
61
63
 
62
64
  ## Configuration
63
65
 
64
- - **`.haitaskrc`** (project root): Jira `baseUrl`, `projectKey`, `issueType`; AI `provider` and `model`; `rules.allowedBranches` and `rules.commitPrefixes`. Single source of truth for behaviour.
66
+ - **`.haitaskrc`** (project root): Jira `baseUrl`, `projectKey`, `issueType`, `transitionToStatus`; AI `provider` and `model`; `rules.allowedBranches` and `rules.commitPrefixes`. Single source of truth for behaviour. **Issue type:** Default is `jira.issueType` (set at `haitask init` or edit `.haitaskrc`). Override for a single run with `haitask run --type Bug` (or `Story`, `Sub-task`, etc.). **Status after create:** Default is `jira.transitionToStatus` (e.g. `Done`). Override for a single run with `haitask run --status "To Do"` or `--status "In Progress"`.
65
67
  - **`.env`**: API keys only. Loaded in order: **project** `.env` (current directory), then **global** `~/.haitask/.env`. So you can use one global `.env` for all projects or override per repo.
66
68
 
67
69
  **AI providers** (set `ai.provider` in `.haitaskrc`): `groq` (default, free), `deepseek` (free), `openai` (paid). Set the corresponding key in `.env`.
@@ -81,3 +83,10 @@ git clone https://github.com/HidayetHidayetov/haitask.git && cd haitask && npm i
81
83
  - **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.
82
84
 
83
85
  No framework-specific setup (e.g. Laravel, React, etc.); the tool only depends on Git and the config files above.
86
+
87
+ ---
88
+
89
+ ## Troubleshooting
90
+
91
+ **Assignee not set / still unassigned**
92
+ In Jira Cloud, the user in `JIRA_ACCOUNT_ID` must be an **Assignable user** in that project, or the assign API will not apply (or will succeed but leave the issue unassigned). Fix: **Project → Space settings → People** — add yourself to the project (team-managed). For company-managed projects, ensure the permission scheme grants you **Assignable user**. You can still assign the issue to yourself manually in Jira; the API can only assign to users who are assignable in the project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haitask",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Hidayet AI Task — Generate Jira tasks from Git commits using AI",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/ai/utils.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * Shared AI utilities: prompt building and response parsing.
3
3
  */
4
4
 
5
+ const CONVENTIONAL_PREFIXES = /^(feat|fix|chore|docs|style|refactor|test|build|ci):\s*/i;
6
+
5
7
  /**
6
8
  * Build system + user prompt from commit data.
7
9
  * @param {{ message: string, branch: string, repoName: string }} commitData
@@ -10,16 +12,23 @@
10
12
  export function buildPrompt(commitData) {
11
13
  const { message, branch, repoName } = commitData;
12
14
  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"]).`;
15
+
16
+ Keys:
17
+ - "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.
18
+ - "description": Detailed description in plain language, suitable for Jira. Expand and formalize the intent of the commit; do not just paste the commit message.
19
+ - "labels": Array of strings, e.g. ["auto", "commit"].
20
+ - "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.`;
14
21
  const user = `Repo: ${repoName}\nBranch: ${branch}\nCommit message:\n${message}\n\nGenerate the JSON object.`;
15
22
  return { system, user };
16
23
  }
17
24
 
25
+ const VALID_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'];
26
+
18
27
  /**
19
- * Parse and validate AI response. Expects { title, description, labels }.
28
+ * Parse and validate AI response. Expects { title, description, labels, priority? }.
20
29
  * @param {string} raw
21
- * @returns {{ title: string, description: string, labels: string[] }}
22
- * @throws {Error} If invalid JSON or missing/ wrong types
30
+ * @returns {{ title: string, description: string, labels: string[], priority: string }}
31
+ * @throws {Error} If invalid JSON or missing/wrong types
23
32
  */
24
33
  export function parseTaskPayload(raw) {
25
34
  let obj;
@@ -35,8 +44,9 @@ export function parseTaskPayload(raw) {
35
44
  throw new Error('AI response labels must be an array of strings.');
36
45
  }
37
46
  const labels = obj.labels.filter((l) => typeof l === 'string');
38
- // Strip conventional commit prefix from title so Jira gets a plain task title
39
47
  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 };
48
+ const title = rawTitle.replace(CONVENTIONAL_PREFIXES, '').trim() || rawTitle;
49
+ const rawPriority = (obj.priority || 'Medium').trim();
50
+ const priority = VALID_PRIORITIES.includes(rawPriority) ? rawPriority : 'Medium';
51
+ return { title, description: obj.description.trim(), labels, priority };
42
52
  }
@@ -48,6 +48,7 @@ export async function runInit() {
48
48
  const jiraBaseUrl = await question(rl, 'Jira base URL', 'https://your-domain.atlassian.net');
49
49
  const jiraProjectKey = await question(rl, 'Jira project key', 'PROJ');
50
50
  const jiraIssueType = await question(rl, 'Jira issue type', 'Task');
51
+ const jiraTransitionToStatus = await question(rl, 'Transition issue to status after create (e.g. Done, To Do, In Progress)', 'Done');
51
52
  const aiProvider = await question(rl, 'AI provider (groq | deepseek | openai)', 'groq');
52
53
  const allowedBranchesStr = await question(rl, 'Allowed branches (comma-separated)', 'main,develop,master');
53
54
  const commitPrefixesStr = await question(rl, 'Commit prefixes (comma-separated)', 'feat,fix,chore');
@@ -57,7 +58,12 @@ export async function runInit() {
57
58
  const model = DEFAULT_MODELS[aiProvider.toLowerCase()] || DEFAULT_MODELS.groq;
58
59
 
59
60
  const config = {
60
- jira: { baseUrl: jiraBaseUrl, projectKey: jiraProjectKey, issueType: jiraIssueType },
61
+ jira: {
62
+ baseUrl: jiraBaseUrl,
63
+ projectKey: jiraProjectKey,
64
+ issueType: jiraIssueType,
65
+ transitionToStatus: (jiraTransitionToStatus || 'Done').trim() || 'Done',
66
+ },
61
67
  ai: { provider: aiProvider.toLowerCase(), model },
62
68
  rules: { allowedBranches, commitPrefixes },
63
69
  };
@@ -91,19 +97,19 @@ export async function runInit() {
91
97
  rl.close();
92
98
  }
93
99
 
94
- let config;
100
+ let loadedConfig = null;
95
101
  try {
96
- config = loadConfig();
102
+ loadedConfig = loadConfig();
97
103
  } catch {
98
- config = null;
104
+ // rc just written; load may fail if cwd changed
99
105
  }
100
106
 
101
- const { valid, missing } = validateEnv(cwd, config);
107
+ const { valid, missing } = validateEnv(cwd, loadedConfig);
102
108
  if (!valid) {
103
109
  console.warn('\nAdd these to your .env before "haitask run":', missing.join(', '));
104
110
  console.log('Env is read from: project .env, then ~/.haitask/.env');
105
- if (config?.ai?.provider === 'groq') console.log('Groq key: https://console.groq.com/keys');
106
- if (config?.ai?.provider === 'deepseek') console.log('Deepseek key: https://platform.deepseek.com/');
111
+ if (loadedConfig?.ai?.provider === 'groq') console.log('Groq key: https://console.groq.com/keys');
112
+ if (loadedConfig?.ai?.provider === 'deepseek') console.log('Deepseek key: https://platform.deepseek.com/');
107
113
  process.exitCode = 1;
108
114
  return;
109
115
  }
@@ -1,13 +1,20 @@
1
1
  /**
2
- * haitask run [--dry] — Execute full pipeline
2
+ * haitask run — Execute full pipeline (Git → AI → Jira).
3
3
  * Thin handler: load config, call pipeline, output result.
4
4
  */
5
5
 
6
6
  import { loadConfig } from '../config/load.js';
7
7
  import { runPipeline } from '../core/pipeline.js';
8
8
 
9
+ function buildIssueUrl(config, issueKey) {
10
+ const baseUrl = (config?.jira?.baseUrl || process.env.JIRA_BASE_URL || '').replace(/\/$/, '');
11
+ return baseUrl ? `${baseUrl}/browse/${issueKey}` : issueKey;
12
+ }
13
+
9
14
  export async function runRun(options = {}) {
10
15
  const dry = options.dry ?? false;
16
+ const type = options.type?.trim() || undefined;
17
+ const status = options.status?.trim() || undefined;
11
18
 
12
19
  let config;
13
20
  try {
@@ -19,7 +26,7 @@ export async function runRun(options = {}) {
19
26
  }
20
27
 
21
28
  try {
22
- const result = await runPipeline(config, { dry });
29
+ const result = await runPipeline(config, { dry, issueType: type, transitionToStatus: status });
23
30
 
24
31
  if (!result.ok) {
25
32
  console.error(result.error || 'Pipeline failed.');
@@ -32,18 +39,15 @@ export async function runRun(options = {}) {
32
39
  console.log('Commit:', result.commitData?.message?.split('\n')[0] || '');
33
40
  console.log('Would create Jira task:', result.payload?.title || '');
34
41
  if (result.payload?.description) {
35
- console.log('Description (preview):', result.payload.description.slice(0, 120) + (result.payload.description.length > 120 ? '...' : ''));
42
+ const desc = result.payload.description;
43
+ console.log('Description (preview):', desc.slice(0, 120) + (desc.length > 120 ? '...' : ''));
36
44
  }
37
45
  return;
38
46
  }
39
47
 
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
48
  console.log('Created Jira issue:', result.key);
44
- if (issueUrl !== result.key) {
45
- console.log(issueUrl);
46
- }
49
+ const issueUrl = buildIssueUrl(config, result.key);
50
+ if (issueUrl !== result.key) console.log(issueUrl);
47
51
  } catch (err) {
48
52
  console.error('Error:', err.message);
49
53
  process.exitCode = 1;
@@ -11,6 +11,7 @@ const DEFAULT_RC = {
11
11
  baseUrl: 'https://your-domain.atlassian.net',
12
12
  projectKey: 'PROJ',
13
13
  issueType: 'Task',
14
+ transitionToStatus: 'Done',
14
15
  },
15
16
  ai: {
16
17
  provider: 'groq',
@@ -28,14 +29,12 @@ const REQUIRED_ENV_KEYS = ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN'];
28
29
  /**
29
30
  * Create .haitaskrc in dir if it does not exist.
30
31
  * @param {string} [dir] - Directory (default: process.cwd())
31
- * @returns {{ created: boolean }} created true if file was written, false if already existed
32
+ * @returns {{ created: boolean }}
32
33
  */
33
34
  export function createDefaultConfigFile(dir = process.cwd()) {
34
- const path = resolve(dir, '.haitaskrc');
35
- if (existsSync(path)) {
36
- return { created: false };
37
- }
38
- writeFileSync(path, JSON.stringify(DEFAULT_RC, null, 2), 'utf-8');
35
+ const rcPath = resolve(dir, '.haitaskrc');
36
+ if (existsSync(rcPath)) return { created: false };
37
+ writeFileSync(rcPath, JSON.stringify(DEFAULT_RC, null, 2), 'utf-8');
39
38
  return { created: true };
40
39
  }
41
40
 
@@ -6,36 +6,36 @@
6
6
  import { readFileSync, existsSync } from 'fs';
7
7
  import { resolve } from 'path';
8
8
 
9
- const DEFAULT_CONFIG_PATH = '.haitaskrc';
10
-
9
+ const CONFIG_FILENAME = '.haitaskrc';
11
10
  const REQUIRED_KEYS = ['jira', 'ai', 'rules'];
12
11
 
13
12
  /**
13
+ * Load and validate .haitaskrc.
14
14
  * @param {string} [configPath] - Absolute or cwd-relative path to .haitaskrc
15
15
  * @returns {object} Parsed config
16
16
  * @throws {Error} If file missing, invalid JSON, or missing required sections
17
17
  */
18
18
  export function loadConfig(configPath) {
19
- const path = configPath
19
+ const filePath = configPath
20
20
  ? resolve(configPath)
21
- : resolve(process.cwd(), DEFAULT_CONFIG_PATH);
21
+ : resolve(process.cwd(), CONFIG_FILENAME);
22
22
 
23
- if (!existsSync(path)) {
24
- throw new Error(`Config not found: ${path}. Run "haitask init" first.`);
23
+ if (!existsSync(filePath)) {
24
+ throw new Error(`Config not found: ${filePath}. Run "haitask init" first.`);
25
25
  }
26
26
 
27
27
  let raw;
28
28
  try {
29
- raw = readFileSync(path, 'utf-8');
29
+ raw = readFileSync(filePath, 'utf-8');
30
30
  } catch (err) {
31
- throw new Error(`Cannot read config: ${path}. ${err.message}`);
31
+ throw new Error(`Cannot read config: ${filePath}. ${err.message}`);
32
32
  }
33
33
 
34
34
  let config;
35
35
  try {
36
36
  config = JSON.parse(raw);
37
37
  } catch (err) {
38
- throw new Error(`Invalid JSON in ${path}. ${err.message}`);
38
+ throw new Error(`Invalid JSON in ${filePath}. ${err.message}`);
39
39
  }
40
40
 
41
41
  const missing = REQUIRED_KEYS.filter((key) => !config[key] || typeof config[key] !== 'object');
@@ -44,11 +44,11 @@ function validateRules(commitData, config) {
44
44
  /**
45
45
  * Run full pipeline: Git → validate → AI → Jira (unless dry).
46
46
  * @param {object} config - Loaded .haitaskrc
47
- * @param {{ dry?: boolean }} options - dry: skip Jira API call
47
+ * @param {{ dry?: boolean, issueType?: string, transitionToStatus?: string }} options
48
48
  * @returns {Promise<{ ok: boolean, dry?: boolean, key?: string, payload?: object, commitData?: object, error?: string }>}
49
49
  */
50
50
  export async function runPipeline(config, options = {}) {
51
- const { dry = false } = options;
51
+ const { dry = false, issueType: typeOverride, transitionToStatus: statusOverride } = options;
52
52
 
53
53
  const commitData = await getLatestCommitData();
54
54
  validateRules(commitData, config);
@@ -59,6 +59,23 @@ export async function runPipeline(config, options = {}) {
59
59
  return { ok: true, dry: true, payload, commitData };
60
60
  }
61
61
 
62
- const { key } = await createIssue(payload, config);
62
+ const jiraConfig = mergeJiraOverrides(config, { issueType: typeOverride, transitionToStatus: statusOverride });
63
+ const { key } = await createIssue(payload, jiraConfig);
63
64
  return { ok: true, key, payload, commitData };
64
65
  }
66
+
67
+ /**
68
+ * Merge run-time overrides into config.jira. Returns config unchanged if no overrides.
69
+ */
70
+ function mergeJiraOverrides(config, overrides) {
71
+ const { issueType, transitionToStatus } = overrides;
72
+ if (issueType == null && transitionToStatus == null) return config;
73
+ return {
74
+ ...config,
75
+ jira: {
76
+ ...config.jira,
77
+ ...(issueType != null && { issueType }),
78
+ ...(transitionToStatus != null && { transitionToStatus }),
79
+ },
80
+ };
81
+ }
package/src/index.js CHANGED
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createRequire } from 'node:module';
3
4
  import { loadEnvFiles } from './config/env-loader.js';
4
- loadEnvFiles();
5
5
  import { program } from 'commander';
6
6
  import { runInit } from './commands/init.js';
7
7
  import { runRun } from './commands/run.js';
8
8
 
9
+ loadEnvFiles();
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const pkg = require('../package.json');
13
+
9
14
  program
10
15
  .name('haitask')
11
16
  .description('HAITASK — Generate Jira tasks from Git commits using AI')
12
- .version('0.1.1');
17
+ .version(pkg.version);
13
18
 
14
19
  program
15
20
  .command('init')
@@ -20,6 +25,8 @@ program
20
25
  .command('run')
21
26
  .description('Run full pipeline: Git → AI → Jira')
22
27
  .option('--dry', 'Skip Jira API call, run everything else')
28
+ .option('-t, --type <type>', 'Jira issue type for this run (e.g. Task, Bug, Story, Sub-task). Overrides .haitaskrc jira.issueType')
29
+ .option('-s, --status <status>', 'Transition issue to this status after create (e.g. Done, "To Do", "In Progress"). Overrides .haitaskrc jira.transitionToStatus')
23
30
  .action((opts) => runRun(opts));
24
31
 
25
32
  program.parse();
@@ -1,11 +1,25 @@
1
1
  /**
2
- * Jira REST client: create issue + assign.
3
- * Jira Cloud REST API v3. Assignee via dedicated endpoint or issue PUT.
2
+ * Jira REST client: create issue, assign, optional transition to status.
3
+ * Jira Cloud REST API v3.
4
4
  */
5
5
 
6
- /**
7
- * Convert plain text to Atlassian Document Format (ADF) for description field.
8
- */
6
+ const ASSIGN_DELAY_MS = 4000;
7
+ const DEFAULT_PROJECT_KEY = 'PROJ';
8
+ const DEFAULT_ISSUE_TYPE = 'Task';
9
+ const DEFAULT_TRANSITION_STATUS = 'Done';
10
+ const VALID_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'];
11
+
12
+ const ASSIGNABLE_HINT =
13
+ 'In Jira Cloud the assignee must be an "Assignable user" in the project: Project → Space settings → People.';
14
+
15
+ function jiraHeaders(auth) {
16
+ return {
17
+ 'Content-Type': 'application/json',
18
+ Accept: 'application/json',
19
+ Authorization: `Basic ${auth}`,
20
+ };
21
+ }
22
+
9
23
  function plainTextToAdf(text) {
10
24
  const paragraphs = (text || '').trim().split(/\n+/).filter(Boolean);
11
25
  const content = paragraphs.length
@@ -14,71 +28,125 @@ function plainTextToAdf(text) {
14
28
  return { type: 'doc', version: 1, content };
15
29
  }
16
30
 
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
- */
31
+ function getAssigneeAccountId(config) {
32
+ const fromConfig = (config?.jira?.assigneeAccountId || '').trim();
33
+ if (fromConfig) return fromConfig;
34
+ return (process.env.JIRA_ACCOUNT_ID || '').trim();
35
+ }
36
+
37
+ async function getMyselfAccountId(baseUrl, auth) {
38
+ const res = await fetch(`${baseUrl}/rest/api/3/myself`, {
39
+ headers: { Accept: 'application/json', Authorization: `Basic ${auth}` },
40
+ });
41
+ if (!res.ok) {
42
+ const text = await res.text();
43
+ throw new Error(`Jira myself API ${res.status}: ${text || res.statusText}`);
44
+ }
45
+ const data = await res.json();
46
+ const id = data?.accountId;
47
+ if (!id) throw new Error('Jira myself response missing accountId.');
48
+ return id;
49
+ }
50
+
51
+ async function verifyAssignee(baseUrl, issueKey, auth) {
52
+ try {
53
+ const res = await fetch(
54
+ `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=assignee`,
55
+ { headers: { Accept: 'application/json', Authorization: `Basic ${auth}` } }
56
+ );
57
+ if (!res.ok) return false;
58
+ const data = await res.json();
59
+ return !!data?.fields?.assignee?.accountId;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
24
65
  async function assignIssue(baseUrl, issueKey, accountId, auth) {
25
- const headers = {
26
- 'Content-Type': 'application/json',
27
- Accept: 'application/json',
28
- Authorization: `Basic ${auth}`,
29
- };
66
+ const id = (accountId || '').trim();
67
+ if (!id) {
68
+ return { ok: false, message: 'No assignee accountId. Set JIRA_ACCOUNT_ID in .env (full format: "712020:uuid").' };
69
+ }
70
+
71
+ const headers = jiraHeaders(auth);
72
+ const assignUrl = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/assignee`;
73
+ let res = await fetch(assignUrl, {
74
+ method: 'PUT',
75
+ headers,
76
+ body: JSON.stringify({ accountId: id }),
77
+ });
30
78
 
31
- const candidates = [accountId];
32
- if (accountId.includes(':')) {
33
- candidates.push(accountId.split(':').slice(-1)[0]);
79
+ if (res.ok) {
80
+ const verified = await verifyAssignee(baseUrl, issueKey, auth);
81
+ if (verified) return { ok: true };
82
+ return { ok: false, message: `Assign API returned success but issue is still unassigned. ${ASSIGNABLE_HINT}` };
34
83
  }
35
84
 
36
- let lastError = '';
37
- for (const id of candidates) {
38
- if (!id?.trim()) continue;
85
+ const body1 = await res.text();
86
+ let lastError = `assignee endpoint ${res.status}: ${body1 || res.statusText}`;
39
87
 
40
- const assignUrl = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/assignee`;
41
- let res = await fetch(assignUrl, {
88
+ if (res.status === 400 || res.status === 404) {
89
+ res = await fetch(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
42
90
  method: 'PUT',
43
91
  headers,
44
- body: JSON.stringify({ accountId: id }),
92
+ body: JSON.stringify({ fields: { assignee: { accountId: id } } }),
45
93
  });
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}`;
94
+ if (res.ok) {
95
+ const verified = await verifyAssignee(baseUrl, issueKey, auth);
96
+ if (verified) return { ok: true };
97
+ return { ok: false, message: `Assign API returned success but issue is still unassigned. ${ASSIGNABLE_HINT}` };
60
98
  }
99
+ const body2 = await res.text();
100
+ lastError += `; issue PUT ${res.status}: ${body2 || res.statusText}`;
61
101
  }
62
102
 
63
103
  return {
64
104
  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="...".`,
105
+ message: `Assign failed. ${lastError} ${ASSIGNABLE_HINT} Use JIRA_ACCOUNT_ID (full format with colon) in .env.`,
66
106
  };
67
107
  }
68
108
 
69
109
  /**
70
- * Read assignee accountId: config first, then .env.
71
- * .env: use quotes if value contains colon, e.g. JIRA_ACCOUNT_ID="712020:ffdf70f7-..."
110
+ * Transition issue to the given status (e.g. Done, To Do, In Progress).
111
+ * Finds transition by destination status name (case-insensitive). No-op if status empty or not found.
72
112
  */
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;
113
+ async function transitionToStatus(baseUrl, issueKey, auth, statusName) {
114
+ const name = (statusName || '').trim();
115
+ if (!name) return;
116
+
117
+ const headers = jiraHeaders(auth);
118
+ const transRes = await fetch(
119
+ `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`,
120
+ { headers: { Accept: 'application/json', Authorization: `Basic ${auth}` } }
121
+ );
122
+ if (!transRes.ok) return;
123
+ const transData = await transRes.json();
124
+ const transitions = transData?.transitions || [];
125
+ const target = name.toLowerCase();
126
+ const transition = transitions.find(
127
+ (t) => (t.to?.name || '').toLowerCase() === target || (t.name || '').toLowerCase() === target
128
+ );
129
+ if (!transition?.id) return;
130
+ await fetch(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`, {
131
+ method: 'POST',
132
+ headers,
133
+ body: JSON.stringify({ transition: { id: transition.id } }),
134
+ });
135
+ }
136
+
137
+ function normalizePriority(value) {
138
+ const v = (value || '').trim();
139
+ if (!v) return 'Medium';
140
+ const cap = v.charAt(0).toUpperCase() + v.slice(1).toLowerCase();
141
+ if (VALID_PRIORITIES.includes(cap)) return cap;
142
+ if (['High', 'Medium', 'Low'].includes(cap)) return cap;
143
+ return 'Medium';
78
144
  }
79
145
 
80
146
  /**
81
- * Create a Jira issue and optionally assign to JIRA_ACCOUNT_ID.
147
+ * Create a Jira issue, assign, and optionally transition to a status.
148
+ * @param {object} payload - { title, description, labels, priority }
149
+ * @param {object} config - .haitaskrc (jira.baseUrl, projectKey, issueType, transitionToStatus)
82
150
  */
83
151
  export async function createIssue(payload, config) {
84
152
  const baseUrl = (config?.jira?.baseUrl || process.env.JIRA_BASE_URL || '').replace(/\/$/, '');
@@ -89,40 +157,72 @@ export async function createIssue(payload, config) {
89
157
  throw new Error('Jira credentials missing. Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN in .env.');
90
158
  }
91
159
 
92
- const projectKey = config?.jira?.projectKey || 'PROJ';
93
- const issueType = config?.jira?.issueType || 'Task';
94
- const assigneeAccountId = getAssigneeAccountId(config);
160
+ const projectKey = config?.jira?.projectKey || DEFAULT_PROJECT_KEY;
161
+ const issueType = config?.jira?.issueType || DEFAULT_ISSUE_TYPE;
162
+ const rawStatus = config?.jira?.transitionToStatus;
163
+ const transitionToStatusName =
164
+ rawStatus === undefined || rawStatus === null ? DEFAULT_TRANSITION_STATUS : String(rawStatus).trim();
165
+ const auth = Buffer.from(`${email}:${token}`, 'utf-8').toString('base64');
166
+
167
+ let assigneeAccountId = getAssigneeAccountId(config);
168
+ if (!assigneeAccountId) {
169
+ assigneeAccountId = await getMyselfAccountId(baseUrl, auth);
170
+ } else if (!assigneeAccountId.includes(':')) {
171
+ assigneeAccountId = await getMyselfAccountId(baseUrl, auth);
172
+ }
173
+
174
+ const priorityName = normalizePriority(payload.priority);
95
175
 
96
- const fields = {
176
+ const baseFields = {
97
177
  project: { key: projectKey },
98
178
  summary: (payload.title || '').trim() || 'Untitled',
99
179
  description: plainTextToAdf(payload.description || ''),
100
180
  issuetype: { name: issueType },
101
181
  labels: Array.isArray(payload.labels) ? payload.labels.filter((l) => typeof l === 'string') : [],
182
+ assignee: { accountId: assigneeAccountId },
102
183
  };
103
184
 
104
- const auth = Buffer.from(`${email}:${token}`, 'utf-8').toString('base64');
105
- const createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
185
+ let fields = { ...baseFields, priority: { name: priorityName } };
186
+ let createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
106
187
  method: 'POST',
107
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Basic ${auth}` },
188
+ headers: jiraHeaders(auth),
108
189
  body: JSON.stringify({ fields }),
109
190
  });
110
191
 
111
192
  if (!createRes.ok) {
112
- const text = await createRes.text();
113
- throw new Error(`Jira API error ${createRes.status}: ${text || createRes.statusText}`);
193
+ const firstErrorText = await createRes.text();
194
+ if (createRes.status === 400) {
195
+ const isPriority = /priority|Priority/i.test(firstErrorText);
196
+ const isAssignee = /assignee|Assignee/i.test(firstErrorText);
197
+ const retryFields = { ...baseFields };
198
+ if (isAssignee) delete retryFields.assignee;
199
+ if (isPriority) delete retryFields.priority;
200
+ if (isPriority || isAssignee) {
201
+ createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
202
+ method: 'POST',
203
+ headers: jiraHeaders(auth),
204
+ body: JSON.stringify({ fields: retryFields }),
205
+ });
206
+ }
207
+ }
208
+ if (!createRes.ok) {
209
+ const errText = createRes.bodyUsed ? firstErrorText : await createRes.text();
210
+ throw new Error(`Jira API error ${createRes.status}: ${errText || createRes.statusText}`);
211
+ }
114
212
  }
115
213
 
116
214
  const data = await createRes.json();
117
215
  const key = data?.key;
118
216
  if (!key) throw new Error('Jira API response missing issue key.');
119
217
 
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
- }
218
+ await new Promise((r) => setTimeout(r, ASSIGN_DELAY_MS));
219
+
220
+ const assignResult = await assignIssue(baseUrl, key, assigneeAccountId, auth);
221
+ if (!assignResult.ok) {
222
+ throw new Error(`Issue ${key} created but assign failed. ${assignResult.message || ''}`);
125
223
  }
126
224
 
225
+ await transitionToStatus(baseUrl, key, auth, transitionToStatusName);
226
+
127
227
  return { key, self: data.self };
128
228
  }
@@ -1,5 +1,4 @@
1
1
  /**
2
- * Shared utilities (if any).
2
+ * Shared utilities. Add helpers here when used by multiple modules.
3
3
  */
4
-
5
- export function noop() { }
4
+ export { };