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 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.4",
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
@@ -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
- 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"]).`;
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/ wrong types
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
- return { title, description: obj.description.trim(), labels };
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
  }
@@ -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
  };
@@ -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.');
@@ -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',
@@ -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,17 @@ 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
+ 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.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();
@@ -1,11 +1,8 @@
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
- */
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
- * 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
- */
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 candidates = [accountId];
32
- if (accountId.includes(':')) {
33
- candidates.push(accountId.split(':').slice(-1)[0]);
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
- let lastError = '';
37
- for (const id of candidates) {
38
- if (!id?.trim()) continue;
76
+ const body1 = await res.text();
77
+ let lastError = `assignee endpoint ${res.status}: ${body1 || res.statusText}`;
39
78
 
40
- const assignUrl = `${baseUrl}/rest/api/3/issue/${encodeURIComponent(issueKey)}/assignee`;
41
- let res = await fetch(assignUrl, {
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) 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}`;
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 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="...".`,
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
- * Read assignee accountId: config first, then .env.
71
- * .env: use quotes if value contains colon, e.g. JIRA_ACCOUNT_ID="712020:ffdf70f7-..."
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 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;
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 assign to JIRA_ACCOUNT_ID.
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 assigneeAccountId = getAssigneeAccountId(config);
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 fields = {
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
- const auth = Buffer.from(`${email}:${token}`, 'utf-8').toString('base64');
105
- const createRes = await fetch(`${baseUrl}/rest/api/3/issue`, {
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 text = await createRes.text();
113
- throw new Error(`Jira API error ${createRes.status}: ${text || createRes.statusText}`);
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
- 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
- }
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
  }