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