@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.
@@ -0,0 +1,137 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ export type ImageDimensions = {
4
+ height: number;
5
+ width: number;
6
+ };
7
+
8
+ export async function getImageDimensions(filePath: string): Promise<ImageDimensions> {
9
+ const buffer = await fs.readFile(filePath);
10
+ return getImageDimensionsFromBuffer(buffer);
11
+ }
12
+
13
+ export function getImageDimensionsFromBuffer(buffer: Buffer): ImageDimensions {
14
+ return parsePng(buffer) ?? parseGif(buffer) ?? parseJpeg(buffer) ?? parseWebp(buffer) ?? unsupported();
15
+ }
16
+
17
+ function parsePng(buffer: Buffer): ImageDimensions | null {
18
+ const pngSignature = "89504e470d0a1a0a";
19
+
20
+ if (buffer.length < 24 || buffer.subarray(0, 8).toString("hex") !== pngSignature) {
21
+ return null;
22
+ }
23
+
24
+ return {
25
+ width: buffer.readUInt32BE(16),
26
+ height: buffer.readUInt32BE(20),
27
+ };
28
+ }
29
+
30
+ function parseGif(buffer: Buffer): ImageDimensions | null {
31
+ const header = buffer.subarray(0, 6).toString("ascii");
32
+
33
+ if (buffer.length < 10 || (header !== "GIF87a" && header !== "GIF89a")) {
34
+ return null;
35
+ }
36
+
37
+ return {
38
+ width: buffer.readUInt16LE(6),
39
+ height: buffer.readUInt16LE(8),
40
+ };
41
+ }
42
+
43
+ function parseJpeg(buffer: Buffer): ImageDimensions | null {
44
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
45
+ return null;
46
+ }
47
+
48
+ let offset = 2;
49
+
50
+ while (offset + 9 < buffer.length) {
51
+ if (buffer[offset] !== 0xff) {
52
+ offset += 1;
53
+ continue;
54
+ }
55
+
56
+ const marker = buffer[offset + 1];
57
+ offset += 2;
58
+
59
+ if (marker === 0xd8 || marker === 0xd9) {
60
+ continue;
61
+ }
62
+
63
+ if (offset + 1 >= buffer.length) {
64
+ break;
65
+ }
66
+
67
+ const segmentLength = buffer.readUInt16BE(offset);
68
+
69
+ if (segmentLength < 2 || offset + segmentLength > buffer.length) {
70
+ break;
71
+ }
72
+
73
+ if (isSofMarker(marker)) {
74
+ return {
75
+ height: buffer.readUInt16BE(offset + 3),
76
+ width: buffer.readUInt16BE(offset + 5),
77
+ };
78
+ }
79
+
80
+ offset += segmentLength;
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function parseWebp(buffer: Buffer): ImageDimensions | null {
87
+ if (
88
+ buffer.length < 30 ||
89
+ buffer.subarray(0, 4).toString("ascii") !== "RIFF" ||
90
+ buffer.subarray(8, 12).toString("ascii") !== "WEBP"
91
+ ) {
92
+ return null;
93
+ }
94
+
95
+ const chunkType = buffer.subarray(12, 16).toString("ascii");
96
+
97
+ if (chunkType === "VP8X" && buffer.length >= 30) {
98
+ return {
99
+ width: 1 + readUInt24LE(buffer, 24),
100
+ height: 1 + readUInt24LE(buffer, 27),
101
+ };
102
+ }
103
+
104
+ if (chunkType === "VP8 " && buffer.length >= 30) {
105
+ return {
106
+ width: buffer.readUInt16LE(26) & 0x3fff,
107
+ height: buffer.readUInt16LE(28) & 0x3fff,
108
+ };
109
+ }
110
+
111
+ if (chunkType === "VP8L" && buffer.length >= 25) {
112
+ const bits = buffer.readUInt32LE(21);
113
+ return {
114
+ width: (bits & 0x3fff) + 1,
115
+ height: ((bits >> 14) & 0x3fff) + 1,
116
+ };
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ function readUInt24LE(buffer: Buffer, offset: number): number {
123
+ return buffer[offset]! | (buffer[offset + 1]! << 8) | (buffer[offset + 2]! << 16);
124
+ }
125
+
126
+ function isSofMarker(marker: number): boolean {
127
+ return (
128
+ (marker >= 0xc0 && marker <= 0xc3) ||
129
+ (marker >= 0xc5 && marker <= 0xc7) ||
130
+ (marker >= 0xc9 && marker <= 0xcb) ||
131
+ (marker >= 0xcd && marker <= 0xcf)
132
+ );
133
+ }
134
+
135
+ function unsupported(): never {
136
+ throw new Error("Unsupported image format for inline Jira media. Use PNG, JPEG, GIF, or WebP.");
137
+ }
@@ -0,0 +1,196 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { parseCsvRows, textToAdf } from "./csv";
5
+ import { loadToolEnv, loadWorkflowConfig, localToolDirName, readEnv } from "./config";
6
+ import { createJiraClient } from "./jira-client";
7
+
8
+ type CliOptions = {
9
+ configPath?: string;
10
+ continueOnError: boolean;
11
+ dryRun: boolean;
12
+ envFilePath?: string;
13
+ filePath: string;
14
+ issueTypeOverride?: string;
15
+ limit?: number;
16
+ projectKeyOverride?: string;
17
+ };
18
+
19
+ async function main() {
20
+ const options = parseArgs(process.argv.slice(2));
21
+ await loadToolEnv(options.envFilePath);
22
+ const config = await loadWorkflowConfig(options.configPath);
23
+ const absoluteCsvPath = path.resolve(options.filePath);
24
+ const csv = await fs.readFile(absoluteCsvPath, "utf8");
25
+ const rows = parseCsvRows(csv);
26
+ const selectedRows = typeof options.limit === "number" ? rows.slice(0, options.limit) : rows;
27
+
28
+ if (selectedRows.length === 0) {
29
+ console.log(`No rows found in ${absoluteCsvPath}.`);
30
+ return;
31
+ }
32
+
33
+ if (options.dryRun) {
34
+ for (const [index, row] of selectedRows.entries()) {
35
+ const projectKey = resolveProjectKey(row, options, config.projectKey);
36
+ const issueType = resolveIssueType(row, options, config.defaultIssueType);
37
+ const summary = requireValue(row, "Summary", index);
38
+ const comment = row.Comment?.trim();
39
+
40
+ console.log(`[dry-run] Row ${index + 2}: would create ${projectKey} ${issueType} "${summary}"`);
41
+
42
+ if (comment) {
43
+ console.log(`[dry-run] Row ${index + 2}: would add comment with ${comment.split("\n").length} line(s)`);
44
+ }
45
+ }
46
+
47
+ return;
48
+ }
49
+
50
+ const jira = createJiraClient();
51
+
52
+ for (const [index, row] of selectedRows.entries()) {
53
+ try {
54
+ const projectKey = resolveProjectKey(row, options, config.projectKey);
55
+ const issueType = resolveIssueType(row, options, config.defaultIssueType);
56
+ const summary = requireValue(row, "Summary", index);
57
+ const description = row.Description?.trim() ?? "";
58
+ const comment = row.Comment?.trim() ?? "";
59
+
60
+ const issue = await jira.createIssue({
61
+ description: textToAdf(description),
62
+ issueType,
63
+ projectKey,
64
+ summary,
65
+ });
66
+
67
+ console.log(`Created ${issue.key}: ${summary}`);
68
+
69
+ if (comment) {
70
+ const adfComment = textToAdf(comment);
71
+ if (adfComment) {
72
+ await jira.addComment(issue.key, adfComment);
73
+ console.log(`Added comment to ${issue.key}`);
74
+ }
75
+ }
76
+ } catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ console.error(`Failed to import row ${index + 2}: ${message}`);
79
+
80
+ if (!options.continueOnError) {
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ process.exitCode = 1;
86
+ }
87
+ }
88
+ }
89
+
90
+ function parseArgs(args: string[]): CliOptions {
91
+ const options: CliOptions = {
92
+ continueOnError: false,
93
+ dryRun: false,
94
+ filePath: path.resolve(process.cwd(), localToolDirName, "import.csv"),
95
+ };
96
+
97
+ for (let index = 0; index < args.length; index += 1) {
98
+ const arg = args[index];
99
+
100
+ if (!arg) {
101
+ continue;
102
+ }
103
+
104
+ if (arg === "--config") {
105
+ options.configPath = requireNextValue(args, ++index, "--config");
106
+ continue;
107
+ }
108
+
109
+ if (arg === "--continue-on-error") {
110
+ options.continueOnError = true;
111
+ continue;
112
+ }
113
+
114
+ if (arg === "--dry-run") {
115
+ options.dryRun = true;
116
+ continue;
117
+ }
118
+
119
+ if (arg === "--env-file") {
120
+ options.envFilePath = requireNextValue(args, ++index, "--env-file");
121
+ continue;
122
+ }
123
+
124
+ if (arg === "--file") {
125
+ options.filePath = requireNextValue(args, ++index, "--file");
126
+ continue;
127
+ }
128
+
129
+ if (arg === "--issue-type") {
130
+ options.issueTypeOverride = requireNextValue(args, ++index, "--issue-type");
131
+ continue;
132
+ }
133
+
134
+ if (arg === "--limit") {
135
+ options.limit = Number.parseInt(requireNextValue(args, ++index, "--limit"), 10);
136
+ continue;
137
+ }
138
+
139
+ if (arg === "--project-key") {
140
+ options.projectKeyOverride = requireNextValue(args, ++index, "--project-key");
141
+ continue;
142
+ }
143
+
144
+ if (arg.startsWith("--")) {
145
+ throw new Error(`Unknown argument: ${arg}`);
146
+ }
147
+
148
+ options.filePath = arg;
149
+ }
150
+
151
+ return options;
152
+ }
153
+
154
+ function resolveProjectKey(row: Record<string, string>, options: CliOptions, configProjectKey?: string): string {
155
+ return (
156
+ options.projectKeyOverride ??
157
+ row["Project Key"]?.trim() ??
158
+ configProjectKey ??
159
+ readEnv("JIRA_PROJECT_KEY") ??
160
+ missingField("Project Key")
161
+ );
162
+ }
163
+
164
+ function resolveIssueType(row: Record<string, string>, options: CliOptions, configDefaultIssueType: string): string {
165
+ return options.issueTypeOverride ?? row["Issue Type"]?.trim() ?? configDefaultIssueType;
166
+ }
167
+
168
+ function requireValue(row: Record<string, string>, fieldName: string, rowIndex: number): string {
169
+ const value = row[fieldName]?.trim();
170
+
171
+ if (!value) {
172
+ throw new Error(`Row ${rowIndex + 2} is missing required field "${fieldName}".`);
173
+ }
174
+
175
+ return value;
176
+ }
177
+
178
+ function missingField(fieldName: string): never {
179
+ throw new Error(`Missing required value for "${fieldName}". Provide it in the CSV or via config.`);
180
+ }
181
+
182
+ function requireNextValue(args: string[], index: number, flagName: string): string {
183
+ const value = args[index];
184
+
185
+ if (!value) {
186
+ throw new Error(`Missing value for ${flagName}.`);
187
+ }
188
+
189
+ return value;
190
+ }
191
+
192
+ void main().catch((error) => {
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ console.error(message);
195
+ process.exitCode = 1;
196
+ });
package/src/install.ts ADDED
@@ -0,0 +1,323 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import { workflowPackageDir } from "./config";
6
+
7
+ const publishedPackageName = "@greeana/jira-dev-workflow";
8
+ const localSkillDir = path.join(".codex", "skills", "jira-dev-workflow");
9
+ const localSkillFile = path.join(localSkillDir, "SKILL.md");
10
+ const localWorkflowDir = ".jira-dev-workflow";
11
+ const localConfigFile = path.join(localWorkflowDir, "config.json");
12
+ const localEnvFile = path.join(localWorkflowDir, ".env.local");
13
+ const localStateDir = path.join(localWorkflowDir, "state");
14
+ const gitignoreEntries = [".jira-dev-workflow/.env.local", ".jira-dev-workflow/state/"];
15
+
16
+ export type InstallOptions = {
17
+ force: boolean;
18
+ json: boolean;
19
+ local: boolean;
20
+ preview: boolean;
21
+ projectKey?: string;
22
+ version?: string;
23
+ };
24
+
25
+ export type InstallResult = {
26
+ created: string[];
27
+ overwritten: string[];
28
+ skipped: string[];
29
+ updated: string[];
30
+ warnings: string[];
31
+ };
32
+
33
+ async function main() {
34
+ const options = parseArgs(process.argv.slice(2));
35
+ const result = await installWorkflowRepo(process.cwd(), options);
36
+ const commandPrefix = resolveCommandPrefix(options);
37
+
38
+ if (options.json) {
39
+ console.log(JSON.stringify(result, null, 2));
40
+ return;
41
+ }
42
+
43
+ console.log(`Installed jira-dev-workflow in ${process.cwd()}`);
44
+ console.log(`Skill command reference: ${commandPrefix}`);
45
+
46
+ printSection("Created", result.created);
47
+ printSection("Overwritten", result.overwritten);
48
+ printSection("Updated", result.updated);
49
+ printSection("Skipped existing", result.skipped);
50
+ printSection("Warnings", result.warnings);
51
+
52
+ console.log("");
53
+ console.log("Edit these files before first use:");
54
+ console.log(`- ${localConfigFile} : set projectKey if JDW is not your Jira project key, and review workflow defaults.`);
55
+ console.log(`- ${localEnvFile} : set JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN.`);
56
+ console.log("");
57
+ console.log("Then run:");
58
+ console.log(`- \`${commandPrefix} test\``);
59
+ }
60
+
61
+ export async function installWorkflowRepo(
62
+ targetDir: string,
63
+ options: Pick<InstallOptions, "force" | "local" | "preview" | "projectKey" | "version">,
64
+ ): Promise<InstallResult> {
65
+ const result: InstallResult = {
66
+ created: [],
67
+ overwritten: [],
68
+ skipped: [],
69
+ updated: [],
70
+ warnings: [],
71
+ };
72
+ const skillTemplate = await loadSkillTemplate({
73
+ local: false,
74
+ preview: false,
75
+ version: undefined,
76
+ ...options,
77
+ });
78
+
79
+ await fs.mkdir(path.join(targetDir, localWorkflowDir), { recursive: true });
80
+ await fs.mkdir(path.join(targetDir, localStateDir), { recursive: true });
81
+ await fs.mkdir(path.join(targetDir, localSkillDir), { recursive: true });
82
+
83
+ await writeManagedFile(path.join(targetDir, localConfigFile), buildInitialConfig(options.projectKey), options.force, targetDir, result);
84
+ await writeManagedFile(path.join(targetDir, localEnvFile), buildEnvTemplate(), options.force, targetDir, result);
85
+ await writeManagedFile(path.join(targetDir, localSkillFile), skillTemplate, options.force, targetDir, result);
86
+ await ensureGitignoreEntries(targetDir, result);
87
+
88
+ if (!options.projectKey) {
89
+ result.warnings.push(`Using example project key JDW in ${localConfigFile}. Change it if your Jira project uses a different key.`);
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ export function buildInitialConfig(projectKey?: string): string {
96
+ const config = {
97
+ projectKey: projectKey?.trim() || "JDW",
98
+ defaultIssueType: "Task",
99
+ issueLinkType: "Relates",
100
+ commitMessageFormat: "{issueKey} {message}",
101
+ searchLimit: 5,
102
+ reuseClosedAsRelatedLink: true,
103
+ stateFile: "state/current-issue.json",
104
+ };
105
+
106
+ return `${JSON.stringify(config, null, 2)}\n`;
107
+ }
108
+
109
+ export function buildEnvTemplate(): string {
110
+ return [
111
+ "JIRA_BASE_URL=https://your-company.atlassian.net",
112
+ "JIRA_EMAIL=your.name@company.com",
113
+ "JIRA_API_TOKEN=your_jira_api_token",
114
+ "",
115
+ ].join("\n");
116
+ }
117
+
118
+ export function resolvePackageSpecifier(options: Pick<InstallOptions, "preview" | "version">): string {
119
+ if (options.version) {
120
+ return `${publishedPackageName}@${options.version.trim()}`;
121
+ }
122
+
123
+ if (options.preview) {
124
+ return `${publishedPackageName}@preview`;
125
+ }
126
+
127
+ return publishedPackageName;
128
+ }
129
+
130
+ export function resolveCommandPrefix(options: Pick<InstallOptions, "local" | "preview" | "version">): string {
131
+ validateSourceSelection(options);
132
+
133
+ if (options.local) {
134
+ return `node ${shellQuote(path.join(workflowPackageDir, "bin", "jira-dev-workflow.mjs"))}`;
135
+ }
136
+
137
+ return `npx -y ${resolvePackageSpecifier(options)}`;
138
+ }
139
+
140
+ export async function loadSkillTemplate(options: Pick<InstallOptions, "local" | "preview" | "version">): Promise<string> {
141
+ const skillTemplatePath = path.join(workflowPackageDir, "skill", "jira-dev-workflow", "SKILL.md");
142
+ const skillTemplate = await fs.readFile(skillTemplatePath, "utf8");
143
+ return skillTemplate.replaceAll(`npx -y ${publishedPackageName}`, resolveCommandPrefix(options));
144
+ }
145
+
146
+ export function mergeGitignore(existingContent: string): { addedEntries: string[]; content: string } {
147
+ const trimmed = existingContent.trimEnd();
148
+ const lines = trimmed ? trimmed.split(/\r?\n/) : [];
149
+ const seen = new Set(lines);
150
+ const addedEntries: string[] = [];
151
+
152
+ for (const entry of gitignoreEntries) {
153
+ if (seen.has(entry)) {
154
+ continue;
155
+ }
156
+
157
+ lines.push(entry);
158
+ seen.add(entry);
159
+ addedEntries.push(entry);
160
+ }
161
+
162
+ return {
163
+ addedEntries,
164
+ content: lines.length > 0 ? `${lines.join("\n")}\n` : "",
165
+ };
166
+ }
167
+
168
+ async function ensureGitignoreEntries(targetDir: string, result: InstallResult) {
169
+ const gitignorePath = path.join(targetDir, ".gitignore");
170
+ let existingContent = "";
171
+ let existed = true;
172
+
173
+ try {
174
+ existingContent = await fs.readFile(gitignorePath, "utf8");
175
+ } catch (error) {
176
+ if (!isFileMissingError(error)) {
177
+ throw error;
178
+ }
179
+
180
+ existed = false;
181
+ }
182
+
183
+ const merged = mergeGitignore(existingContent);
184
+
185
+ if (merged.addedEntries.length === 0 && existed) {
186
+ return;
187
+ }
188
+
189
+ await fs.writeFile(gitignorePath, merged.content, "utf8");
190
+
191
+ if (existed) {
192
+ result.updated.push(relativeDisplayPath(targetDir, gitignorePath));
193
+ return;
194
+ }
195
+
196
+ result.created.push(relativeDisplayPath(targetDir, gitignorePath));
197
+ }
198
+
199
+ async function writeManagedFile(filePath: string, content: string, force: boolean, targetDir: string, result: InstallResult) {
200
+ try {
201
+ await fs.readFile(filePath, "utf8");
202
+
203
+ if (!force) {
204
+ result.skipped.push(relativeDisplayPath(targetDir, filePath));
205
+ return;
206
+ }
207
+
208
+ await fs.writeFile(filePath, content, "utf8");
209
+ result.overwritten.push(relativeDisplayPath(targetDir, filePath));
210
+ } catch (error) {
211
+ if (!isFileMissingError(error)) {
212
+ throw error;
213
+ }
214
+
215
+ await fs.writeFile(filePath, content, "utf8");
216
+ result.created.push(relativeDisplayPath(targetDir, filePath));
217
+ }
218
+ }
219
+
220
+ function parseArgs(args: string[]): InstallOptions {
221
+ const options: InstallOptions = {
222
+ force: false,
223
+ json: false,
224
+ local: false,
225
+ preview: false,
226
+ };
227
+
228
+ for (let index = 0; index < args.length; index += 1) {
229
+ const arg = args[index];
230
+
231
+ if (!arg) {
232
+ continue;
233
+ }
234
+
235
+ if (arg === "--force") {
236
+ options.force = true;
237
+ continue;
238
+ }
239
+
240
+ if (arg === "--json") {
241
+ options.json = true;
242
+ continue;
243
+ }
244
+
245
+ if (arg === "--project-key") {
246
+ options.projectKey = requireNextValue(args, ++index, "--project-key");
247
+ continue;
248
+ }
249
+
250
+ if (arg === "--local") {
251
+ options.local = true;
252
+ continue;
253
+ }
254
+
255
+ if (arg === "--preview") {
256
+ options.preview = true;
257
+ continue;
258
+ }
259
+
260
+ if (arg === "--version") {
261
+ options.version = requireNextValue(args, ++index, "--version");
262
+ continue;
263
+ }
264
+
265
+ throw new Error(`Unknown argument: ${arg}`);
266
+ }
267
+
268
+ validateSourceSelection(options);
269
+
270
+ return options;
271
+ }
272
+
273
+ function printSection(title: string, values: string[]) {
274
+ if (values.length === 0) {
275
+ return;
276
+ }
277
+
278
+ console.log("");
279
+ console.log(`${title}:`);
280
+
281
+ for (const value of values) {
282
+ console.log(`- ${value}`);
283
+ }
284
+ }
285
+
286
+ function relativeDisplayPath(targetDir: string, filePath: string): string {
287
+ const relativePath = path.relative(targetDir, filePath) || ".";
288
+ return relativePath.split(path.sep).join("/");
289
+ }
290
+
291
+ function validateSourceSelection(options: Pick<InstallOptions, "local" | "preview" | "version">) {
292
+ const selectedModes = [options.local, options.preview, Boolean(options.version)].filter(Boolean).length;
293
+
294
+ if (selectedModes > 1) {
295
+ throw new Error("Pass at most one of --local, --preview, or --version.");
296
+ }
297
+ }
298
+
299
+ function requireNextValue(args: string[], index: number, flagName: string): string {
300
+ const value = args[index];
301
+
302
+ if (!value) {
303
+ throw new Error(`Missing value for ${flagName}.`);
304
+ }
305
+
306
+ return value;
307
+ }
308
+
309
+ function isFileMissingError(error: unknown): error is NodeJS.ErrnoException {
310
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
311
+ }
312
+
313
+ function shellQuote(value: string): string {
314
+ return `'${value.replaceAll("'", `'\\''`)}'`;
315
+ }
316
+
317
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
318
+ void main().catch((error) => {
319
+ const message = error instanceof Error ? error.message : String(error);
320
+ console.error(message);
321
+ process.exitCode = 1;
322
+ });
323
+ }