haitask 0.1.2 → 0.1.4
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 +14 -6
- package/src/commands/init.js +7 -1
- package/src/commands/run.js +3 -1
- package/src/config/init.js +1 -0
- package/src/core/pipeline.js +14 -3
- package/src/index.js +3 -1
- package/src/jira/client.js +149 -55
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
|
@@ -10,16 +10,23 @@
|
|
|
10
10
|
export function buildPrompt(commitData) {
|
|
11
11
|
const { message, branch, repoName } = commitData;
|
|
12
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
|
-
|
|
13
|
+
|
|
14
|
+
Keys:
|
|
15
|
+
- "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.
|
|
16
|
+
- "description": Detailed description in plain language, suitable for Jira. Expand and formalize the intent of the commit; do not just paste the commit message.
|
|
17
|
+
- "labels": Array of strings, e.g. ["auto", "commit"].
|
|
18
|
+
- "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
19
|
const user = `Repo: ${repoName}\nBranch: ${branch}\nCommit message:\n${message}\n\nGenerate the JSON object.`;
|
|
15
20
|
return { system, user };
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
const VALID_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'];
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
|
-
* Parse and validate AI response. Expects { title, description, labels }.
|
|
26
|
+
* Parse and validate AI response. Expects { title, description, labels, priority? }.
|
|
20
27
|
* @param {string} raw
|
|
21
|
-
* @returns {{ title: string, description: string, labels: string[] }}
|
|
22
|
-
* @throws {Error} If invalid JSON or missing/
|
|
28
|
+
* @returns {{ title: string, description: string, labels: string[], priority: string }}
|
|
29
|
+
* @throws {Error} If invalid JSON or missing/wrong types
|
|
23
30
|
*/
|
|
24
31
|
export function parseTaskPayload(raw) {
|
|
25
32
|
let obj;
|
|
@@ -35,8 +42,9 @@ export function parseTaskPayload(raw) {
|
|
|
35
42
|
throw new Error('AI response labels must be an array of strings.');
|
|
36
43
|
}
|
|
37
44
|
const labels = obj.labels.filter((l) => typeof l === 'string');
|
|
38
|
-
// Strip conventional commit prefix from title so Jira gets a plain task title
|
|
39
45
|
const rawTitle = (obj.title || '').trim();
|
|
40
46
|
const title = rawTitle.replace(/^(feat|fix|chore|docs|style|refactor|test|build|ci):\s*/i, '').trim() || rawTitle;
|
|
41
|
-
|
|
47
|
+
const rawPriority = (obj.priority || 'Medium').trim();
|
|
48
|
+
const priority = VALID_PRIORITIES.includes(rawPriority) ? rawPriority : 'Medium';
|
|
49
|
+
return { title, description: obj.description.trim(), labels, priority };
|
|
42
50
|
}
|
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
|
};
|
package/src/commands/run.js
CHANGED
|
@@ -8,6 +8,8 @@ import { runPipeline } from '../core/pipeline.js';
|
|
|
8
8
|
|
|
9
9
|
export async function runRun(options = {}) {
|
|
10
10
|
const dry = options.dry ?? false;
|
|
11
|
+
const type = options.type?.trim() || undefined;
|
|
12
|
+
const status = options.status?.trim() || undefined;
|
|
11
13
|
|
|
12
14
|
let config;
|
|
13
15
|
try {
|
|
@@ -19,7 +21,7 @@ export async function runRun(options = {}) {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
try {
|
|
22
|
-
const result = await runPipeline(config, { dry });
|
|
24
|
+
const result = await runPipeline(config, { dry, issueType: type, transitionToStatus: status });
|
|
23
25
|
|
|
24
26
|
if (!result.ok) {
|
|
25
27
|
console.error(result.error || 'Pipeline failed.');
|
package/src/config/init.js
CHANGED
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,17 @@ export async function runPipeline(config, options = {}) {
|
|
|
59
59
|
return { ok: true, dry: true, payload, commitData };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
let jiraConfig = config;
|
|
63
|
+
if (typeOverride != null || statusOverride != null) {
|
|
64
|
+
jiraConfig = {
|
|
65
|
+
...config,
|
|
66
|
+
jira: {
|
|
67
|
+
...config.jira,
|
|
68
|
+
...(typeOverride != null && { issueType: typeOverride }),
|
|
69
|
+
...(statusOverride != null && { transitionToStatus: statusOverride }),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const { key } = await createIssue(payload, jiraConfig);
|
|
63
74
|
return { ok: true, key, payload, commitData };
|
|
64
75
|
}
|
package/src/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { runRun } from './commands/run.js';
|
|
|
9
9
|
program
|
|
10
10
|
.name('haitask')
|
|
11
11
|
.description('HAITASK — Generate Jira tasks from Git commits using AI')
|
|
12
|
-
.version('0.1.
|
|
12
|
+
.version('0.1.3');
|
|
13
13
|
|
|
14
14
|
program
|
|
15
15
|
.command('init')
|
|
@@ -20,6 +20,8 @@ program
|
|
|
20
20
|
.command('run')
|
|
21
21
|
.description('Run full pipeline: Git → AI → Jira')
|
|
22
22
|
.option('--dry', 'Skip Jira API call, run everything else')
|
|
23
|
+
.option('-t, --type <type>', 'Jira issue type for this run (e.g. Task, Bug, Story, Sub-task). Overrides .haitaskrc jira.issueType')
|
|
24
|
+
.option('-s, --status <status>', 'Transition issue to this status after create (e.g. Done, "To Do", "In Progress"). Overrides .haitaskrc jira.transitionToStatus')
|
|
23
25
|
.action((opts) => runRun(opts));
|
|
24
26
|
|
|
25
27
|
program.parse();
|
package/src/jira/client.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
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
|
-
* Convert plain text to Atlassian Document Format (ADF) for description field.
|
|
8
|
-
*/
|
|
9
6
|
function plainTextToAdf(text) {
|
|
10
7
|
const paragraphs = (text || '').trim().split(/\n+/).filter(Boolean);
|
|
11
8
|
const content = paragraphs.length
|
|
@@ -14,71 +11,139 @@ function plainTextToAdf(text) {
|
|
|
14
11
|
return { type: 'doc', version: 1, content };
|
|
15
12
|
}
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
const ASSIGNABLE_HINT =
|
|
15
|
+
'In Jira Cloud the assignee must be an "Assignable user" in the project: Project → Space settings → People.';
|
|
16
|
+
|
|
17
|
+
function getAssigneeAccountId(config) {
|
|
18
|
+
const fromConfig = (config?.jira?.assigneeAccountId || '').trim();
|
|
19
|
+
if (fromConfig) return fromConfig;
|
|
20
|
+
return (process.env.JIRA_ACCOUNT_ID || '').trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getMyselfAccountId(baseUrl, auth) {
|
|
24
|
+
const res = await fetch(`${baseUrl}/rest/api/3/myself`, {
|
|
25
|
+
headers: { Accept: 'application/json', Authorization: `Basic ${auth}` },
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
throw new Error(`Jira myself API ${res.status}: ${text || res.statusText}`);
|
|
30
|
+
}
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
const id = data?.accountId;
|
|
33
|
+
if (!id) throw new Error('Jira myself response missing accountId.');
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function verifyAssignee(baseUrl, issueKey, auth) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(
|
|
40
|
+
`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=assignee`,
|
|
41
|
+
{ headers: { Accept: 'application/json', Authorization: `Basic ${auth}` } }
|
|
42
|
+
);
|
|
43
|
+
if (!res.ok) return false;
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
return !!data?.fields?.assignee?.accountId;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
24
51
|
async function assignIssue(baseUrl, issueKey, accountId, auth) {
|
|
52
|
+
const id = (accountId || '').trim();
|
|
53
|
+
if (!id) {
|
|
54
|
+
return { ok: false, message: 'No assignee accountId. Set JIRA_ACCOUNT_ID in .env (full format: "712020:uuid").' };
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
const headers = {
|
|
26
58
|
'Content-Type': 'application/json',
|
|
27
59
|
Accept: 'application/json',
|
|
28
60
|
Authorization: `Basic ${auth}`,
|
|
29
61
|
};
|
|
30
62
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
63
|
+
const assignUrl = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/assignee`;
|
|
64
|
+
let res = await fetch(assignUrl, {
|
|
65
|
+
method: 'PUT',
|
|
66
|
+
headers,
|
|
67
|
+
body: JSON.stringify({ accountId: id }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
const verified = await verifyAssignee(baseUrl, issueKey, auth);
|
|
72
|
+
if (verified) return { ok: true };
|
|
73
|
+
return { ok: false, message: `Assign API returned success but issue is still unassigned. ${ASSIGNABLE_HINT}` };
|
|
34
74
|
}
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!id?.trim()) continue;
|
|
76
|
+
const body1 = await res.text();
|
|
77
|
+
let lastError = `assignee endpoint ${res.status}: ${body1 || res.statusText}`;
|
|
39
78
|
|
|
40
|
-
|
|
41
|
-
|
|
79
|
+
if (res.status === 400 || res.status === 404) {
|
|
80
|
+
res = await fetch(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}`, {
|
|
42
81
|
method: 'PUT',
|
|
43
82
|
headers,
|
|
44
|
-
body: JSON.stringify({ accountId: id }),
|
|
83
|
+
body: JSON.stringify({ fields: { assignee: { accountId: id } } }),
|
|
45
84
|
});
|
|
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}`;
|
|
85
|
+
if (res.ok) {
|
|
86
|
+
const verified = await verifyAssignee(baseUrl, issueKey, auth);
|
|
87
|
+
if (verified) return { ok: true };
|
|
88
|
+
return { ok: false, message: `Assign API returned success but issue is still unassigned. ${ASSIGNABLE_HINT}` };
|
|
60
89
|
}
|
|
90
|
+
const body2 = await res.text();
|
|
91
|
+
lastError += `; issue PUT ${res.status}: ${body2 || res.statusText}`;
|
|
61
92
|
}
|
|
62
93
|
|
|
63
94
|
return {
|
|
64
95
|
ok: false,
|
|
65
|
-
message: `Assign failed. ${lastError} Use JIRA_ACCOUNT_ID
|
|
96
|
+
message: `Assign failed. ${lastError} ${ASSIGNABLE_HINT} Use JIRA_ACCOUNT_ID (full format with colon) in .env.`,
|
|
66
97
|
};
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
/**
|
|
70
|
-
*
|
|
71
|
-
* .
|
|
101
|
+
* Transition issue to the given status (e.g. Done, To Do, In Progress).
|
|
102
|
+
* Finds transition by destination status name (case-insensitive). No-op if status empty or not found.
|
|
72
103
|
*/
|
|
73
|
-
function
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
async function transitionToStatus(baseUrl, issueKey, auth, statusName) {
|
|
105
|
+
const name = (statusName || '').trim();
|
|
106
|
+
if (!name) return;
|
|
107
|
+
|
|
108
|
+
const headers = {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
Accept: 'application/json',
|
|
111
|
+
Authorization: `Basic ${auth}`,
|
|
112
|
+
};
|
|
113
|
+
const transRes = await fetch(
|
|
114
|
+
`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`,
|
|
115
|
+
{ headers: { Accept: 'application/json', Authorization: `Basic ${auth}` } }
|
|
116
|
+
);
|
|
117
|
+
if (!transRes.ok) return;
|
|
118
|
+
const transData = await transRes.json();
|
|
119
|
+
const transitions = transData?.transitions || [];
|
|
120
|
+
const target = name.toLowerCase();
|
|
121
|
+
const transition = transitions.find(
|
|
122
|
+
(t) => (t.to?.name || '').toLowerCase() === target || (t.name || '').toLowerCase() === target
|
|
123
|
+
);
|
|
124
|
+
if (!transition?.id) return;
|
|
125
|
+
await fetch(`${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/transitions`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers,
|
|
128
|
+
body: JSON.stringify({ transition: { id: transition.id } }),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const VALID_PRIORITIES = ['Highest', 'High', 'Medium', 'Low', 'Lowest'];
|
|
133
|
+
|
|
134
|
+
function normalizePriority(value) {
|
|
135
|
+
const v = (value || '').trim();
|
|
136
|
+
if (!v) return 'Medium';
|
|
137
|
+
const cap = v.charAt(0).toUpperCase() + v.slice(1).toLowerCase();
|
|
138
|
+
if (VALID_PRIORITIES.includes(cap)) return cap;
|
|
139
|
+
if (['High', 'Medium', 'Low'].includes(cap)) return cap;
|
|
140
|
+
return 'Medium';
|
|
78
141
|
}
|
|
79
142
|
|
|
80
143
|
/**
|
|
81
|
-
* Create a Jira issue and optionally
|
|
144
|
+
* Create a Jira issue, assign, and optionally transition to a status.
|
|
145
|
+
* @param {object} payload - { title, description, labels, priority }
|
|
146
|
+
* @param {object} config - .haitaskrc (jira.baseUrl, projectKey, issueType, transitionToStatus)
|
|
82
147
|
*/
|
|
83
148
|
export async function createIssue(payload, config) {
|
|
84
149
|
const baseUrl = (config?.jira?.baseUrl || process.env.JIRA_BASE_URL || '').replace(/\/$/, '');
|
|
@@ -91,38 +156,67 @@ export async function createIssue(payload, config) {
|
|
|
91
156
|
|
|
92
157
|
const projectKey = config?.jira?.projectKey || 'PROJ';
|
|
93
158
|
const issueType = config?.jira?.issueType || 'Task';
|
|
94
|
-
const
|
|
159
|
+
const rawStatus = config?.jira?.transitionToStatus;
|
|
160
|
+
const transitionToStatusName = rawStatus === undefined || rawStatus === null ? 'Done' : String(rawStatus).trim();
|
|
161
|
+
const auth = Buffer.from(`${email}:${token}`, 'utf-8').toString('base64');
|
|
162
|
+
|
|
163
|
+
let assigneeAccountId = getAssigneeAccountId(config);
|
|
164
|
+
if (!assigneeAccountId) {
|
|
165
|
+
assigneeAccountId = await getMyselfAccountId(baseUrl, auth);
|
|
166
|
+
} else if (!assigneeAccountId.includes(':')) {
|
|
167
|
+
assigneeAccountId = await getMyselfAccountId(baseUrl, auth);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const priorityName = normalizePriority(payload.priority);
|
|
95
171
|
|
|
96
|
-
const
|
|
172
|
+
const baseFields = {
|
|
97
173
|
project: { key: projectKey },
|
|
98
174
|
summary: (payload.title || '').trim() || 'Untitled',
|
|
99
175
|
description: plainTextToAdf(payload.description || ''),
|
|
100
176
|
issuetype: { name: issueType },
|
|
101
177
|
labels: Array.isArray(payload.labels) ? payload.labels.filter((l) => typeof l === 'string') : [],
|
|
178
|
+
assignee: { accountId: assigneeAccountId },
|
|
102
179
|
};
|
|
103
180
|
|
|
104
|
-
|
|
105
|
-
|
|
181
|
+
let fields = { ...baseFields, priority: { name: priorityName } };
|
|
182
|
+
let createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
|
|
106
183
|
method: 'POST',
|
|
107
184
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Basic ${auth}` },
|
|
108
185
|
body: JSON.stringify({ fields }),
|
|
109
186
|
});
|
|
110
187
|
|
|
111
188
|
if (!createRes.ok) {
|
|
112
|
-
const
|
|
113
|
-
|
|
189
|
+
const firstErrorText = await createRes.text();
|
|
190
|
+
if (createRes.status === 400) {
|
|
191
|
+
const isPriority = /priority|Priority/i.test(firstErrorText);
|
|
192
|
+
const isAssignee = /assignee|Assignee/i.test(firstErrorText);
|
|
193
|
+
const retryFields = { ...baseFields };
|
|
194
|
+
if (isAssignee) delete retryFields.assignee;
|
|
195
|
+
if (isPriority) delete retryFields.priority;
|
|
196
|
+
if (isPriority || isAssignee) {
|
|
197
|
+
createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Basic ${auth}` },
|
|
200
|
+
body: JSON.stringify({ fields: retryFields }),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!createRes.ok) {
|
|
205
|
+
const errText = createRes.bodyUsed ? firstErrorText : await createRes.text();
|
|
206
|
+
throw new Error(`Jira API error ${createRes.status}: ${errText || createRes.statusText}`);
|
|
207
|
+
}
|
|
114
208
|
}
|
|
115
209
|
|
|
116
210
|
const data = await createRes.json();
|
|
117
211
|
const key = data?.key;
|
|
118
212
|
if (!key) throw new Error('Jira API response missing issue key.');
|
|
119
213
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
throw new Error(`Issue ${key} created but assign failed. ${assignResult.message || ''}`);
|
|
124
|
-
}
|
|
214
|
+
const assignResult = await assignIssue(baseUrl, key, assigneeAccountId, auth);
|
|
215
|
+
if (!assignResult.ok) {
|
|
216
|
+
throw new Error(`Issue ${key} created but assign failed. ${assignResult.message || ''}`);
|
|
125
217
|
}
|
|
126
218
|
|
|
219
|
+
await transitionToStatus(baseUrl, key, auth, transitionToStatusName);
|
|
220
|
+
|
|
127
221
|
return { key, self: data.self };
|
|
128
222
|
}
|