@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,197 @@
1
+ import { loadToolEnv, loadWorkflowConfig, readEnv } from "./config";
2
+ import { formatCommitMessage } from "./commit-message";
3
+ import { getDiffContext } from "./git";
4
+ import { searchIssuesForText } from "./issue-search";
5
+ import { createJiraClient } from "./jira-client";
6
+ import { buildIssueDescriptionDraft, buildIssueSummaryDraft } from "./prepare-commit-draft";
7
+
8
+ type CliOptions = {
9
+ configPath?: string;
10
+ envFilePath?: string;
11
+ json: boolean;
12
+ limit?: number;
13
+ projectKeyOverride?: string;
14
+ staged: boolean;
15
+ };
16
+
17
+ async function main() {
18
+ const options = parseArgs(process.argv.slice(2));
19
+ await loadToolEnv(options.envFilePath);
20
+ const config = await loadWorkflowConfig(options.configPath);
21
+ const projectKey = options.projectKeyOverride ?? config.projectKey ?? readEnv("JIRA_PROJECT_KEY");
22
+
23
+ if (!projectKey) {
24
+ throw new Error("Set projectKey in .jira-dev-workflow/config.json or pass --project-key.");
25
+ }
26
+
27
+ const diffContext = await getDiffContext({ staged: options.staged });
28
+ const jira = createJiraClient();
29
+ const searchLimit = options.limit ?? config.searchLimit;
30
+ const searchResult = await searchIssuesForText({
31
+ jira,
32
+ limit: searchLimit,
33
+ projectKey,
34
+ searchText: diffContext.combinedText,
35
+ });
36
+
37
+ const issueSummaryDraft = buildIssueSummaryDraft({
38
+ changedFiles: diffContext.changedFiles,
39
+ openMatches: searchResult.openMatches,
40
+ patchHighlights: diffContext.patchHighlights,
41
+ });
42
+ const issueDescriptionDraft = buildIssueDescriptionDraft({
43
+ changedFiles: diffContext.changedFiles,
44
+ patchHighlights: diffContext.patchHighlights,
45
+ });
46
+
47
+ const reuseOpenCommitMessageDraft = searchResult.recommendedOpenIssueKey
48
+ ? formatCommitMessage(
49
+ config.commitMessageFormat,
50
+ searchResult.recommendedOpenIssueKey,
51
+ searchResult.openMatches[0]?.summary || issueSummaryDraft,
52
+ )
53
+ : null;
54
+
55
+ const createNewCommitMessageDraft = formatCommitMessage(config.commitMessageFormat, "<new-issue-key>", issueSummaryDraft);
56
+
57
+ const response = {
58
+ changedFiles: diffContext.changedFiles,
59
+ closedMatches: searchResult.closedMatches,
60
+ commitMessageDrafts: {
61
+ createNew: createNewCommitMessageDraft,
62
+ reuseOpen: reuseOpenCommitMessageDraft,
63
+ },
64
+ issueDraft: {
65
+ description: issueDescriptionDraft,
66
+ relatedClosedIssueKey: searchResult.recommendedClosedIssueKey,
67
+ summary: issueSummaryDraft,
68
+ },
69
+ openMatches: searchResult.openMatches,
70
+ projectKey,
71
+ recommendedAction: searchResult.recommendedAction,
72
+ recommendedClosedIssueKey: searchResult.recommendedClosedIssueKey,
73
+ recommendedOpenIssueKey: searchResult.recommendedOpenIssueKey,
74
+ searchTextPreview: diffContext.combinedText.split(/\r?\n/).slice(0, 20),
75
+ staged: options.staged,
76
+ };
77
+
78
+ if (options.json) {
79
+ console.log(JSON.stringify(response, null, 2));
80
+ return;
81
+ }
82
+
83
+ console.log(`Prepared commit plan for project ${projectKey}.`);
84
+ console.log(`Mode: ${options.staged ? "staged changes" : "working tree changes"}`);
85
+ console.log("");
86
+ console.log("Changed files:");
87
+
88
+ for (const filePath of diffContext.changedFiles) {
89
+ console.log(`- ${filePath}`);
90
+ }
91
+
92
+ console.log("");
93
+ console.log(`Recommended action: ${response.recommendedAction}`);
94
+
95
+ if (response.openMatches.length > 0) {
96
+ console.log("");
97
+ console.log("Open issue candidates:");
98
+
99
+ for (const match of response.openMatches) {
100
+ console.log(`- ${match.issueKey} [score ${match.score}] ${match.summary}`);
101
+ }
102
+ }
103
+
104
+ if (response.closedMatches.length > 0) {
105
+ console.log("");
106
+ console.log("Closed related issues:");
107
+
108
+ for (const match of response.closedMatches) {
109
+ console.log(`- ${match.issueKey} [score ${match.score}] ${match.summary}`);
110
+ }
111
+ }
112
+
113
+ console.log("");
114
+ console.log("New issue draft:");
115
+ console.log(`Summary: ${issueSummaryDraft}`);
116
+ console.log("Description:");
117
+ console.log(issueDescriptionDraft);
118
+
119
+ console.log("");
120
+ console.log("Commit message previews:");
121
+
122
+ if (reuseOpenCommitMessageDraft) {
123
+ console.log(`- reuse ${searchResult.recommendedOpenIssueKey}: ${reuseOpenCommitMessageDraft}`);
124
+ }
125
+
126
+ console.log(`- create new: ${createNewCommitMessageDraft}`);
127
+ }
128
+
129
+ function parseArgs(args: string[]): CliOptions {
130
+ const options: CliOptions = {
131
+ json: false,
132
+ staged: true,
133
+ };
134
+
135
+ for (let index = 0; index < args.length; index += 1) {
136
+ const arg = args[index];
137
+
138
+ if (!arg) {
139
+ continue;
140
+ }
141
+
142
+ if (arg === "--config") {
143
+ options.configPath = requireNextValue(args, ++index, "--config");
144
+ continue;
145
+ }
146
+
147
+ if (arg === "--env-file") {
148
+ options.envFilePath = requireNextValue(args, ++index, "--env-file");
149
+ continue;
150
+ }
151
+
152
+ if (arg === "--json") {
153
+ options.json = true;
154
+ continue;
155
+ }
156
+
157
+ if (arg === "--limit") {
158
+ options.limit = Number.parseInt(requireNextValue(args, ++index, "--limit"), 10);
159
+ continue;
160
+ }
161
+
162
+ if (arg === "--project-key") {
163
+ options.projectKeyOverride = requireNextValue(args, ++index, "--project-key");
164
+ continue;
165
+ }
166
+
167
+ if (arg === "--working-tree") {
168
+ options.staged = false;
169
+ continue;
170
+ }
171
+
172
+ if (arg === "--staged") {
173
+ options.staged = true;
174
+ continue;
175
+ }
176
+
177
+ throw new Error(`Unknown argument: ${arg}`);
178
+ }
179
+
180
+ return options;
181
+ }
182
+
183
+ function requireNextValue(args: string[], index: number, flagName: string): string {
184
+ const value = args[index];
185
+
186
+ if (!value) {
187
+ throw new Error(`Missing value for ${flagName}.`);
188
+ }
189
+
190
+ return value;
191
+ }
192
+
193
+ void main().catch((error) => {
194
+ const message = error instanceof Error ? error.message : String(error);
195
+ console.error(message);
196
+ process.exitCode = 1;
197
+ });
@@ -0,0 +1,66 @@
1
+ import { loadWorkflowConfig } from "./config";
2
+ import { loadCurrentIssueState } from "./state";
3
+
4
+ type CliOptions = {
5
+ configPath?: string;
6
+ json: boolean;
7
+ };
8
+
9
+ async function main() {
10
+ const options = parseArgs(process.argv.slice(2));
11
+ const config = await loadWorkflowConfig(options.configPath);
12
+ const state = await loadCurrentIssueState(config);
13
+
14
+ if (!state) {
15
+ throw new Error("No current issue is stored.");
16
+ }
17
+
18
+ if (options.json) {
19
+ console.log(JSON.stringify(state, null, 2));
20
+ return;
21
+ }
22
+
23
+ console.log(`${state.issueKey}: ${state.summary ?? "(no summary stored)"}`);
24
+
25
+ if (state.url) {
26
+ console.log(state.url);
27
+ }
28
+ }
29
+
30
+ function parseArgs(args: string[]): CliOptions {
31
+ const options: CliOptions = { json: false };
32
+
33
+ for (let index = 0; index < args.length; index += 1) {
34
+ const arg = args[index];
35
+
36
+ if (arg === "--config") {
37
+ options.configPath = requireNextValue(args, ++index, "--config");
38
+ continue;
39
+ }
40
+
41
+ if (arg === "--json") {
42
+ options.json = true;
43
+ continue;
44
+ }
45
+
46
+ throw new Error(`Unknown argument: ${arg}`);
47
+ }
48
+
49
+ return options;
50
+ }
51
+
52
+ function requireNextValue(args: string[], index: number, flagName: string): string {
53
+ const value = args[index];
54
+
55
+ if (!value) {
56
+ throw new Error(`Missing value for ${flagName}.`);
57
+ }
58
+
59
+ return value;
60
+ }
61
+
62
+ void main().catch((error) => {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ console.error(message);
65
+ process.exitCode = 1;
66
+ });
package/src/state.ts ADDED
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { WorkflowConfig } from "./config";
5
+ import { resolveStateFilePath } from "./config";
6
+
7
+ export type CurrentIssueState = {
8
+ issueKey: string;
9
+ summary?: string;
10
+ url?: string;
11
+ };
12
+
13
+ export async function loadCurrentIssueState(config: WorkflowConfig): Promise<CurrentIssueState | null> {
14
+ const filePath = resolveStateFilePath(config);
15
+
16
+ try {
17
+ const content = await fs.readFile(filePath, "utf8");
18
+ return JSON.parse(content) as CurrentIssueState;
19
+ } catch (error) {
20
+ if (isFileMissingError(error)) {
21
+ return null;
22
+ }
23
+
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ export async function saveCurrentIssueState(config: WorkflowConfig, state: CurrentIssueState) {
29
+ const filePath = resolveStateFilePath(config);
30
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
31
+ await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
32
+ }
33
+
34
+ function isFileMissingError(error: unknown): error is NodeJS.ErrnoException {
35
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
36
+ }
@@ -0,0 +1,107 @@
1
+ import { loadToolEnv, loadWorkflowConfig, readEnv } from "./config";
2
+ import { createJiraClient } from "./jira-client";
3
+
4
+ type CliOptions = {
5
+ configPath?: string;
6
+ envFilePath?: string;
7
+ projectKeyOverride?: string;
8
+ };
9
+
10
+ async function main() {
11
+ const options = parseArgs(process.argv.slice(2));
12
+ await loadToolEnv(options.envFilePath);
13
+ const config = await loadWorkflowConfig(options.configPath);
14
+
15
+ const projectKey = options.projectKeyOverride ?? config.projectKey ?? readEnv("JIRA_PROJECT_KEY");
16
+
17
+ if (!projectKey) {
18
+ throw new Error("Set projectKey in .jira-dev-workflow/config.json or pass --project-key.");
19
+ }
20
+
21
+ const jira = createJiraClient();
22
+ const user = await jira.getCurrentUser();
23
+
24
+ console.log("Authenticated user:");
25
+ console.log(`- display name: ${user.displayName ?? "(unknown)"}`);
26
+ console.log(`- email: ${user.emailAddress ?? "(hidden by Atlassian privacy settings)"}`);
27
+ console.log(`- account id: ${user.accountId ?? "(unknown)"}`);
28
+
29
+ const project = await jira.getProject(projectKey);
30
+
31
+ console.log("");
32
+ console.log("Project access:");
33
+ console.log(`- key: ${project.key}`);
34
+ console.log(`- name: ${project.name}`);
35
+ console.log(`- id: ${project.id}`);
36
+ console.log(`- type: ${project.projectTypeKey ?? "(unknown)"}`);
37
+ console.log(`- style: ${project.style ?? "(unknown)"}`);
38
+
39
+ const permissions = await jira.getMyPermissions(project.key, ["BROWSE_PROJECTS", "CREATE_ISSUES"]);
40
+ const browseProjects = permissions.permissions?.BROWSE_PROJECTS?.havePermission ?? false;
41
+ const createIssues = permissions.permissions?.CREATE_ISSUES?.havePermission ?? false;
42
+
43
+ console.log("");
44
+ console.log("Project permissions:");
45
+ console.log(`- browse project: ${browseProjects ? "yes" : "no"}`);
46
+ console.log(`- create issues: ${createIssues ? "yes" : "no"}`);
47
+
48
+ if (!browseProjects || !createIssues) {
49
+ throw new Error(
50
+ `Jira connection works, but project permissions are insufficient for issue creation. browse=${browseProjects} createIssues=${createIssues}`,
51
+ );
52
+ }
53
+
54
+ console.log("");
55
+ console.log(`Connection OK. Ready to work in project ${project.key}.`);
56
+ }
57
+
58
+ function parseArgs(args: string[]): CliOptions {
59
+ const options: CliOptions = {};
60
+
61
+ for (let index = 0; index < args.length; index += 1) {
62
+ const arg = args[index];
63
+
64
+ if (!arg) {
65
+ continue;
66
+ }
67
+
68
+ if (arg === "--config") {
69
+ options.configPath = requireNextValue(args, ++index, "--config");
70
+ continue;
71
+ }
72
+
73
+ if (arg === "--env-file") {
74
+ options.envFilePath = requireNextValue(args, ++index, "--env-file");
75
+ continue;
76
+ }
77
+
78
+ if (arg === "--project-key") {
79
+ options.projectKeyOverride = requireNextValue(args, ++index, "--project-key");
80
+ continue;
81
+ }
82
+
83
+ if (arg.startsWith("--")) {
84
+ throw new Error(`Unknown argument: ${arg}`);
85
+ }
86
+
87
+ options.projectKeyOverride = arg;
88
+ }
89
+
90
+ return options;
91
+ }
92
+
93
+ function requireNextValue(args: string[], index: number, flagName: string): string {
94
+ const value = args[index];
95
+
96
+ if (!value) {
97
+ throw new Error(`Missing value for ${flagName}.`);
98
+ }
99
+
100
+ return value;
101
+ }
102
+
103
+ void main().catch((error) => {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ console.error(message);
106
+ process.exitCode = 1;
107
+ });
@@ -0,0 +1,84 @@
1
+ import { loadToolEnv, loadWorkflowConfig } from "./config";
2
+ import { createJiraClient, getIssueSummary } from "./jira-client";
3
+ import { saveCurrentIssueState } from "./state";
4
+
5
+ type CliOptions = {
6
+ configPath?: string;
7
+ envFilePath?: string;
8
+ issueKey?: string;
9
+ };
10
+
11
+ async function main() {
12
+ const options = parseArgs(process.argv.slice(2));
13
+ await loadToolEnv(options.envFilePath);
14
+ const config = await loadWorkflowConfig(options.configPath);
15
+ const issueKey = options.issueKey?.trim();
16
+
17
+ if (!issueKey) {
18
+ throw new Error("Pass --issue.");
19
+ }
20
+
21
+ const jira = createJiraClient();
22
+ const issue = await jira.getIssue(issueKey);
23
+ const summary = getIssueSummary(issue);
24
+ const url = jira.getIssueBrowseUrl(issueKey);
25
+
26
+ await saveCurrentIssueState(config, {
27
+ issueKey,
28
+ summary,
29
+ url,
30
+ });
31
+
32
+ console.log(`Selected current issue ${issueKey}: ${summary}`);
33
+ }
34
+
35
+ function parseArgs(args: string[]): CliOptions {
36
+ const options: CliOptions = {};
37
+
38
+ for (let index = 0; index < args.length; index += 1) {
39
+ const arg = args[index];
40
+
41
+ if (!arg) {
42
+ continue;
43
+ }
44
+
45
+ if (arg === "--config") {
46
+ options.configPath = requireNextValue(args, ++index, "--config");
47
+ continue;
48
+ }
49
+
50
+ if (arg === "--env-file") {
51
+ options.envFilePath = requireNextValue(args, ++index, "--env-file");
52
+ continue;
53
+ }
54
+
55
+ if (arg === "--issue") {
56
+ options.issueKey = requireNextValue(args, ++index, "--issue");
57
+ continue;
58
+ }
59
+
60
+ if (arg.startsWith("--")) {
61
+ throw new Error(`Unknown argument: ${arg}`);
62
+ }
63
+
64
+ options.issueKey = arg;
65
+ }
66
+
67
+ return options;
68
+ }
69
+
70
+ function requireNextValue(args: string[], index: number, flagName: string): string {
71
+ const value = args[index];
72
+
73
+ if (!value) {
74
+ throw new Error(`Missing value for ${flagName}.`);
75
+ }
76
+
77
+ return value;
78
+ }
79
+
80
+ void main().catch((error) => {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ console.error(message);
83
+ process.exitCode = 1;
84
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "types": [
10
+ "node"
11
+ ]
12
+ },
13
+ "include": [
14
+ "src/**/*.ts",
15
+ "tests/**/*.ts"
16
+ ]
17
+ }