jira-sprinter 2.0.1 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-sprinter",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Small CLI tool to manage sprints in JIRA Board",
5
5
  "main": "src/main.ts",
6
6
  "type": "commonjs",
@@ -16,7 +16,7 @@
16
16
  "update-snapshots": "vitest run --update",
17
17
  "all": "yarn && yarn run build && yarn run format && yarn test"
18
18
  },
19
- "packageManager": "yarn@4.12.0",
19
+ "packageManager": "yarn@4.17.1",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/redhat-plumbers-in-action/sprinter.git"
@@ -34,22 +34,22 @@
34
34
  "scrum"
35
35
  ],
36
36
  "dependencies": {
37
- "@inquirer/checkbox": "^5.1.3",
38
- "@inquirer/select": "5.1.3",
37
+ "@inquirer/checkbox": "^5.2.1",
38
+ "@inquirer/select": "5.2.1",
39
39
  "@total-typescript/ts-reset": "0.6.1",
40
40
  "chalk": "5.6.2",
41
- "commander": "14.0.3",
42
- "dotenv": "17.4.1",
43
- "jira.js": "5.3.1",
44
- "zod": "4.3.6"
41
+ "commander": "15.0.0",
42
+ "dotenv": "17.4.2",
43
+ "jira.js": "5.4.0",
44
+ "zod": "4.4.3"
45
45
  },
46
46
  "devDependencies": {
47
- "@types/node": "25.5.2",
48
- "@vitest/coverage-v8": "4.1.3",
49
- "esbuild": "0.28.0",
50
- "prettier": "3.8.1",
47
+ "@types/node": "26.1.1",
48
+ "@vitest/coverage-v8": "4.1.10",
49
+ "esbuild": "0.28.1",
50
+ "prettier": "3.9.5",
51
51
  "ts-node": "10.9.2",
52
- "typescript": "6.0.2",
53
- "vitest": "4.1.3"
52
+ "typescript": "7.0.2",
53
+ "vitest": "4.1.10"
54
54
  }
55
55
  }
package/src/auto.ts ADDED
@@ -0,0 +1,94 @@
1
+ import chalk from 'chalk';
2
+ import { OptionValues } from 'commander';
3
+
4
+ import { Logger } from './logger';
5
+ import { Jira } from './jira';
6
+
7
+ export async function runAuto(options: OptionValues): Promise<void> {
8
+ const logger = new Logger(!!options.nocolor);
9
+
10
+ const jira = await Jira.getInstance(options.dry, logger, options.assignee);
11
+
12
+ const boardIssues = await jira.getBoardIssues(
13
+ options.board,
14
+ `project = RHEL and issuetype in (Bug, Story, Vulnerability) and statusCategory != Done`
15
+ );
16
+
17
+ const preliminaryTestingRequested = boardIssues.filter(
18
+ issue =>
19
+ issue.fields?.[jira.fields.preliminaryTesting]?.value === 'Requested' &&
20
+ !issue.fields?.issuelinks?.some(
21
+ l =>
22
+ l.type?.outward === 'split to' &&
23
+ l.outwardIssue?.fields?.summary.startsWith(
24
+ jira.preliminaryTestingTask.summary
25
+ ) &&
26
+ l.outwardIssue?.fields?.status.name !== 'Closed'
27
+ )
28
+ );
29
+ logger.log(
30
+ `${chalk.green('Preliminary Testing Requested')} - ${preliminaryTestingRequested.length}`
31
+ );
32
+
33
+ const preliminaryTestingFailed = boardIssues.filter(
34
+ issue =>
35
+ issue.fields?.[jira.fields.preliminaryTesting]?.value === 'Fail' &&
36
+ issue.fields?.issuelinks?.some(
37
+ l =>
38
+ l.type?.outward === 'split to' &&
39
+ l.outwardIssue?.fields?.summary.startsWith(
40
+ jira.preliminaryTestingTask.summary
41
+ ) &&
42
+ l.outwardIssue?.fields?.status.name !== 'Closed'
43
+ )
44
+ );
45
+ logger.log(
46
+ `${chalk.red('Preliminary Testing Failed')} - ${preliminaryTestingFailed.length}`
47
+ );
48
+
49
+ if (preliminaryTestingRequested.length > 0) {
50
+ logger.log(
51
+ `${chalk.cyan('Creating split tasks for Preliminary Testing Requested...')}`
52
+ );
53
+ }
54
+
55
+ for (const issue of preliminaryTestingRequested) {
56
+ if (!issue.key) {
57
+ continue;
58
+ }
59
+
60
+ await jira.createTasks(issue.key, [jira.preliminaryTestingTask.value]);
61
+ }
62
+
63
+ if (preliminaryTestingFailed.length > 0) {
64
+ logger.log(
65
+ `${chalk.red('Closing Preliminary Testing Task - Testing Failed...')}`
66
+ );
67
+ }
68
+
69
+ for (const issue of preliminaryTestingFailed) {
70
+ if (!issue.key) {
71
+ continue;
72
+ }
73
+
74
+ const preliminaryTestingTask = issue.fields?.issuelinks?.find(
75
+ l =>
76
+ l.type?.outward === 'split to' &&
77
+ l.outwardIssue?.fields?.summary.startsWith(
78
+ jira.preliminaryTestingTask.summary
79
+ ) &&
80
+ l.outwardIssue?.fields?.status.name !== 'Closed'
81
+ );
82
+
83
+ if (!preliminaryTestingTask || !preliminaryTestingTask.outwardIssue?.key) {
84
+ logger.log(
85
+ `${chalk.red('Preliminary Testing Task not found')} - ${issue.key}`
86
+ );
87
+ continue;
88
+ }
89
+
90
+ await jira.closeTask(preliminaryTestingTask.outwardIssue?.key);
91
+ }
92
+
93
+ process.exit(0);
94
+ }
package/src/cli.ts CHANGED
@@ -5,12 +5,8 @@ import checkbox from '@inquirer/checkbox';
5
5
 
6
6
  import { Jira } from './jira';
7
7
  import { Logger } from './logger';
8
- import {
9
- getDefaultValue,
10
- getOptions,
11
- getUserFromLogin,
12
- tokenUnavailable,
13
- } from './util';
8
+ import { getDefaultValue, getOptions } from './util';
9
+ import { runAuto } from './auto';
14
10
 
15
11
  import { SearchResults } from 'jira.js/dist/esm/types/agile/models';
16
12
  import {
@@ -28,7 +24,28 @@ export function cli(): Command {
28
24
  program
29
25
  .name('jira-sprinter')
30
26
  .description('🏃 Small CLI tool to manage sprints in JIRA Board')
31
- .version('2.0.1');
27
+ .version('2.1.0');
28
+
29
+ program.addCommand(
30
+ new Command('auto')
31
+ .description(
32
+ 'Automatically manages split tasks (DEV and Preliminary Testing) based on ticket state and status'
33
+ )
34
+ .option('-b, --board [board]', 'Jira Board ID', getDefaultValue('BOARD'))
35
+ .requiredOption(
36
+ '-t, --team [assigned team]',
37
+ 'Jira Assigned Team',
38
+ getDefaultValue('TEAM')
39
+ )
40
+ .option(
41
+ '-c, --components [components]',
42
+ 'Jira Components',
43
+ getDefaultValue('COMPONENTS')
44
+ )
45
+ .action(async (_opts, command) => {
46
+ await runAuto(command.optsWithGlobals());
47
+ })
48
+ );
32
49
 
33
50
  program
34
51
  .option('-b, --board [board]', 'Jira Board ID', getDefaultValue('BOARD'))
@@ -43,29 +60,23 @@ export function cli(): Command {
43
60
  getDefaultValue('YOLO')
44
61
  )
45
62
  .option('-n, --nocolor', 'Disable color output', getDefaultValue('NOCOLOR'))
46
- .option('-x, --dry', 'dry run', getDefaultValue('DRY'));
63
+ .option('-x, --dry', 'dry run', getDefaultValue('DRY'))
64
+ .action(() => {});
47
65
 
48
66
  return program;
49
67
  }
50
68
 
51
69
  const runProgram = async () => {
52
70
  const program = cli();
53
- program.parse();
71
+ await program.parseAsync();
72
+
73
+ // If there are any arguments (like auto), just return, the command logic is handled in separate functions
74
+ if (program.args.length > 0) return;
54
75
 
55
76
  const options = getOptions(program.opts());
56
77
  const logger = new Logger(!!options.nocolor);
57
78
 
58
- const token = process.env.JIRA_API_TOKEN ?? tokenUnavailable();
59
- const jira = new Jira(
60
- 'https://redhat.atlassian.net',
61
- token,
62
- options.dry,
63
- logger,
64
- getUserFromLogin()
65
- );
66
-
67
- const version = await jira.getVersion();
68
- console.debug(`JIRA Version: ${version}`);
79
+ const jira = await Jira.getInstance(options.dry, logger, options.assignee);
69
80
 
70
81
  const sprints = await jira.getSprints(+options.board);
71
82
 
@@ -110,53 +121,12 @@ const runProgram = async () => {
110
121
  `See more: ${chalk.italic.underline(jira.getIssueURL(issue.key ?? ''))}\n`
111
122
  );
112
123
 
113
- const availableTasks = [
114
- {
115
- name: 'DEV Task',
116
- summary: '[DEV Task]:',
117
- label: 'dev_task',
118
- value: '14476',
119
- checked: true,
120
- },
121
- {
122
- name: 'QE Task',
123
- summary: '[QE Task]:',
124
- label: 'qe_task',
125
- value: '14480',
126
- checked: true,
127
- },
128
- {
129
- name: 'Upstream',
130
- summary: '[Upstream]:',
131
- label: 'upstream_task',
132
- value: '14475',
133
- },
134
- {
135
- name: 'Root Cause Analysis Task',
136
- summary: '[Root Cause Analysis Task]:',
137
- label: 'root_cause_analysis_task',
138
- value: '14483',
139
- },
140
- {
141
- name: 'Preliminary Testing Task',
142
- summary: '[Preliminary Testing Task]:',
143
- label: 'preliminary_testing_task',
144
- value: '14478',
145
- },
146
- {
147
- name: 'Integration Testing',
148
- summary: '[Integration Testing Task]:',
149
- label: 'integration_testing_task',
150
- value: '14481',
151
- },
152
- ];
153
-
154
- let answer: number[] = [];
124
+ let answer: string[] = [];
155
125
  if (!options.yolo) {
156
126
  answer = await checkbox({
157
127
  message: `Split ${chalk.bold(issue.key)} into following tasks:\n`,
158
128
  choices: [
159
- ...availableTasks.map(task => {
129
+ ...jira.availableTasks.map(task => {
160
130
  const isTaskOpen = issue.fields?.issuelinks?.some(
161
131
  l =>
162
132
  l.type?.outward === 'split to' &&
@@ -172,24 +142,24 @@ const runProgram = async () => {
172
142
  };
173
143
  }),
174
144
  new Separator(),
175
- { name: 'SKIP', value: -1 },
176
- { name: 'EXIT', value: -2 },
145
+ { name: 'SKIP', value: '-1' },
146
+ { name: 'EXIT', value: '-2' },
177
147
  ],
178
148
  loop: false,
179
149
  pageSize: 10,
180
150
  });
181
151
 
182
- if (answer.includes(-1)) {
152
+ if (answer.includes('-1')) {
183
153
  continue;
184
154
  }
185
155
 
186
- if (answer.includes(-2)) {
156
+ if (answer.includes('-2')) {
187
157
  process.exit(0);
188
158
  }
189
159
  }
190
160
 
191
161
  if (options.yolo) {
192
- answer = availableTasks
162
+ answer = jira.availableTasks
193
163
  .filter(task => task.checked ?? false)
194
164
  .map(task => task.value);
195
165
  }
@@ -203,7 +173,7 @@ const runProgram = async () => {
203
173
  tasks = await jira.getlinkedTasks(
204
174
  issue.key!,
205
175
  answer.map(
206
- task => availableTasks.find(t => t.value === task)?.name ?? ''
176
+ task => jira.availableTasks.find(t => t.value === task)?.name ?? ''
207
177
  )
208
178
  );
209
179
 
package/src/jira.ts CHANGED
@@ -2,7 +2,7 @@ import { AgileClient, Version3Client } from 'jira.js';
2
2
  import { SearchResults, Sprint } from 'jira.js/dist/esm/types/agile/models';
3
3
  import { Issue } from 'jira.js/dist/esm/types/version3/models/issue';
4
4
 
5
- import { raise } from './util';
5
+ import { raise, tokenUnavailable } from './util';
6
6
  import { Size } from './schema/jira';
7
7
  import { Logger } from './logger';
8
8
 
@@ -16,9 +16,56 @@ export class Jira {
16
16
  severity: 'customfield_10840',
17
17
  sprint: 'customfield_10020',
18
18
  storyPoints: 'customfield_10028',
19
+ preliminaryTesting: 'customfield_10879',
19
20
  };
20
21
 
21
- readonly nonTaskIssuesJQL = `(labels not in (upstream_task, dev_task, qe_task, root_cause_analysis_task, preliminary_testing_task, integration_testing_task) OR labels is EMPTY) AND type in (Bug, "Story", Vulnerability) AND Project = RHEL AND statusCategory != Done`;
22
+ readonly devTask = {
23
+ name: 'DEV Task',
24
+ summary: '[DEV Task]:',
25
+ label: 'dev_task',
26
+ value: '14476',
27
+ };
28
+ readonly qeTask = {
29
+ name: 'QE Task',
30
+ summary: '[QE Task]:',
31
+ label: 'qe_task',
32
+ value: '14480',
33
+ };
34
+ readonly upstreamTask = {
35
+ name: 'Upstream',
36
+ summary: '[Upstream]:',
37
+ label: 'upstream_task',
38
+ value: '14475',
39
+ };
40
+ readonly rootCauseAnalysisTask = {
41
+ name: 'Root Cause Analysis Task',
42
+ summary: '[Root Cause Analysis Task]:',
43
+ label: 'root_cause_analysis_task',
44
+ value: '14483',
45
+ };
46
+ readonly preliminaryTestingTask = {
47
+ name: 'Preliminary Testing Task',
48
+ summary: '[Preliminary Testing Task]:',
49
+ label: 'preliminary_testing_task',
50
+ value: '14478',
51
+ };
52
+ readonly integrationTestingTask = {
53
+ name: 'Integration Testing',
54
+ summary: '[Integration Testing Task]:',
55
+ label: 'integration_testing_task',
56
+ value: '14481',
57
+ };
58
+ readonly availableTasks = [
59
+ { ...this.devTask, checked: true },
60
+ { ...this.qeTask, checked: true },
61
+ { ...this.upstreamTask, checked: false },
62
+ { ...this.rootCauseAnalysisTask, checked: false },
63
+ { ...this.preliminaryTestingTask, checked: false },
64
+ { ...this.integrationTestingTask, checked: false },
65
+ ];
66
+ readonly availableTasksLabels = this.availableTasks.map(task => task.label);
67
+
68
+ readonly nonTaskIssuesJQL = `(labels not in (${this.availableTasksLabels.join(', ')}) OR labels is EMPTY) AND type in (Bug, "Story", Vulnerability) AND Project = RHEL AND statusCategory != Done`;
22
69
 
23
70
  constructor(
24
71
  readonly instance: string,
@@ -115,6 +162,32 @@ export class Jira {
115
162
  return response.issues;
116
163
  }
117
164
 
165
+ async getBoardIssues(
166
+ boardId: number,
167
+ jql?: string
168
+ ): Promise<SearchResults['issues']> {
169
+ const response = await this.agile.board.getIssuesForBoard({
170
+ boardId: boardId,
171
+ maxResults: 500,
172
+ jql: jql ?? '',
173
+ fields: [
174
+ 'id',
175
+ 'issuetype',
176
+ 'status',
177
+ 'summary',
178
+ 'assignee',
179
+ 'priority',
180
+ 'components',
181
+ this.fields.storyPoints,
182
+ this.fields.severity,
183
+ this.fields.preliminaryTesting,
184
+ 'issuelinks',
185
+ ],
186
+ });
187
+
188
+ return response.issues;
189
+ }
190
+
118
191
  composeTaskSummaryJQL(expectedTasks: string[]) {
119
192
  return `summary ~ "\\\\[${expectedTasks.join('\\\\]: " OR summary ~ "\\\\[')}\\\\]: "`;
120
193
  }
@@ -161,7 +234,7 @@ export class Jira {
161
234
  return response.issues ?? [];
162
235
  }
163
236
 
164
- async createTasks(issue: string, tasks: number[]) {
237
+ async createTasks(issue: string, tasks: string[]) {
165
238
  if (this.dry) {
166
239
  this.logger.log(
167
240
  `Would create tasks: ${tasks.join(', ')} for issue: ${issue}`
@@ -174,7 +247,23 @@ export class Jira {
174
247
  issueIdOrKey: issue,
175
248
  fields: {
176
249
  // Jira expects multi-select values as objects with id/value
177
- [this.fields.automation]: tasks.map(id => ({ id: String(id) })),
250
+ [this.fields.automation]: tasks.map(id => ({ id: id })),
251
+ },
252
+ });
253
+ }
254
+
255
+ async closeTask(issue: string) {
256
+ if (this.dry) {
257
+ this.logger.log(`Would close task: ${issue}`);
258
+ return;
259
+ }
260
+
261
+ this.logger.log(`Closing task: ${issue}`);
262
+
263
+ await this.api.issues.doTransition({
264
+ issueIdOrKey: issue,
265
+ transition: {
266
+ id: '31',
178
267
  },
179
268
  });
180
269
  }
@@ -208,4 +297,21 @@ export class Jira {
208
297
  getIssueURL(issue: string) {
209
298
  return `${this.instance}/browse/${issue}`;
210
299
  }
300
+
301
+ static async getInstance(dryRun: boolean, logger: Logger, assignee: string) {
302
+ const apiToken = process.env.JIRA_API_TOKEN ?? tokenUnavailable();
303
+
304
+ const instance = new this(
305
+ 'https://redhat.atlassian.net',
306
+ apiToken,
307
+ dryRun,
308
+ logger,
309
+ assignee
310
+ );
311
+
312
+ const version = await instance.getVersion();
313
+ console.debug(`JIRA Version: ${version}`);
314
+
315
+ return instance;
316
+ }
211
317
  }
package/src/util.ts CHANGED
@@ -21,7 +21,8 @@ export function isDefaultValuesDisabled(): boolean {
21
21
  }
22
22
 
23
23
  export function getDefaultValue(
24
- envName: 'ASSIGNEE' | 'BOARD' | 'NOCOLOR' | 'DRY' | 'YOLO'
24
+ envName:
25
+ 'ASSIGNEE' | 'BOARD' | 'TEAM' | 'COMPONENTS' | 'NOCOLOR' | 'DRY' | 'YOLO'
25
26
  ) {
26
27
  if (isDefaultValuesDisabled()) {
27
28
  return undefined;
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Create split tasks for Scrum board
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ Environment=PATH=/home/user/.local/bin:/usr/local/bin:/usr/bin:/bin
7
+ EnvironmentFile=/home/user/.config/jira-sprinter/.env
8
+ ExecStart=/usr/bin/npx jira-sprinter auto ...
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Run jira-sprinter auto every 20 minutes
3
+
4
+ [Timer]
5
+ OnCalendar=*:0/20
6
+ AccuracySec=1min
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target