@greeana/jira-dev-workflow 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/README.md +293 -0
- package/bin/jira-dev-workflow.mjs +223 -0
- package/examples/.jira-dev-workflow/.env.example +3 -0
- package/examples/.jira-dev-workflow/config.json +9 -0
- package/examples/.jira-dev-workflow/import.csv +3 -0
- package/package.json +53 -0
- package/skill/jira-dev-workflow/SKILL.md +113 -0
- package/src/add-comment.ts +177 -0
- package/src/attach-file.ts +86 -0
- package/src/comment-adf.ts +151 -0
- package/src/commit-message.ts +10 -0
- package/src/commit-with-issue.ts +90 -0
- package/src/config.ts +184 -0
- package/src/create-issue.ts +191 -0
- package/src/csv.ts +273 -0
- package/src/find-issue.ts +172 -0
- package/src/git.ts +59 -0
- package/src/image-dimensions.ts +137 -0
- package/src/import-issues.ts +196 -0
- package/src/install.ts +323 -0
- package/src/issue-search.ts +129 -0
- package/src/jira-client.ts +325 -0
- package/src/prepare-commit-draft.ts +164 -0
- package/src/prepare-commit.ts +197 -0
- package/src/show-current-issue.ts +66 -0
- package/src/state.ts +36 -0
- package/src/test-connection.ts +107 -0
- package/src/use-issue.ts +84 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { loadToolEnv, loadWorkflowConfig } from "./config";
|
|
5
|
+
import { createJiraClient } from "./jira-client";
|
|
6
|
+
import { loadCurrentIssueState } from "./state";
|
|
7
|
+
import { buildCommentAdf, type InlineMediaItem } from "./comment-adf";
|
|
8
|
+
import { getImageDimensions } from "./image-dimensions";
|
|
9
|
+
|
|
10
|
+
type CliOptions = {
|
|
11
|
+
configPath?: string;
|
|
12
|
+
envFilePath?: string;
|
|
13
|
+
filePaths: string[];
|
|
14
|
+
inlineFilePaths: string[];
|
|
15
|
+
issueKey?: string;
|
|
16
|
+
json: boolean;
|
|
17
|
+
text?: string;
|
|
18
|
+
textFilePath?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const options = parseArgs(process.argv.slice(2));
|
|
23
|
+
await loadToolEnv(options.envFilePath);
|
|
24
|
+
const config = await loadWorkflowConfig(options.configPath);
|
|
25
|
+
const jira = createJiraClient();
|
|
26
|
+
const currentIssue = await loadCurrentIssueState(config);
|
|
27
|
+
const issueKey = options.issueKey ?? currentIssue?.issueKey;
|
|
28
|
+
|
|
29
|
+
if (!issueKey) {
|
|
30
|
+
throw new Error("Pass --issue or set the current issue first with jira-dev-workflow use.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const baseText = await resolveCommentText(options);
|
|
34
|
+
const inlineMedia = await uploadInlineMedia(jira, issueKey, options.inlineFilePaths);
|
|
35
|
+
const comment = buildCommentAdf({
|
|
36
|
+
attachmentFileNames: options.filePaths,
|
|
37
|
+
inlineMedia,
|
|
38
|
+
text: baseText,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!comment) {
|
|
42
|
+
throw new Error("Pass --text, --text-file, --file, or --inline-file.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const filePath of options.filePaths) {
|
|
46
|
+
await jira.attachFile(issueKey, filePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await jira.addComment(issueKey, comment);
|
|
50
|
+
|
|
51
|
+
const response = {
|
|
52
|
+
attachedFiles: options.filePaths,
|
|
53
|
+
inlineFiles: options.inlineFilePaths,
|
|
54
|
+
issueKey,
|
|
55
|
+
text: baseText,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (options.json) {
|
|
59
|
+
console.log(JSON.stringify(response, null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`Added comment to ${issueKey}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolveCommentText(options: CliOptions): Promise<string> {
|
|
67
|
+
if (options.textFilePath) {
|
|
68
|
+
return fs.readFile(options.textFilePath, "utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return options.text ?? "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseArgs(args: string[]): CliOptions {
|
|
75
|
+
const options: CliOptions = {
|
|
76
|
+
filePaths: [],
|
|
77
|
+
inlineFilePaths: [],
|
|
78
|
+
json: false,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
82
|
+
const arg = args[index];
|
|
83
|
+
|
|
84
|
+
if (!arg) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (arg === "--config") {
|
|
89
|
+
options.configPath = requireNextValue(args, ++index, "--config");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (arg === "--env-file") {
|
|
94
|
+
options.envFilePath = requireNextValue(args, ++index, "--env-file");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (arg === "--issue") {
|
|
99
|
+
options.issueKey = requireNextValue(args, ++index, "--issue");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (arg === "--file") {
|
|
104
|
+
options.filePaths.push(requireNextValue(args, ++index, "--file"));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (arg === "--inline-file") {
|
|
109
|
+
options.inlineFilePaths.push(requireNextValue(args, ++index, "--inline-file"));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (arg === "--json") {
|
|
114
|
+
options.json = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (arg === "--text") {
|
|
119
|
+
options.text = requireNextValue(args, ++index, "--text");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (arg === "--text-file") {
|
|
124
|
+
options.textFilePath = requireNextValue(args, ++index, "--text-file");
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return options;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function uploadInlineMedia(
|
|
135
|
+
jira: ReturnType<typeof createJiraClient>,
|
|
136
|
+
issueKey: string,
|
|
137
|
+
inlineFilePaths: string[],
|
|
138
|
+
): Promise<InlineMediaItem[]> {
|
|
139
|
+
const inlineMedia: InlineMediaItem[] = [];
|
|
140
|
+
|
|
141
|
+
for (const filePath of inlineFilePaths) {
|
|
142
|
+
const uploaded = await jira.attachFile(issueKey, filePath);
|
|
143
|
+
const attachment = uploaded[0];
|
|
144
|
+
|
|
145
|
+
if (!attachment?.content) {
|
|
146
|
+
throw new Error(`Jira did not return an attachment content URL for inline file ${filePath}.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const mediaId = await jira.resolveMediaIdFromAttachmentContentUrl(attachment.content);
|
|
150
|
+
const dimensions = await getImageDimensions(filePath);
|
|
151
|
+
|
|
152
|
+
inlineMedia.push({
|
|
153
|
+
collection: "",
|
|
154
|
+
dimensions,
|
|
155
|
+
fileName: attachment.filename ?? path.basename(filePath),
|
|
156
|
+
mediaId,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return inlineMedia;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function requireNextValue(args: string[], index: number, flagName: string): string {
|
|
164
|
+
const value = args[index];
|
|
165
|
+
|
|
166
|
+
if (!value) {
|
|
167
|
+
throw new Error(`Missing value for ${flagName}.`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
void main().catch((error) => {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
+
console.error(message);
|
|
176
|
+
process.exitCode = 1;
|
|
177
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { loadToolEnv, loadWorkflowConfig } from "./config";
|
|
2
|
+
import { createJiraClient } from "./jira-client";
|
|
3
|
+
import { loadCurrentIssueState } from "./state";
|
|
4
|
+
|
|
5
|
+
type CliOptions = {
|
|
6
|
+
configPath?: string;
|
|
7
|
+
envFilePath?: string;
|
|
8
|
+
filePaths: string[];
|
|
9
|
+
issueKey?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
const options = parseArgs(process.argv.slice(2));
|
|
14
|
+
await loadToolEnv(options.envFilePath);
|
|
15
|
+
const config = await loadWorkflowConfig(options.configPath);
|
|
16
|
+
const jira = createJiraClient();
|
|
17
|
+
const currentIssue = await loadCurrentIssueState(config);
|
|
18
|
+
const issueKey = options.issueKey ?? currentIssue?.issueKey;
|
|
19
|
+
|
|
20
|
+
if (!issueKey) {
|
|
21
|
+
throw new Error("Pass --issue or set the current issue first with jira-dev-workflow use.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (options.filePaths.length === 0) {
|
|
25
|
+
throw new Error("Pass at least one --file.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const filePath of options.filePaths) {
|
|
29
|
+
await jira.attachFile(issueKey, filePath);
|
|
30
|
+
console.log(`Attached ${filePath} -> ${issueKey}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseArgs(args: string[]): CliOptions {
|
|
35
|
+
const options: CliOptions = {
|
|
36
|
+
filePaths: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
40
|
+
const arg = args[index];
|
|
41
|
+
|
|
42
|
+
if (!arg) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (arg === "--config") {
|
|
47
|
+
options.configPath = requireNextValue(args, ++index, "--config");
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg === "--env-file") {
|
|
52
|
+
options.envFilePath = requireNextValue(args, ++index, "--env-file");
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (arg === "--file") {
|
|
57
|
+
options.filePaths.push(requireNextValue(args, ++index, "--file"));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg === "--issue") {
|
|
62
|
+
options.issueKey = requireNextValue(args, ++index, "--issue");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requireNextValue(args: string[], index: number, flagName: string): string {
|
|
73
|
+
const value = args[index];
|
|
74
|
+
|
|
75
|
+
if (!value) {
|
|
76
|
+
throw new Error(`Missing value for ${flagName}.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
void main().catch((error) => {
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
console.error(message);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { textToAdf, type AdfDocument } from "./csv";
|
|
4
|
+
import type { ImageDimensions } from "./image-dimensions";
|
|
5
|
+
|
|
6
|
+
export type InlineMediaItem = {
|
|
7
|
+
collection?: string;
|
|
8
|
+
dimensions: ImageDimensions;
|
|
9
|
+
fileName: string;
|
|
10
|
+
mediaId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function buildCommentAdf(input: {
|
|
14
|
+
attachmentFileNames?: string[];
|
|
15
|
+
inlineMedia?: InlineMediaItem[];
|
|
16
|
+
text?: string;
|
|
17
|
+
}): AdfDocument | null {
|
|
18
|
+
const baseText = buildCommentBody(input.text ?? "", input.attachmentFileNames ?? []);
|
|
19
|
+
const inlineMedia = input.inlineMedia ?? [];
|
|
20
|
+
|
|
21
|
+
const content = buildCommentContent(baseText, inlineMedia);
|
|
22
|
+
|
|
23
|
+
if (content.length === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
version: 1,
|
|
29
|
+
type: "doc",
|
|
30
|
+
content,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildCommentBody(text: string, filePaths: string[]): string {
|
|
35
|
+
const normalizedText = text.trim();
|
|
36
|
+
const fileNames = filePaths.map((filePath) => path.basename(filePath)).filter(Boolean);
|
|
37
|
+
|
|
38
|
+
if (fileNames.length === 0) {
|
|
39
|
+
return normalizedText;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const attachmentSection = ["Attached files:", ...fileNames.map((fileName) => `- ${fileName}`)].join("\n");
|
|
43
|
+
|
|
44
|
+
if (!normalizedText) {
|
|
45
|
+
return attachmentSection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `${normalizedText}\n\n${attachmentSection}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildCommentContent(text: string, inlineMedia: InlineMediaItem[]) {
|
|
52
|
+
if (inlineMedia.length === 0) {
|
|
53
|
+
return textToAdf(text)?.content ?? [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const normalized = text.replace(/\r\n/g, "\n").trim();
|
|
57
|
+
|
|
58
|
+
if (!normalized) {
|
|
59
|
+
return inlineMedia.map((item) => inlineMediaToNode(item));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const usedIndexes = new Set<number>();
|
|
63
|
+
const blocks = normalized
|
|
64
|
+
.split(/\n{2,}/)
|
|
65
|
+
.map((block) => block.trim())
|
|
66
|
+
.filter((block) => block.length > 0);
|
|
67
|
+
const content: NonNullable<AdfDocument["content"]> = [];
|
|
68
|
+
let nextSequentialInlineIndex = 0;
|
|
69
|
+
|
|
70
|
+
blocks.forEach((block) => {
|
|
71
|
+
const inlineReference = parseInlineMediaPlaceholder(block);
|
|
72
|
+
|
|
73
|
+
if (!inlineReference) {
|
|
74
|
+
const blockDocument = textToAdf(block);
|
|
75
|
+
|
|
76
|
+
if (blockDocument?.content?.length) {
|
|
77
|
+
content.push(...blockDocument.content);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const inlineIndex = inlineReference.sequential ? nextSequentialInlineIndex++ : inlineReference.index;
|
|
84
|
+
|
|
85
|
+
if (inlineIndex >= 0 && inlineIndex < inlineMedia.length) {
|
|
86
|
+
content.push(inlineMediaToNode(inlineMedia[inlineIndex]));
|
|
87
|
+
usedIndexes.add(inlineIndex);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
content.push(...(textToAdf(block)?.content ?? []));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (usedIndexes.size === 0) {
|
|
95
|
+
return [...content, ...inlineMedia.map((item) => inlineMediaToNode(item))];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
inlineMedia.forEach((item, index) => {
|
|
99
|
+
if (!usedIndexes.has(index)) {
|
|
100
|
+
content.push(inlineMediaToNode(item));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return content;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseInlineMediaPlaceholder(block: string): { index: number; sequential: boolean } | null {
|
|
108
|
+
const match = block.match(/^\[\[inline-file(?::(\d+))?\]\]$/);
|
|
109
|
+
|
|
110
|
+
if (!match) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rawIndex = match[1];
|
|
115
|
+
|
|
116
|
+
if (!rawIndex) {
|
|
117
|
+
return { index: 0, sequential: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const parsedIndex = Number.parseInt(rawIndex, 10);
|
|
121
|
+
|
|
122
|
+
if (!Number.isFinite(parsedIndex) || parsedIndex <= 0) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { index: parsedIndex - 1, sequential: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function inlineMediaToNode(item: InlineMediaItem) {
|
|
130
|
+
return {
|
|
131
|
+
type: "mediaSingle" as const,
|
|
132
|
+
attrs: {
|
|
133
|
+
layout: "center" as const,
|
|
134
|
+
width: 100,
|
|
135
|
+
widthType: "percentage" as const,
|
|
136
|
+
},
|
|
137
|
+
content: [
|
|
138
|
+
{
|
|
139
|
+
type: "media" as const,
|
|
140
|
+
attrs: {
|
|
141
|
+
alt: item.fileName,
|
|
142
|
+
collection: item.collection ?? "",
|
|
143
|
+
height: item.dimensions.height,
|
|
144
|
+
id: item.mediaId,
|
|
145
|
+
type: "file" as const,
|
|
146
|
+
width: item.dimensions.width,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function formatCommitMessage(template: string, issueKey: string, message: string): string {
|
|
2
|
+
if (message.startsWith(`${issueKey} `) || message === issueKey) {
|
|
3
|
+
return message;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return template
|
|
7
|
+
.replaceAll("{issueKey}", issueKey)
|
|
8
|
+
.replaceAll("{message}", message)
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { commitWithMessage } from "./git";
|
|
2
|
+
import { loadWorkflowConfig } from "./config";
|
|
3
|
+
import { loadCurrentIssueState } from "./state";
|
|
4
|
+
import { formatCommitMessage } from "./commit-message";
|
|
5
|
+
|
|
6
|
+
type CliOptions = {
|
|
7
|
+
configPath?: string;
|
|
8
|
+
dryRun: boolean;
|
|
9
|
+
issueKey?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const options = parseArgs(process.argv.slice(2));
|
|
15
|
+
const config = await loadWorkflowConfig(options.configPath);
|
|
16
|
+
const currentIssue = await loadCurrentIssueState(config);
|
|
17
|
+
const issueKey = options.issueKey ?? currentIssue?.issueKey;
|
|
18
|
+
const message = options.message?.trim();
|
|
19
|
+
|
|
20
|
+
if (!issueKey) {
|
|
21
|
+
throw new Error("Pass --issue or set the current issue first with jira-dev-workflow use.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!message) {
|
|
25
|
+
throw new Error("Pass -m or --message.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const finalMessage = formatCommitMessage(config.commitMessageFormat, issueKey, message);
|
|
29
|
+
|
|
30
|
+
if (options.dryRun) {
|
|
31
|
+
console.log(finalMessage);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await commitWithMessage(finalMessage);
|
|
36
|
+
console.log(finalMessage);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseArgs(args: string[]): CliOptions {
|
|
40
|
+
const options: CliOptions = {
|
|
41
|
+
dryRun: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
45
|
+
const arg = args[index];
|
|
46
|
+
|
|
47
|
+
if (!arg) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (arg === "--config") {
|
|
52
|
+
options.configPath = requireNextValue(args, ++index, "--config");
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (arg === "--dry-run") {
|
|
57
|
+
options.dryRun = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg === "--issue") {
|
|
62
|
+
options.issueKey = requireNextValue(args, ++index, "--issue");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (arg === "-m" || arg === "--message") {
|
|
67
|
+
options.message = requireNextValue(args, ++index, arg);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return options;
|
|
75
|
+
}
|
|
76
|
+
function requireNextValue(args: string[], index: number, flagName: string): string {
|
|
77
|
+
const value = args[index];
|
|
78
|
+
|
|
79
|
+
if (!value) {
|
|
80
|
+
throw new Error(`Missing value for ${flagName}.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
void main().catch((error) => {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
console.error(message);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type WorkflowConfig = {
|
|
6
|
+
commitMessageFormat: string;
|
|
7
|
+
configBaseDir: string;
|
|
8
|
+
defaultIssueType: string;
|
|
9
|
+
issueLinkType: string;
|
|
10
|
+
projectKey?: string;
|
|
11
|
+
reuseClosedAsRelatedLink: boolean;
|
|
12
|
+
searchLimit: number;
|
|
13
|
+
stateFile: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const localToolDirName = ".jira-dev-workflow";
|
|
17
|
+
export const configFileName = "config.json";
|
|
18
|
+
|
|
19
|
+
const defaultConfig: WorkflowConfig = {
|
|
20
|
+
commitMessageFormat: "{issueKey} {message}",
|
|
21
|
+
configBaseDir: process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : process.cwd(),
|
|
22
|
+
defaultIssueType: "Task",
|
|
23
|
+
issueLinkType: "Relates",
|
|
24
|
+
reuseClosedAsRelatedLink: true,
|
|
25
|
+
searchLimit: 5,
|
|
26
|
+
stateFile: "state/current-issue.json",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const workflowPackageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
|
|
31
|
+
export async function loadWorkflowConfig(configPath?: string): Promise<WorkflowConfig> {
|
|
32
|
+
const resolvedConfigPath = configPath ? path.resolve(configPath) : await findConfigPath();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const content = await fs.readFile(resolvedConfigPath, "utf8");
|
|
36
|
+
const parsed = JSON.parse(content) as Partial<WorkflowConfig>;
|
|
37
|
+
const configBaseDir = path.dirname(resolvedConfigPath);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...defaultConfig,
|
|
41
|
+
configBaseDir,
|
|
42
|
+
...parsed,
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (isFileMissingError(error)) {
|
|
46
|
+
return { ...defaultConfig };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveStateFilePath(config: WorkflowConfig): string {
|
|
54
|
+
return path.isAbsolute(config.stateFile) ? config.stateFile : path.resolve(config.configBaseDir, config.stateFile);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function loadToolEnv(envFilePath?: string) {
|
|
58
|
+
const toolDir = envFilePath ? null : await findLocalToolDir();
|
|
59
|
+
const candidates = envFilePath
|
|
60
|
+
? [path.resolve(envFilePath)]
|
|
61
|
+
: [
|
|
62
|
+
path.join(toolDir ?? path.resolve(process.cwd(), localToolDirName), ".env.local"),
|
|
63
|
+
path.join(toolDir ?? path.resolve(process.cwd(), localToolDirName), ".env"),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const parsed = await readEnvFile(candidate);
|
|
68
|
+
|
|
69
|
+
if (!parsed) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
74
|
+
if (process.env[key] === undefined) {
|
|
75
|
+
process.env[key] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readEnv(name: string): string | undefined {
|
|
82
|
+
const value = process.env[name]?.trim();
|
|
83
|
+
return value ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readEnvFile(filePath: string): Promise<Record<string, string> | null> {
|
|
87
|
+
try {
|
|
88
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
89
|
+
return parseEnvFile(content);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (isFileMissingError(error)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
100
|
+
const values: Record<string, string> = {};
|
|
101
|
+
|
|
102
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
103
|
+
const line = rawLine.trim();
|
|
104
|
+
|
|
105
|
+
if (!line || line.startsWith("#")) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const separatorIndex = line.indexOf("=");
|
|
110
|
+
|
|
111
|
+
if (separatorIndex === -1) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
116
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
117
|
+
|
|
118
|
+
if (!key) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
values[key] = stripWrappingQuotes(rawValue);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return values;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stripWrappingQuotes(value: string): string {
|
|
129
|
+
if (value.length >= 2) {
|
|
130
|
+
const first = value[0];
|
|
131
|
+
const last = value[value.length - 1];
|
|
132
|
+
|
|
133
|
+
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
|
134
|
+
return value.slice(1, -1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isFileMissingError(error: unknown): error is NodeJS.ErrnoException {
|
|
142
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function findConfigPath(): Promise<string> {
|
|
146
|
+
const toolDir = await findLocalToolDir();
|
|
147
|
+
return toolDir
|
|
148
|
+
? path.join(toolDir, configFileName)
|
|
149
|
+
: path.resolve(process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : process.cwd(), localToolDirName, configFileName);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function findLocalToolDir(): Promise<string | null> {
|
|
153
|
+
const searchRoots = [process.cwd(), process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : ""].filter(Boolean);
|
|
154
|
+
|
|
155
|
+
for (const startDir of searchRoots) {
|
|
156
|
+
let currentDir = path.resolve(startDir);
|
|
157
|
+
|
|
158
|
+
while (true) {
|
|
159
|
+
const candidate = path.join(currentDir, localToolDirName);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const stat = await fs.stat(candidate);
|
|
163
|
+
|
|
164
|
+
if (stat.isDirectory()) {
|
|
165
|
+
return candidate;
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (!isFileMissingError(error)) {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const parentDir = path.dirname(currentDir);
|
|
174
|
+
|
|
175
|
+
if (parentDir === currentDir) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
currentDir = parentDir;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|