@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,129 @@
1
+ import { createJiraClient, getIssueSearchText, getIssueStatusCategoryKey, getIssueSummary, type JiraIssue } from "./jira-client";
2
+
3
+ export type RankedIssue = {
4
+ issueKey: string;
5
+ score: number;
6
+ status: string;
7
+ statusCategory: string;
8
+ summary: string;
9
+ updated?: string;
10
+ url: string;
11
+ };
12
+
13
+ export type IssueSearchResult = {
14
+ closedMatches: RankedIssue[];
15
+ openMatches: RankedIssue[];
16
+ recommendedAction: "reuse-open" | "create-related-to-closed" | "create-new";
17
+ recommendedClosedIssueKey: string | null;
18
+ recommendedOpenIssueKey: string | null;
19
+ };
20
+
21
+ export function buildSearchJql(projectKey: string, searchText: string): string {
22
+ const tokens = tokenizeSearchText(searchText).slice(0, 8);
23
+ const queryTokens = tokens.length > 0 ? tokens : searchText.split(/\s+/).map((token) => token.trim()).filter(Boolean);
24
+
25
+ if (queryTokens.length === 0) {
26
+ throw new Error("Could not build a Jira query from empty search text.");
27
+ }
28
+
29
+ const tokenClauses = queryTokens.slice(0, 8).map((token) => `text ~ "${escapeJqlValue(token)}"`);
30
+ const query = tokenClauses.join(" OR ");
31
+ return `project = "${escapeJqlValue(projectKey)}" AND (${query}) ORDER BY updated DESC`;
32
+ }
33
+
34
+ export function tokenizeSearchText(searchText: string): string[] {
35
+ const stopWords = new Set([
36
+ "the",
37
+ "and",
38
+ "for",
39
+ "with",
40
+ "from",
41
+ "into",
42
+ "that",
43
+ "this",
44
+ "have",
45
+ "your",
46
+ "will",
47
+ "make",
48
+ "add",
49
+ "use",
50
+ ]);
51
+
52
+ const seen = new Set<string>();
53
+ const tokens: string[] = [];
54
+
55
+ for (const token of searchText.toLowerCase().split(/[^a-z0-9]+/)) {
56
+ if (token.length < 3 || stopWords.has(token) || seen.has(token)) {
57
+ continue;
58
+ }
59
+
60
+ seen.add(token);
61
+ tokens.push(token);
62
+ }
63
+
64
+ return tokens;
65
+ }
66
+
67
+ export function rankIssues(
68
+ issues: JiraIssue[],
69
+ searchText: string,
70
+ jira: Pick<ReturnType<typeof createJiraClient>, "getIssueBrowseUrl">,
71
+ ): RankedIssue[] {
72
+ const tokens = tokenizeSearchText(searchText);
73
+
74
+ return issues
75
+ .map((issue) => {
76
+ const haystack = getIssueSearchText(issue).toLowerCase();
77
+ const score = tokens.reduce((count, token) => count + (haystack.includes(token) ? 1 : 0), 0);
78
+ const statusCategory = getIssueStatusCategoryKey(issue) ?? "unknown";
79
+
80
+ return {
81
+ issueKey: issue.key,
82
+ score,
83
+ status: issue.fields.status?.name ?? "Unknown",
84
+ statusCategory,
85
+ summary: getIssueSummary(issue),
86
+ updated: issue.fields.updated,
87
+ url: jira.getIssueBrowseUrl(issue.key),
88
+ };
89
+ })
90
+ .sort((left, right) => {
91
+ if (right.score !== left.score) {
92
+ return right.score - left.score;
93
+ }
94
+
95
+ return (right.updated ?? "").localeCompare(left.updated ?? "");
96
+ });
97
+ }
98
+
99
+ export async function searchIssuesForText(input: {
100
+ jira: ReturnType<typeof createJiraClient>;
101
+ limit: number;
102
+ projectKey: string;
103
+ searchText: string;
104
+ }): Promise<IssueSearchResult> {
105
+ const { jira, limit, projectKey, searchText } = input;
106
+ const jql = buildSearchJql(projectKey, searchText);
107
+ const issues = await jira.searchIssues(jql, Math.max(limit * 3, 15), [
108
+ "summary",
109
+ "status",
110
+ "description",
111
+ "updated",
112
+ ]);
113
+
114
+ const ranked = rankIssues(issues, searchText, jira);
115
+ const openMatches = ranked.filter((issue) => issue.statusCategory !== "done").slice(0, limit);
116
+ const closedMatches = ranked.filter((issue) => issue.statusCategory === "done").slice(0, limit);
117
+
118
+ return {
119
+ closedMatches,
120
+ openMatches,
121
+ recommendedAction: openMatches.length > 0 ? "reuse-open" : closedMatches.length > 0 ? "create-related-to-closed" : "create-new",
122
+ recommendedClosedIssueKey: openMatches.length === 0 ? closedMatches[0]?.issueKey ?? null : null,
123
+ recommendedOpenIssueKey: openMatches[0]?.issueKey ?? null,
124
+ };
125
+ }
126
+
127
+ function escapeJqlValue(value: string): string {
128
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
129
+ }
@@ -0,0 +1,325 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { AdfDocument } from "./csv";
5
+ import { adfToPlainText } from "./csv";
6
+ import { readEnv } from "./config";
7
+
8
+ export type JiraCurrentUser = {
9
+ accountId?: string;
10
+ displayName?: string;
11
+ emailAddress?: string;
12
+ self?: string;
13
+ };
14
+
15
+ export type JiraIssueStatus = {
16
+ name: string;
17
+ statusCategory?: {
18
+ key?: string;
19
+ name?: string;
20
+ };
21
+ };
22
+
23
+ export type JiraIssue = {
24
+ key: string;
25
+ self?: string;
26
+ fields: {
27
+ description?: unknown;
28
+ issuetype?: { name?: string };
29
+ status?: JiraIssueStatus;
30
+ summary?: string;
31
+ updated?: string;
32
+ };
33
+ };
34
+
35
+ export type JiraProject = {
36
+ id: string;
37
+ key: string;
38
+ name: string;
39
+ projectTypeKey?: string;
40
+ simplified?: boolean;
41
+ style?: string;
42
+ };
43
+
44
+ type JiraCreateIssueResponse = {
45
+ id: string;
46
+ key: string;
47
+ };
48
+
49
+ type JiraErrorResponse = {
50
+ errorMessages?: string[];
51
+ errors?: Record<string, string>;
52
+ };
53
+
54
+ type JiraPermissionsResponse = {
55
+ permissions?: Record<
56
+ string,
57
+ {
58
+ havePermission: boolean;
59
+ id: string;
60
+ key: string;
61
+ name: string;
62
+ type: string;
63
+ }
64
+ >;
65
+ };
66
+
67
+ type JiraSearchResponse = {
68
+ issues?: JiraIssue[];
69
+ };
70
+
71
+ export type JiraAttachment = {
72
+ content?: string;
73
+ created?: string;
74
+ filename?: string;
75
+ id?: number | string;
76
+ mimeType?: string;
77
+ self?: string;
78
+ size?: number;
79
+ thumbnail?: string;
80
+ };
81
+
82
+ export function createJiraClient() {
83
+ const baseUrl = readEnv("JIRA_BASE_URL");
84
+
85
+ if (!baseUrl) {
86
+ throw new Error("Set JIRA_BASE_URL in .jira-dev-workflow/.env.local or in the environment before running jira-dev-workflow.");
87
+ }
88
+
89
+ const email = readEnv("JIRA_EMAIL");
90
+ const apiToken = readEnv("JIRA_API_TOKEN");
91
+
92
+ if (!email || !apiToken) {
93
+ throw new Error("Set JIRA_EMAIL and JIRA_API_TOKEN in .jira-dev-workflow/.env.local or in the environment before running jira-dev-workflow.");
94
+ }
95
+
96
+ const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
97
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
98
+
99
+ return {
100
+ addComment(issueKey: string, comment: AdfDocument) {
101
+ return jiraRequest<void>(normalizedBaseUrl, authHeader, `/rest/api/3/issue/${issueKey}/comment`, {
102
+ body: JSON.stringify({ body: comment }),
103
+ method: "POST",
104
+ });
105
+ },
106
+
107
+ addIssueLink(inwardIssueKey: string, outwardIssueKey: string, linkTypeName: string) {
108
+ return jiraRequest<void>(normalizedBaseUrl, authHeader, "/rest/api/3/issueLink", {
109
+ body: JSON.stringify({
110
+ inwardIssue: { key: inwardIssueKey },
111
+ outwardIssue: { key: outwardIssueKey },
112
+ type: { name: linkTypeName },
113
+ }),
114
+ method: "POST",
115
+ });
116
+ },
117
+
118
+ async attachFile(issueKey: string, filePath: string): Promise<JiraAttachment[]> {
119
+ const fileName = path.basename(filePath);
120
+ const bytes = await fs.readFile(filePath);
121
+ const formData = new FormData();
122
+ formData.append("file", new Blob([bytes]), fileName);
123
+
124
+ return fetch(`${normalizedBaseUrl}/rest/api/3/issue/${issueKey}/attachments`, {
125
+ body: formData,
126
+ headers: {
127
+ Accept: "application/json",
128
+ Authorization: authHeader,
129
+ "X-Atlassian-Token": "no-check",
130
+ },
131
+ method: "POST",
132
+ }).then(async (response) => {
133
+ const rawBody = await response.text();
134
+ const parsedBody = rawBody ? safeJsonParse(rawBody) : null;
135
+
136
+ if (!response.ok) {
137
+ const details = formatJiraError(parsedBody, rawBody);
138
+ throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ""}`);
139
+ }
140
+
141
+ return Array.isArray(parsedBody) ? (parsedBody as JiraAttachment[]) : [];
142
+ });
143
+ },
144
+
145
+ createIssue(input: {
146
+ description?: AdfDocument | null;
147
+ issueType: string;
148
+ labels?: string[];
149
+ projectKey: string;
150
+ summary: string;
151
+ }) {
152
+ const fields: Record<string, unknown> = {
153
+ issuetype: { name: input.issueType },
154
+ project: { key: input.projectKey },
155
+ summary: input.summary,
156
+ };
157
+
158
+ if (input.description) {
159
+ fields.description = input.description;
160
+ }
161
+
162
+ if (input.labels?.length) {
163
+ fields.labels = input.labels;
164
+ }
165
+
166
+ return jiraRequest<JiraCreateIssueResponse>(normalizedBaseUrl, authHeader, "/rest/api/3/issue", {
167
+ body: JSON.stringify({ fields }),
168
+ method: "POST",
169
+ });
170
+ },
171
+
172
+ getCurrentUser() {
173
+ return jiraRequest<JiraCurrentUser>(normalizedBaseUrl, authHeader, "/rest/api/3/myself", {
174
+ method: "GET",
175
+ });
176
+ },
177
+
178
+ getIssue(issueKey: string) {
179
+ return jiraRequest<JiraIssue>(normalizedBaseUrl, authHeader, `/rest/api/3/issue/${issueKey}`, {
180
+ method: "GET",
181
+ });
182
+ },
183
+
184
+ getIssueBrowseUrl(issueKey: string) {
185
+ return `${normalizedBaseUrl}/browse/${issueKey}`;
186
+ },
187
+
188
+ getMyPermissions(projectKey: string, permissionKeys: string[]) {
189
+ const searchParams = new URLSearchParams({
190
+ permissions: permissionKeys.join(","),
191
+ projectKey,
192
+ });
193
+
194
+ return jiraRequest<JiraPermissionsResponse>(
195
+ normalizedBaseUrl,
196
+ authHeader,
197
+ `/rest/api/3/mypermissions?${searchParams.toString()}`,
198
+ { method: "GET" },
199
+ );
200
+ },
201
+
202
+ getProject(projectKey: string) {
203
+ return jiraRequest<JiraProject>(
204
+ normalizedBaseUrl,
205
+ authHeader,
206
+ `/rest/api/3/project/${encodeURIComponent(projectKey)}`,
207
+ { method: "GET" },
208
+ );
209
+ },
210
+
211
+ async searchIssues(jql: string, maxResults: number, fields: string[]) {
212
+ const response = await jiraRequest<JiraSearchResponse>(normalizedBaseUrl, authHeader, "/rest/api/3/search/jql", {
213
+ body: JSON.stringify({
214
+ fields,
215
+ jql,
216
+ maxResults,
217
+ }),
218
+ method: "POST",
219
+ });
220
+
221
+ return response.issues ?? [];
222
+ },
223
+
224
+ async resolveMediaIdFromAttachmentContentUrl(contentUrl: string) {
225
+ const absoluteUrl = toAbsoluteUrl(normalizedBaseUrl, contentUrl);
226
+ const response = await fetch(absoluteUrl, {
227
+ headers: {
228
+ Accept: "*/*",
229
+ Authorization: authHeader,
230
+ },
231
+ method: "GET",
232
+ redirect: "manual",
233
+ });
234
+
235
+ const location = response.headers.get("location");
236
+
237
+ if (!location) {
238
+ throw new Error(`Could not resolve media ID from attachment redirect for ${contentUrl}. No Location header returned.`);
239
+ }
240
+
241
+ const mediaId = extractMediaIdFromLocation(location);
242
+
243
+ if (!mediaId) {
244
+ throw new Error(`Could not extract media ID from attachment redirect location: ${location}`);
245
+ }
246
+
247
+ return mediaId;
248
+ },
249
+ };
250
+ }
251
+
252
+ export function getIssueStatusCategoryKey(issue: JiraIssue): string | undefined {
253
+ return issue.fields.status?.statusCategory?.key;
254
+ }
255
+
256
+ export function getIssueSummary(issue: JiraIssue): string {
257
+ return issue.fields.summary ?? "";
258
+ }
259
+
260
+ export function getIssueSearchText(issue: JiraIssue): string {
261
+ return [issue.fields.summary ?? "", adfToPlainText(issue.fields.description)].join("\n");
262
+ }
263
+
264
+ async function jiraRequest<T>(
265
+ baseUrl: string,
266
+ authHeader: string,
267
+ apiPath: string,
268
+ init: RequestInit,
269
+ ): Promise<T> {
270
+ const response = await fetch(`${baseUrl}${apiPath}`, {
271
+ ...init,
272
+ headers: {
273
+ Accept: "application/json",
274
+ Authorization: authHeader,
275
+ "Content-Type": "application/json",
276
+ ...(init.headers ?? {}),
277
+ },
278
+ });
279
+
280
+ if (response.status === 204) {
281
+ return undefined as T;
282
+ }
283
+
284
+ const rawBody = await response.text();
285
+ const parsedBody = rawBody ? safeJsonParse(rawBody) : null;
286
+
287
+ if (!response.ok) {
288
+ const details = formatJiraError(parsedBody, rawBody);
289
+ throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ""}`);
290
+ }
291
+
292
+ return (parsedBody ?? undefined) as T;
293
+ }
294
+
295
+ function safeJsonParse(input: string): unknown {
296
+ try {
297
+ return JSON.parse(input);
298
+ } catch {
299
+ return input;
300
+ }
301
+ }
302
+
303
+ function formatJiraError(parsedBody: unknown, rawBody: string): string {
304
+ if (parsedBody && typeof parsedBody === "object") {
305
+ const body = parsedBody as JiraErrorResponse;
306
+ const messages = body.errorMessages ?? [];
307
+ const fieldErrors = Object.entries(body.errors ?? {}).map(([field, message]) => `${field}: ${message}`);
308
+ return [...messages, ...fieldErrors].join("; ");
309
+ }
310
+
311
+ return rawBody.trim();
312
+ }
313
+
314
+ function toAbsoluteUrl(baseUrl: string, maybeRelativeUrl: string): string {
315
+ if (/^https?:\/\//i.test(maybeRelativeUrl)) {
316
+ return maybeRelativeUrl;
317
+ }
318
+
319
+ return `${baseUrl}${maybeRelativeUrl.startsWith("/") ? "" : "/"}${maybeRelativeUrl}`;
320
+ }
321
+
322
+ function extractMediaIdFromLocation(location: string): string | null {
323
+ const match = location.match(/\/file\/([0-9a-fA-F-]{36})\//);
324
+ return match?.[1] ?? null;
325
+ }
@@ -0,0 +1,164 @@
1
+ import type { RankedIssue } from "./issue-search";
2
+
3
+ type DraftInput = {
4
+ changedFiles: string[];
5
+ openMatches: RankedIssue[];
6
+ patchHighlights: string[];
7
+ };
8
+
9
+ const genericTokens = new Set([
10
+ "app",
11
+ "apps",
12
+ "component",
13
+ "components",
14
+ "const",
15
+ "default",
16
+ "export",
17
+ "file",
18
+ "files",
19
+ "function",
20
+ "import",
21
+ "index",
22
+ "json",
23
+ "lib",
24
+ "node",
25
+ "page",
26
+ "path",
27
+ "return",
28
+ "route",
29
+ "src",
30
+ "test",
31
+ "tests",
32
+ "ts",
33
+ "tsx",
34
+ "type",
35
+ "types",
36
+ "update",
37
+ "use",
38
+ "utils",
39
+ "var",
40
+ ]);
41
+
42
+ const acronyms = new Map([
43
+ ["api", "API"],
44
+ ["cta", "CTA"],
45
+ ["html", "HTML"],
46
+ ["jira", "Jira"],
47
+ ["openai", "OpenAI"],
48
+ ["qa", "QA"],
49
+ ["ui", "UI"],
50
+ ]);
51
+
52
+ export function buildIssueSummaryDraft(input: DraftInput): string {
53
+ const areaPhrase = buildAreaPhrase(input.changedFiles, input.patchHighlights);
54
+
55
+ if (!areaPhrase) {
56
+ return input.changedFiles.length === 1
57
+ ? `Update ${humanizeFileLabel(input.changedFiles[0])}`
58
+ : "Update staged changes";
59
+ }
60
+
61
+ const topOpenMatch = input.openMatches[0];
62
+
63
+ if (topOpenMatch && topOpenMatch.score >= 2) {
64
+ return topOpenMatch.summary;
65
+ }
66
+
67
+ return `Update ${areaPhrase}`;
68
+ }
69
+
70
+ export function buildIssueDescriptionDraft(input: { changedFiles: string[]; patchHighlights: string[] }): string {
71
+ const lines: string[] = ["Prepared from the current diff.", "", "Changed files:"];
72
+
73
+ for (const filePath of input.changedFiles.slice(0, 12)) {
74
+ lines.push(`- ${filePath}`);
75
+ }
76
+
77
+ if (input.changedFiles.length > 12) {
78
+ lines.push(`- ...and ${input.changedFiles.length - 12} more files`);
79
+ }
80
+
81
+ if (input.patchHighlights.length > 0) {
82
+ lines.push("", "Patch highlights:");
83
+
84
+ for (const highlight of input.patchHighlights.slice(0, 12)) {
85
+ lines.push(`- ${truncateLine(highlight, 140)}`);
86
+ }
87
+ }
88
+
89
+ return lines.join("\n");
90
+ }
91
+
92
+ export function buildAreaPhrase(changedFiles: string[], patchHighlights: string[]): string {
93
+ const tokenWeights = new Map<string, number>();
94
+
95
+ for (const filePath of changedFiles) {
96
+ for (const token of tokenize(filePath)) {
97
+ tokenWeights.set(token, (tokenWeights.get(token) ?? 0) + 3);
98
+ }
99
+ }
100
+
101
+ for (const highlight of patchHighlights) {
102
+ for (const token of tokenize(highlight)) {
103
+ tokenWeights.set(token, (tokenWeights.get(token) ?? 0) + 1);
104
+ }
105
+ }
106
+
107
+ const tokens = [...tokenWeights.entries()]
108
+ .filter(([token]) => !genericTokens.has(token))
109
+ .sort((left, right) => {
110
+ if (right[1] !== left[1]) {
111
+ return right[1] - left[1];
112
+ }
113
+
114
+ return left[0].localeCompare(right[0]);
115
+ })
116
+ .map(([token]) => token)
117
+ .slice(0, 4);
118
+
119
+ if (tokens.length === 0) {
120
+ return "";
121
+ }
122
+
123
+ return tokens.map(formatToken).join(" ");
124
+ }
125
+
126
+ function tokenize(input: string): string[] {
127
+ const normalized = input
128
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
129
+ .toLowerCase();
130
+
131
+ return normalized
132
+ .split(/[^a-z0-9]+/)
133
+ .map((token) => token.trim())
134
+ .filter((token) => token.length >= 2);
135
+ }
136
+
137
+ function formatToken(token: string): string {
138
+ const acronym = acronyms.get(token);
139
+
140
+ if (acronym) {
141
+ return acronym;
142
+ }
143
+
144
+ return token;
145
+ }
146
+
147
+ function humanizeFileLabel(filePath?: string): string {
148
+ if (!filePath) {
149
+ return "staged file";
150
+ }
151
+
152
+ const segments = filePath.split(/[\\/]/).filter(Boolean);
153
+ const fileName = segments[segments.length - 1] ?? filePath;
154
+ const withoutExtension = fileName.replace(/\.[^.]+$/, "");
155
+ return withoutExtension.replace(/[-_]+/g, " ");
156
+ }
157
+
158
+ function truncateLine(text: string, maxLength: number): string {
159
+ if (text.length <= maxLength) {
160
+ return text;
161
+ }
162
+
163
+ return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
164
+ }