jira-sprinter 1.0.1

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/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "jira-sprinter",
3
+ "version": "1.0.1",
4
+ "description": "Small CLI tool to manage sprints in JIRA Board",
5
+ "main": "src/main.ts",
6
+ "type": "commonjs",
7
+ "bin": "./dist/main.js",
8
+ "directories": {
9
+ "test": "tests"
10
+ },
11
+ "scripts": {
12
+ "build": "esbuild ./src/main.js --bundle --outdir=dist --platform=node --target=node20.0.0 --packages=bundle",
13
+ "format": "prettier --write '**/*.ts'",
14
+ "format-check": "prettier --check '**/*.ts'",
15
+ "test": "vitest run --coverage",
16
+ "update-snapshots": "vitest run --update",
17
+ "all": "yarn && yarn run build && yarn run format && yarn test"
18
+ },
19
+ "packageManager": "yarn@4.9.3",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/redhat-plumbers-in-action/sprinter.git"
23
+ },
24
+ "author": "jamacku@redhat.com",
25
+ "license": "GPL-3.0+",
26
+ "bugs": {
27
+ "url": "https://github.com/redhat-plumbers-in-action/sprinter/issues"
28
+ },
29
+ "homepage": "https://github.com/redhat-plumbers-in-action/sprinter#readme",
30
+ "keywords": [
31
+ "cli",
32
+ "jira",
33
+ "agile",
34
+ "scrum"
35
+ ],
36
+ "dependencies": {
37
+ "@inquirer/checkbox": "^4.3.0",
38
+ "@inquirer/select": "4.4.0",
39
+ "@total-typescript/ts-reset": "0.6.1",
40
+ "chalk": "5.6.2",
41
+ "commander": "14.0.1",
42
+ "dotenv": "17.2.3",
43
+ "jira.js": "5.2.2",
44
+ "zod": "4.1.12"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "24.7.2",
48
+ "@vitest/coverage-v8": "3.2.4",
49
+ "esbuild": "0.25.11",
50
+ "prettier": "3.6.2",
51
+ "ts-node": "10.9.2",
52
+ "typescript": "5.9.3",
53
+ "vitest": "3.2.4"
54
+ }
55
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,233 @@
1
+ import chalk from 'chalk';
2
+ import { Command } from 'commander';
3
+ import select, { Separator } from '@inquirer/select';
4
+ import checkbox from '@inquirer/checkbox';
5
+
6
+ import { Jira } from './jira';
7
+ import { Logger } from './logger';
8
+ import { getDefaultValue, getOptions, raise, tokenUnavailable } from './util';
9
+
10
+ import { SearchResults } from 'jira.js/dist/esm/types/agile/models';
11
+ import {
12
+ colorTaskSchema,
13
+ issueStatusSchema,
14
+ issueTypeSchema,
15
+ colorSizeSchema,
16
+ Size,
17
+ } from './schema/jira';
18
+ import { Issue } from 'jira.js/dist/esm/types/version2/models';
19
+
20
+ export function cli(): Command {
21
+ const program = new Command();
22
+
23
+ program
24
+ .name('jira-sprinter')
25
+ .description('🏃 Small CLI tool to manage sprints in JIRA Board')
26
+ .version('1.0.0');
27
+
28
+ program
29
+ .option('-b, --board [board]', 'Jira Board ID', getDefaultValue('BOARD'))
30
+ .option(
31
+ '-a, --assignee [assignee]',
32
+ 'Jira Assignee',
33
+ getDefaultValue('ASSIGNEE')
34
+ )
35
+ .option('-n, --nocolor', 'Disable color output', getDefaultValue('NOCOLOR'))
36
+ .option('-x, --dry', 'dry run', getDefaultValue('DRY'));
37
+
38
+ return program;
39
+ }
40
+
41
+ const runProgram = async () => {
42
+ const program = cli();
43
+ program.parse();
44
+
45
+ const options = getOptions(program.opts());
46
+ const logger = new Logger(!!options.nocolor);
47
+
48
+ const token = process.env.JIRA_API_TOKEN ?? tokenUnavailable();
49
+ const jira = new Jira(
50
+ 'https://issues.redhat.com',
51
+ token,
52
+ options.dry,
53
+ logger
54
+ );
55
+
56
+ const version = await jira.getVersion();
57
+ console.debug(`JIRA Version: ${version}`);
58
+
59
+ const sprints = await jira.getSprints(+options.board);
60
+
61
+ const sprintOrBacklog = await select({
62
+ message: 'Pick issues to process from sprint or backlog',
63
+ choices: [
64
+ ...sprints.map(sprint => ({
65
+ name: `${sprint.name} (${sprint.state === 'active' ? chalk.green(sprint.state) : chalk.yellow(sprint.state)})`,
66
+ value: sprint.id,
67
+ })),
68
+ {
69
+ name: `${chalk.bold('Backlog')}`,
70
+ value: -1,
71
+ },
72
+ ],
73
+ default: -1,
74
+ pageSize: 5,
75
+ loop: false,
76
+ });
77
+
78
+ let issues: SearchResults['issues'] = [];
79
+ if (sprintOrBacklog === -1) {
80
+ issues = await jira.getBacklog(+options.board, options.assignee);
81
+ } else {
82
+ issues = await jira.getIssuesInSprint(sprintOrBacklog, options.assignee);
83
+ }
84
+
85
+ if (issues.length === 0) {
86
+ logger.log(`${chalk.green('No issues found')}.`);
87
+ process.exit(0);
88
+ }
89
+
90
+ // TODO:
91
+ // Show issue - allow to split it into tasks
92
+ // Allow to set story points and assignee
93
+ // add task into sprint
94
+
95
+ for (const issue of issues) {
96
+ logger.log(
97
+ `\n${issueTypeSchema.parse(issue.fields?.issuetype.name)} ${issue.key} - ${chalk.bold(issueStatusSchema.parse(issue.fields?.status.name))} - ${chalk.italic(issue.fields?.assignee?.displayName ?? '')}`
98
+ );
99
+ logger.log(
100
+ `${chalk.underline((issue.fields?.components ?? []).map(component => component.name).join(', ') || 'NO COMPONENT')} - ${chalk.italic(issue.fields?.summary ?? '')}`
101
+ );
102
+ logger.log(
103
+ `See more: ${chalk.italic.underline(jira.getIssueURL(issue.key ?? ''))}\n`
104
+ );
105
+
106
+ const availableTasks = [
107
+ { name: 'DEV Task', value: 39396, checked: true },
108
+ { name: 'QE Task', value: 39400, checked: true },
109
+ { name: 'Upstream', value: 39395 },
110
+ { name: 'Root Cause Analysis Task', value: 40950 },
111
+ { name: 'Preliminary Testing Task', value: 39398 },
112
+ { name: 'Integration Testing', value: 48270 },
113
+ ];
114
+
115
+ const answer = await checkbox({
116
+ message: `Split ${chalk.bold(issue.key)} into following tasks:\n`,
117
+ choices: [
118
+ ...availableTasks.map(task => ({
119
+ name: colorTaskSchema(task.name),
120
+ value: task.value,
121
+ checked: task.checked ?? false,
122
+ })),
123
+ new Separator(),
124
+ { name: 'SKIP', value: -1 },
125
+ { name: 'EXIT', value: -2 },
126
+ ],
127
+ loop: false,
128
+ pageSize: 10,
129
+ });
130
+
131
+ if (answer.includes(-1)) {
132
+ continue;
133
+ }
134
+
135
+ if (answer.includes(-2)) {
136
+ process.exit(0);
137
+ }
138
+
139
+ // Create tasks
140
+ await jira.createTasks(issue.key!, answer);
141
+
142
+ let tasks: Issue[] = [];
143
+ // wait for tasks to be created
144
+ for (let attempt = 1; attempt <= 10; attempt++) {
145
+ tasks = await jira.getlinkedTasks(
146
+ issue.key!,
147
+ answer.map(
148
+ task => availableTasks.find(t => t.value === task)?.name ?? ''
149
+ )
150
+ );
151
+
152
+ if (Array.isArray(tasks) && tasks.length >= answer.length) break;
153
+ if (attempt < 10) {
154
+ logger.log(`Waiting for tasks to be created...`);
155
+ await new Promise(resolve => setTimeout(resolve, 5000));
156
+ }
157
+ }
158
+
159
+ // loop through tasks and and set sprint, assignee and story points
160
+ for (const task of tasks) {
161
+ // Skip QE Task
162
+ if (task.fields.summary.includes('QE Task')) {
163
+ continue;
164
+ }
165
+
166
+ logger.log(`${chalk.italic(task.fields.summary)}`);
167
+ const storyPointsAnswer: Size = await select({
168
+ message: 'Story Points',
169
+ choices: [
170
+ {
171
+ name: colorSizeSchema.parse(0),
172
+ value: 0,
173
+ },
174
+ {
175
+ name: colorSizeSchema.parse(1),
176
+ value: 1,
177
+ },
178
+ {
179
+ name: colorSizeSchema.parse(2),
180
+ value: 2,
181
+ },
182
+ {
183
+ name: colorSizeSchema.parse(3),
184
+ value: 3,
185
+ },
186
+ {
187
+ name: colorSizeSchema.parse(5),
188
+ value: 5,
189
+ },
190
+ {
191
+ name: colorSizeSchema.parse(8),
192
+ value: 8,
193
+ },
194
+ {
195
+ name: colorSizeSchema.parse(13),
196
+ value: 13,
197
+ },
198
+ ],
199
+ default: issue.fields?.[jira.fields.storyPoints] ?? 3,
200
+ pageSize: 6,
201
+ loop: false,
202
+ });
203
+
204
+ const addToSprintAnswer = await select({
205
+ message: 'Add to sprint',
206
+ choices: [
207
+ { name: `${chalk.green('Yes')}`, value: true },
208
+ { name: `${chalk.red('No')}`, value: false },
209
+ ],
210
+ default: true,
211
+ pageSize: 2,
212
+ loop: false,
213
+ });
214
+
215
+ // update task
216
+ await jira.setValues(task.key, {
217
+ assignee: issue.fields?.assignee?.emailAddress,
218
+ size: storyPointsAnswer,
219
+ sprint:
220
+ addToSprintAnswer && sprintOrBacklog != -1
221
+ ? sprintOrBacklog
222
+ : undefined,
223
+ });
224
+ }
225
+
226
+ logger.log(
227
+ `Dropping ${chalk.bold(issue.key)} from sprint and setting story points to ${chalk.bold(0)}...`
228
+ );
229
+ await jira.setValues(issue.key!, { size: 0, sprint: null });
230
+ }
231
+ };
232
+
233
+ export default runProgram;
package/src/jira.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { AgileClient, Version2Client } from 'jira.js';
2
+ import { SearchResults, Sprint } from 'jira.js/dist/esm/types/agile/models';
3
+
4
+ import { raise } from './util';
5
+ import { Size } from './schema/jira';
6
+ import { Logger } from './logger';
7
+
8
+ export class Jira {
9
+ readonly api: Version2Client;
10
+ readonly agile: AgileClient;
11
+ readonly fields = {
12
+ automation: 'customfield_12316240',
13
+ assignee: 'assignee',
14
+ priority: 'priority',
15
+ severity: 'customfield_12316142',
16
+ sprint: 'customfield_12310940',
17
+ storyPoints: 'customfield_12310243',
18
+ };
19
+
20
+ readonly issuesWithoutTasksJQL = `issueFunction not in linkedIssuesOf("type = Task AND (summary ~ 'DEV Task' OR summary ~ 'QE Task')") AND type not in (Task, Epic) AND project = "RHEL"`;
21
+
22
+ constructor(
23
+ readonly instance: string,
24
+ apiToken: string,
25
+ readonly dry: boolean,
26
+ readonly logger: Logger
27
+ ) {
28
+ this.api = new Version2Client({
29
+ host: instance,
30
+ authentication: {
31
+ oauth2: {
32
+ accessToken: apiToken,
33
+ },
34
+ },
35
+ });
36
+
37
+ this.agile = new AgileClient({
38
+ host: instance,
39
+ authentication: {
40
+ oauth2: {
41
+ accessToken: apiToken,
42
+ },
43
+ },
44
+ });
45
+ }
46
+
47
+ async getVersion(): Promise<string> {
48
+ const response = await this.api.serverInfo.getServerInfo();
49
+ return response.version ?? raise('Jira.getVersion(): missing version.');
50
+ }
51
+
52
+ async getSprints(boardId: number): Promise<Sprint[]> {
53
+ const response = await this.agile.board.getAllSprints({
54
+ boardId: boardId,
55
+ state: 'active,future',
56
+ });
57
+
58
+ return response.values;
59
+ }
60
+
61
+ async getIssuesInSprint(
62
+ sprintId: number,
63
+ assignee: string | undefined = undefined,
64
+ issuesWithoutTasks: boolean = true
65
+ ): Promise<SearchResults['issues']> {
66
+ let jql = assignee ? `assignee = "${assignee}"` : '';
67
+ jql += issuesWithoutTasks ? ` AND ${this.issuesWithoutTasksJQL}` : '';
68
+
69
+ const response = await this.agile.sprint.getIssuesForSprint({
70
+ sprintId: +sprintId,
71
+ jql,
72
+ maxResults: 500,
73
+ fields: [
74
+ 'id',
75
+ 'issuetype',
76
+ 'status',
77
+ 'summary',
78
+ 'assignee',
79
+ 'priority',
80
+ 'components',
81
+ // sprint
82
+ this.fields.storyPoints,
83
+ this.fields.severity,
84
+ ],
85
+ });
86
+ return response.issues;
87
+ }
88
+
89
+ async getBacklog(
90
+ boardId: number,
91
+ assignee?: string
92
+ ): Promise<SearchResults['issues']> {
93
+ const response = await this.agile.board.getIssuesForBacklog({
94
+ boardId: boardId,
95
+ jql: assignee ? `assignee = "${assignee}"` : undefined,
96
+ maxResults: 500,
97
+ fields: [
98
+ 'id',
99
+ 'issuetype',
100
+ 'status',
101
+ 'summary',
102
+ 'assignee',
103
+ 'priority',
104
+ 'components',
105
+ this.fields.storyPoints,
106
+ this.fields.severity,
107
+ ],
108
+ });
109
+
110
+ return response.issues;
111
+ }
112
+
113
+ composeTaskSummaryJQL(expectedTasks: string[]) {
114
+ return `summary ~ "\\\\[${expectedTasks.join('\\\\]: " OR summary ~ "\\\\[')}\\\\]: "`;
115
+ }
116
+
117
+ async getlinkedTasks(issue: string, expectedTasks: string[]) {
118
+ const response = await this.api.issueSearch.searchForIssuesUsingJqlPost({
119
+ jql: `issue in linkedIssues("${issue}") AND type = Task AND status = New AND (${this.composeTaskSummaryJQL(expectedTasks)})`,
120
+ fields: [
121
+ 'id',
122
+ 'issuetype',
123
+ 'status',
124
+ 'components',
125
+ 'summary',
126
+ 'assignee',
127
+ this.fields.storyPoints,
128
+ ],
129
+ });
130
+
131
+ return response.issues ?? [];
132
+ }
133
+
134
+ async createTasks(issue: string, tasks: number[]) {
135
+ if (this.dry) {
136
+ this.logger.log(
137
+ `Would create tasks: ${tasks.join(', ')} for issue: ${issue}`
138
+ );
139
+ return;
140
+ }
141
+
142
+ await this.api.issues.editIssue({
143
+ issueIdOrKey: issue,
144
+ fields: {
145
+ // Jira expects multi-select values as objects with id/value
146
+ [this.fields.automation]: tasks.map(id => ({ id: String(id) })),
147
+ },
148
+ });
149
+ }
150
+
151
+ async setValues(
152
+ issue: string,
153
+ values: {
154
+ assignee?: string;
155
+ size?: Size;
156
+ sprint?: number | null;
157
+ }
158
+ ) {
159
+ const assigneeValue = values.assignee
160
+ ? { [this.fields.assignee]: { name: values.assignee } }
161
+ : {};
162
+ const storyPointsValue = values.size
163
+ ? { [this.fields.storyPoints]: values.size }
164
+ : {};
165
+ const sprintValue =
166
+ values.sprint === undefined
167
+ ? {}
168
+ : { [this.fields.sprint]: values.sprint };
169
+
170
+ await this.api.issues.editIssue({
171
+ issueIdOrKey: issue,
172
+ fields: { ...assigneeValue, ...storyPointsValue, ...sprintValue },
173
+ });
174
+ }
175
+
176
+ getIssueURL(issue: string) {
177
+ return `${this.instance}/browse/${issue}`;
178
+ }
179
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,14 @@
1
+ export class Logger {
2
+ static readonly colorRegex = /\[\d+m/gm;
3
+
4
+ constructor(readonly noColor: boolean = false) {}
5
+
6
+ log(message: string): void {
7
+ if (!this.noColor) {
8
+ console.log(message);
9
+ return;
10
+ }
11
+
12
+ console.log(message.replace(Logger.colorRegex, ''));
13
+ }
14
+ }
package/src/main.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import dotenv from 'dotenv';
5
+
6
+ import '@total-typescript/ts-reset';
7
+
8
+ import runProgram from './cli';
9
+
10
+ dotenv.config({
11
+ path: [
12
+ `${path.resolve(process.cwd(), '.env')}`,
13
+ `${path.resolve(os.homedir(), '.config', 'jira-sprinter', '.env')}`,
14
+ `${path.resolve(os.homedir(), '.env.jira-sprinter')}`,
15
+ `${path.resolve(os.homedir(), '.env')}`,
16
+ ],
17
+ });
18
+
19
+ try {
20
+ runProgram();
21
+ } catch (error) {
22
+ console.error(error);
23
+ process.exit(1);
24
+ }
@@ -0,0 +1,107 @@
1
+ import chalk from 'chalk';
2
+ import { z } from 'zod';
3
+
4
+ export const sizeSchema = z.union([
5
+ z.literal(0), // gray
6
+ z.literal(1), // green
7
+ z.literal(2), // green
8
+ z.literal(3), // yellow
9
+ z.literal(5), // yellow bold
10
+ z.literal(8), // red
11
+ z.literal(13), // red bold
12
+ ]);
13
+
14
+ export const colorSizeSchema = sizeSchema.transform(val => {
15
+ switch (val) {
16
+ case 0:
17
+ return chalk.gray(val);
18
+ case 1:
19
+ return chalk.green(val);
20
+ case 2:
21
+ return chalk.green(val);
22
+ case 3:
23
+ return chalk.yellow(val);
24
+ case 5:
25
+ return chalk.yellow.bold(val);
26
+ case 8:
27
+ return chalk.red(val);
28
+ case 13:
29
+ return chalk.red.bold(val);
30
+ default:
31
+ return val;
32
+ }
33
+ });
34
+
35
+ export type Size = z.infer<typeof sizeSchema>;
36
+
37
+ export const colorTaskSchema = (task: string) => {
38
+ switch (task) {
39
+ case 'DEV Task':
40
+ return chalk.green(task);
41
+ case 'Upstream':
42
+ return chalk.green(task);
43
+ case 'Root Cause Analysis Task':
44
+ return chalk.green(task);
45
+ case 'QE Task':
46
+ return chalk.yellow(task);
47
+ case 'Preliminary Testing Task':
48
+ return chalk.blue(task);
49
+ case 'Integration Testing':
50
+ return chalk.blue(task);
51
+ default:
52
+ return task;
53
+ }
54
+ };
55
+
56
+ export const issueIdSchema = z.string().regex(/^RHEL-\d+$/);
57
+
58
+ export type IssueID = z.infer<typeof issueIdSchema>;
59
+
60
+ export const issueTypeSchema = z
61
+ .union([
62
+ z.literal('Task'),
63
+ z.literal('Bug'),
64
+ z.literal('Story'),
65
+ z.literal('Epic'),
66
+ z.string(),
67
+ ])
68
+ .transform(val => {
69
+ switch (val) {
70
+ case 'Task':
71
+ return '☑️';
72
+ case 'Bug':
73
+ return '🐛';
74
+ case 'Story':
75
+ return '🎁';
76
+ case 'Epic':
77
+ return '⚡';
78
+ default:
79
+ return val;
80
+ }
81
+ });
82
+
83
+ export const issueStatusSchema = z
84
+ .union([
85
+ z.literal('New'),
86
+ z.literal('Planning'),
87
+ z.literal('In Progress'),
88
+ z.literal('Integration'),
89
+ z.literal('Release Pending'),
90
+ z.string(),
91
+ ])
92
+ .transform(val => {
93
+ switch (val) {
94
+ case 'New':
95
+ return chalk.cyan(val);
96
+ case 'Planning':
97
+ return chalk.cyan(val);
98
+ case 'In Progress':
99
+ return chalk.blue(val);
100
+ case 'Integration':
101
+ return chalk.green(val);
102
+ case 'Release Pending':
103
+ return chalk.green(val);
104
+ default:
105
+ return val;
106
+ }
107
+ });
package/src/util.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { OptionValues } from 'commander';
2
+ import os from 'os';
3
+
4
+ export function raise(error: string): never {
5
+ throw new Error(error);
6
+ }
7
+
8
+ export function tokenUnavailable(): never {
9
+ return raise(
10
+ `JIRA_API_TOKEN not set.\nPlease set the JIRA_API_TOKEN environment variable in '~/.config/sprinter/.env' or '~/.env.sprinter' or '~/.env.'`
11
+ );
12
+ }
13
+
14
+ export function getUserFromLogin(): string {
15
+ const login = os.userInfo().username;
16
+ return `${login}@redhat.com`;
17
+ }
18
+
19
+ export function isDefaultValuesDisabled(): boolean {
20
+ return process.env['NODEFAULTS'] ? true : false;
21
+ }
22
+
23
+ export function getDefaultValue(
24
+ envName: 'ASSIGNEE' | 'BOARD' | 'NOCOLOR' | 'DRY'
25
+ ) {
26
+ if (isDefaultValuesDisabled()) {
27
+ return undefined;
28
+ }
29
+
30
+ const value = process.env[envName];
31
+
32
+ if (envName === 'ASSIGNEE' && !value) {
33
+ return getUserFromLogin();
34
+ }
35
+
36
+ if (envName === 'NOCOLOR' && !value) {
37
+ return false;
38
+ }
39
+
40
+ return value;
41
+ }
42
+
43
+ export function getOptions(inputs: OptionValues): OptionValues {
44
+ return {
45
+ ...inputs,
46
+ assignee: inputs.assignee || getDefaultValue('ASSIGNEE'),
47
+ board: inputs.board || getDefaultValue('BOARD'),
48
+ };
49
+ }