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 +11 -2
- package/package.json +1 -1
- package/src/ai/utils.js +17 -7
- package/src/commands/init.js +13 -7
- package/src/commands/run.js +13 -9
- package/src/config/init.js +5 -6
- package/src/config/load.js +9 -9
- package/src/core/pipeline.js +20 -3
- package/src/index.js +9 -2
- package/src/jira/client.js +163 -63
- package/src/utils/index.js +2 -3
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
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
|
-
|
|
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/
|
|
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(
|
|
41
|
-
|
|
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
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -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: {
|
|
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
|
|
100
|
+
let loadedConfig = null;
|
|
95
101
|
try {
|
|
96
|
-
|
|
102
|
+
loadedConfig = loadConfig();
|
|
97
103
|
} catch {
|
|
98
|
-
|
|
104
|
+
// rc just written; load may fail if cwd changed
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
const { valid, missing } = validateEnv(cwd,
|
|
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 (
|
|
106
|
-
if (
|
|
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
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* haitask run
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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;
|
package/src/config/init.js
CHANGED
|
@@ -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 }}
|
|
32
|
+
* @returns {{ created: boolean }}
|
|
32
33
|
*/
|
|
33
34
|
export function createDefaultConfigFile(dir = process.cwd()) {
|
|
34
|
-
const
|
|
35
|
-
if (existsSync(
|
|
36
|
-
|
|
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
|
|
package/src/config/load.js
CHANGED
|
@@ -6,36 +6,36 @@
|
|
|
6
6
|
import { readFileSync, existsSync } from 'fs';
|
|
7
7
|
import { resolve } from 'path';
|
|
8
8
|
|
|
9
|
-
const
|
|
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
|
|
19
|
+
const filePath = configPath
|
|
20
20
|
? resolve(configPath)
|
|
21
|
-
: resolve(process.cwd(),
|
|
21
|
+
: resolve(process.cwd(), CONFIG_FILENAME);
|
|
22
22
|
|
|
23
|
-
if (!existsSync(
|
|
24
|
-
throw new Error(`Config not found: ${
|
|
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(
|
|
29
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
30
30
|
} catch (err) {
|
|
31
|
-
throw new Error(`Cannot read config: ${
|
|
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 ${
|
|
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');
|
package/src/core/pipeline.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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();
|
package/src/jira/client.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Jira REST client: create issue
|
|
3
|
-
* Jira Cloud REST API v3.
|
|
2
|
+
* Jira REST client: create issue, assign, optional transition to status.
|
|
3
|
+
* Jira Cloud REST API v3.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
if (!id?.trim()) continue;
|
|
85
|
+
const body1 = await res.text();
|
|
86
|
+
let lastError = `assignee endpoint ${res.status}: ${body1 || res.statusText}`;
|
|
39
87
|
|
|
40
|
-
|
|
41
|
-
|
|
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)
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
*
|
|
71
|
-
* .
|
|
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
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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 ||
|
|
93
|
-
const issueType = config?.jira?.issueType ||
|
|
94
|
-
const
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
185
|
+
let fields = { ...baseFields, priority: { name: priorityName } };
|
|
186
|
+
let createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
|
|
106
187
|
method: 'POST',
|
|
107
|
-
headers:
|
|
188
|
+
headers: jiraHeaders(auth),
|
|
108
189
|
body: JSON.stringify({ fields }),
|
|
109
190
|
});
|
|
110
191
|
|
|
111
192
|
if (!createRes.ok) {
|
|
112
|
-
const
|
|
113
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
}
|
package/src/utils/index.js
CHANGED