jira-sprinter 2.2.0 → 2.4.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/README.md CHANGED
@@ -87,6 +87,7 @@ The `auto` command automates the management of split tasks based on ticket state
87
87
  - **Preliminary Testing Requested**: Scans the board for issues where Preliminary Testing is marked as "Requested" and creates the corresponding split task if one does not already exist (or was previously closed).
88
88
  - **Preliminary Testing Failed**: When testing has failed, automatically closes the linked Preliminary Testing split task.
89
89
  - **Integration without QE Task**: Finds issues in "Integration" status that lack an open QE Task and creates one automatically.
90
+ - **Release Pending with QE Task**: When an issue moves to "Release Pending" status, automatically closes the linked QE Task if it is still open.
90
91
 
91
92
  ```md
92
93
  $ jira-sprinter auto --help
package/dist/main.js CHANGED
@@ -47713,7 +47713,9 @@ var Jira = class {
47713
47713
  }
47714
47714
  async getlinkedTasks(issue2, expectedTasks) {
47715
47715
  if (this.dry) {
47716
- this.logger.log(`Would get linked tasks for issue: ${issue2}`);
47716
+ this.logger.log(
47717
+ ` ${source_default.dim(`Fetching linked tasks for ${issue2} (dry-run)`)}`
47718
+ );
47717
47719
  return [
47718
47720
  {
47719
47721
  key: "RHEL-1234",
@@ -47752,11 +47754,13 @@ var Jira = class {
47752
47754
  async createTasks(issue2, tasks) {
47753
47755
  if (this.dry) {
47754
47756
  this.logger.log(
47755
- `Would create tasks: ${tasks.join(", ")} for issue: ${issue2}`
47757
+ ` ${source_default.dim(`Creating tasks ${tasks.join(", ")} for ${issue2} (dry-run)`)}`
47756
47758
  );
47757
47759
  return;
47758
47760
  }
47759
- this.logger.log(`Creating tasks: ${tasks.join(", ")} for issue: ${issue2}`);
47761
+ this.logger.log(
47762
+ ` ${source_default.cyan(`Creating tasks ${tasks.join(", ")} for ${issue2}`)}`
47763
+ );
47760
47764
  await this.api.issues.editIssue({
47761
47765
  issueIdOrKey: issue2,
47762
47766
  fields: {
@@ -47767,10 +47771,10 @@ var Jira = class {
47767
47771
  }
47768
47772
  async closeTask(issue2) {
47769
47773
  if (this.dry) {
47770
- this.logger.log(`Would close task: ${issue2}`);
47774
+ this.logger.log(` ${source_default.dim(`Closing task ${issue2} (dry-run)`)}`);
47771
47775
  return;
47772
47776
  }
47773
- this.logger.log(`Closing task: ${issue2}`);
47777
+ this.logger.log(` ${source_default.cyan(`Closing task ${issue2}`)}`);
47774
47778
  await this.api.issues.doTransition({
47775
47779
  issueIdOrKey: issue2,
47776
47780
  transition: {
@@ -47800,7 +47804,7 @@ var Jira = class {
47800
47804
  assignee
47801
47805
  );
47802
47806
  const version2 = await instance.getVersion();
47803
- console.debug(`JIRA Version: ${version2}`);
47807
+ logger.log(source_default.dim(`JIRA v${version2}`));
47804
47808
  return instance;
47805
47809
  }
47806
47810
  };
@@ -47826,9 +47830,12 @@ var Logger = class _Logger {
47826
47830
  async function runAuto(options) {
47827
47831
  const logger = new Logger(!!options.nocolor);
47828
47832
  const jira = await Jira.getInstance(options.dry, logger, options.assignee);
47833
+ logger.log(
47834
+ `${source_default.cyan(`Verifying issues in board ${options.board} - team: ${options.team}`)}`
47835
+ );
47829
47836
  const boardIssues = await jira.getBoardIssues(
47830
47837
  options.board,
47831
- `project = RHEL and issuetype in (Bug, Story, Vulnerability) and statusCategory != Done`
47838
+ `project = RHEL and issuetype in (Bug, Story, Vulnerability) and status != Closed`
47832
47839
  );
47833
47840
  const preliminaryTestingRequested = boardIssues.filter(
47834
47841
  (issue2) => issue2.fields?.[jira.fields.preliminaryTesting]?.value === "Requested" && !issue2.fields?.issuelinks?.some(
@@ -47837,11 +47844,6 @@ async function runAuto(options) {
47837
47844
  ) && l.outwardIssue?.fields?.status.name !== "Closed"
47838
47845
  )
47839
47846
  );
47840
- if (preliminaryTestingRequested.length > 0) {
47841
- logger.log(
47842
- `${source_default.green("Preliminary Testing Requested")} - ${preliminaryTestingRequested.length}`
47843
- );
47844
- }
47845
47847
  const preliminaryTestingFailed = boardIssues.filter(
47846
47848
  (issue2) => issue2.fields?.[jira.fields.preliminaryTesting]?.value === "Fail" && issue2.fields?.issuelinks?.some(
47847
47849
  (l) => l.type?.outward === "split to" && l.outwardIssue?.fields?.summary.startsWith(
@@ -47849,25 +47851,36 @@ async function runAuto(options) {
47849
47851
  ) && l.outwardIssue?.fields?.status.name !== "Closed"
47850
47852
  )
47851
47853
  );
47852
- if (preliminaryTestingFailed.length > 0) {
47853
- logger.log(
47854
- `${source_default.red("Preliminary Testing Failed")} - ${preliminaryTestingFailed.length}`
47855
- );
47856
- }
47857
47854
  const issuesInIntegration = boardIssues.filter(
47858
47855
  (issue2) => issue2.fields?.status.name === "Integration" && !issue2.fields?.issuelinks?.some(
47859
47856
  (l) => l.type?.outward === "split to" && l.outwardIssue?.fields?.summary.startsWith(jira.qeTask.summary) && l.outwardIssue?.fields?.status.name !== "Closed"
47860
47857
  )
47861
47858
  );
47862
- if (issuesInIntegration.length > 0) {
47863
- logger.log(
47864
- `${source_default.yellow("Issues in Integration w/o QE Task")} - ${issuesInIntegration.length}`
47865
- );
47859
+ const issuesInReleasePending = boardIssues.filter(
47860
+ (issue2) => issue2.fields?.status.name === "Release Pending" && issue2.fields?.issuelinks?.some(
47861
+ (l) => l.type?.outward === "split to" && l.outwardIssue?.fields?.summary.startsWith(jira.qeTask.summary) && l.outwardIssue?.fields?.status.name !== "Closed"
47862
+ )
47863
+ );
47864
+ const summary = [
47865
+ {
47866
+ label: "Preliminary Testing Requested",
47867
+ count: preliminaryTestingRequested.length
47868
+ },
47869
+ {
47870
+ label: "Preliminary Testing Failed",
47871
+ count: preliminaryTestingFailed.length
47872
+ },
47873
+ { label: "Integration w/o QE Task", count: issuesInIntegration.length },
47874
+ {
47875
+ label: "Release Pending w/ QE Task",
47876
+ count: issuesInReleasePending.length
47877
+ }
47878
+ ].filter((entry) => entry.count > 0);
47879
+ for (const { label, count } of summary) {
47880
+ logger.log(` ${source_default.cyan(label)}: ${source_default.bold(count)}`);
47866
47881
  }
47867
- if (preliminaryTestingRequested.length > 0) {
47868
- logger.log(
47869
- `${source_default.cyan("Creating split tasks for Preliminary Testing Requested...")}`
47870
- );
47882
+ if (summary.length === 0) {
47883
+ logger.log(` ${source_default.green("Nothing to do")}`);
47871
47884
  }
47872
47885
  for (const issue2 of preliminaryTestingRequested) {
47873
47886
  if (!issue2.key) {
@@ -47875,11 +47888,6 @@ async function runAuto(options) {
47875
47888
  }
47876
47889
  await jira.createTasks(issue2.key, [jira.preliminaryTestingTask.value]);
47877
47890
  }
47878
- if (preliminaryTestingFailed.length > 0) {
47879
- logger.log(
47880
- `${source_default.red("Closing Preliminary Testing Task - Testing Failed...")}`
47881
- );
47882
- }
47883
47891
  for (const issue2 of preliminaryTestingFailed) {
47884
47892
  if (!issue2.key) {
47885
47893
  continue;
@@ -47891,23 +47899,31 @@ async function runAuto(options) {
47891
47899
  );
47892
47900
  if (!preliminaryTestingTask || !preliminaryTestingTask.outwardIssue?.key) {
47893
47901
  logger.log(
47894
- `${source_default.red("Preliminary Testing Task not found")} - ${issue2.key}`
47902
+ ` ${source_default.red("Preliminary Testing Task not found")} - ${issue2.key}`
47895
47903
  );
47896
47904
  continue;
47897
47905
  }
47898
47906
  await jira.closeTask(preliminaryTestingTask.outwardIssue?.key);
47899
47907
  }
47900
- if (issuesInIntegration.length > 0) {
47901
- logger.log(
47902
- `${source_default.cyan("Creating split tasks for Issues in Integration w/o QE Task...")}`
47903
- );
47904
- }
47905
47908
  for (const issue2 of issuesInIntegration) {
47906
47909
  if (!issue2.key) {
47907
47910
  continue;
47908
47911
  }
47909
47912
  await jira.createTasks(issue2.key, [jira.qeTask.value]);
47910
47913
  }
47914
+ for (const issue2 of issuesInReleasePending) {
47915
+ if (!issue2.key) {
47916
+ continue;
47917
+ }
47918
+ const qeTask = issue2.fields?.issuelinks?.find(
47919
+ (l) => l.type?.outward === "split to" && l.outwardIssue?.fields?.summary.startsWith(jira.qeTask.summary) && l.outwardIssue?.fields?.status.name !== "Closed"
47920
+ );
47921
+ if (!qeTask || !qeTask.outwardIssue?.key) {
47922
+ logger.log(` ${source_default.red("QE Task not found")} - ${issue2.key}`);
47923
+ continue;
47924
+ }
47925
+ await jira.closeTask(qeTask.outwardIssue?.key);
47926
+ }
47911
47927
  process.exit(0);
47912
47928
  }
47913
47929
 
@@ -48014,7 +48030,7 @@ var issueStatusSchema = external_exports.union([
48014
48030
  // src/cli.ts
48015
48031
  function cli() {
48016
48032
  const program2 = new Command();
48017
- program2.name("jira-sprinter").description("\u{1F3C3} Small CLI tool to manage sprints in JIRA Board").version("2.2.0");
48033
+ program2.name("jira-sprinter").description("\u{1F3C3} Small CLI tool to manage sprints in JIRA Board").version("2.3.0");
48018
48034
  program2.addCommand(
48019
48035
  new Command("auto").description(
48020
48036
  "Automatically manages split tasks (Preliminary Testing and QE) based on ticket state and status"
@@ -48074,7 +48090,7 @@ var runProgram = async () => {
48074
48090
  issues = await jira.getIssuesInSprint(sprintOrBacklog, options.assignee);
48075
48091
  }
48076
48092
  if (issues.length === 0) {
48077
- logger.log(`${source_default.green("No issues found")}.`);
48093
+ logger.log(source_default.green("No issues found"));
48078
48094
  process.exit(0);
48079
48095
  }
48080
48096
  for (const issue2 of issues) {
@@ -48134,7 +48150,7 @@ ${issueTypeSchema.parse(issue2.fields?.issuetype.name)} ${issue2.key} - ${source
48134
48150
  );
48135
48151
  if (Array.isArray(tasks) && tasks.length >= answer.length) break;
48136
48152
  if (attempt < 10) {
48137
- logger.log(`Waiting for tasks to be created...`);
48153
+ logger.log(source_default.dim("Waiting for tasks to be created..."));
48138
48154
  await new Promise((resolve) => setTimeout(resolve, 5e3));
48139
48155
  }
48140
48156
  }
@@ -48142,7 +48158,7 @@ ${issueTypeSchema.parse(issue2.fields?.issuetype.name)} ${issue2.key} - ${source
48142
48158
  if (task.fields.summary.includes("QE Task")) {
48143
48159
  continue;
48144
48160
  }
48145
- logger.log(`${source_default.italic(task.fields.summary)}`);
48161
+ logger.log(source_default.italic(task.fields.summary));
48146
48162
  let storyPointsAnswer;
48147
48163
  let addToSprintAnswer;
48148
48164
  if (!options.yolo) {
@@ -48197,7 +48213,7 @@ ${issueTypeSchema.parse(issue2.fields?.issuetype.name)} ${issue2.key} - ${source
48197
48213
  storyPointsAnswer = issue2.fields?.[jira.fields.storyPoints];
48198
48214
  addToSprintAnswer = sprintOrBacklog != -1;
48199
48215
  logger.log(
48200
- `Setting story points to ${colorSizeSchema.parse(storyPointsAnswer)} and adding it to the sprint.`
48216
+ ` ${source_default.cyan("Story points")}: ${colorSizeSchema.parse(storyPointsAnswer)}, ${source_default.cyan("Sprint")}: ${addToSprintAnswer ? source_default.green("yes") : source_default.red("no")}`
48201
48217
  );
48202
48218
  }
48203
48219
  await jira.setValues(task.key, {
@@ -48207,7 +48223,7 @@ ${issueTypeSchema.parse(issue2.fields?.issuetype.name)} ${issue2.key} - ${source
48207
48223
  });
48208
48224
  }
48209
48225
  logger.log(
48210
- `Dropping ${source_default.bold(issue2.key)} from sprint and setting story points to ${source_default.bold(0)}...`
48226
+ `${source_default.dim(`Resetting ${issue2.key} \u2014 story points \u2192 0, removing from sprint`)}`
48211
48227
  );
48212
48228
  await jira.setValues(issue2.key, { size: 0, sprint: null });
48213
48229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jira-sprinter",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Small CLI tool to manage sprints in JIRA Board",
5
5
  "main": "src/main.ts",
6
6
  "type": "commonjs",
@@ -9,7 +9,7 @@
9
9
  "test": "tests"
10
10
  },
11
11
  "scripts": {
12
- "build": "esbuild ./src/main.js --bundle --outdir=dist --platform=node --target=node20.0.0 --packages=bundle",
12
+ "build": "esbuild ./src/main.js --bundle --outdir=dist --platform=node --target=node20.0.0 --packages=bundle --external:kerberos",
13
13
  "format": "prettier --write '**/*.ts'",
14
14
  "format-check": "prettier --check '**/*.ts'",
15
15
  "test": "vitest run --coverage",
@@ -41,6 +41,8 @@
41
41
  "commander": "15.0.0",
42
42
  "dotenv": "17.4.2",
43
43
  "jira.js": "5.4.0",
44
+ "kerberos": "^7.0.0",
45
+ "product-pages": "^1.0.0",
44
46
  "zod": "4.4.3"
45
47
  },
46
48
  "devDependencies": {
package/src/auto.ts CHANGED
@@ -3,15 +3,38 @@ import { OptionValues } from 'commander';
3
3
 
4
4
  import { Logger } from './logger';
5
5
  import { Jira } from './jira';
6
+ import { ProductPages } from './product-pages';
7
+ import {
8
+ readDeadlines,
9
+ DEFAULT_DEADLINES_PATH,
10
+ computePreliminaryTestingDueDate,
11
+ computeQeTaskDueDate,
12
+ classifyScheduleTasks,
13
+ SCHEDULE_TASK_REGEX,
14
+ type ReleaseDeadlines,
15
+ } from './schema/deadlines';
6
16
 
7
17
  export async function runAuto(options: OptionValues): Promise<void> {
8
18
  const logger = new Logger(!!options.nocolor);
19
+ const deadlinesFile: string = options.deadlinesFile ?? DEFAULT_DEADLINES_PATH;
9
20
 
10
21
  const jira = await Jira.getInstance(options.dry, logger, options.assignee);
11
22
 
23
+ logger.log(
24
+ `${chalk.cyan(`Verifying issues in board ${options.board} - team: ${options.team}`)}`
25
+ );
26
+
12
27
  const boardIssues = await jira.getBoardIssues(
13
28
  options.board,
14
- `project = RHEL and issuetype in (Bug, Story, Vulnerability) and statusCategory != Done`
29
+ `project = RHEL and issuetype in (Bug, Story, Vulnerability) and status != Closed`
30
+ );
31
+
32
+ const uniqueReleases = extractUniqueReleases(boardIssues);
33
+ const deadlinesDb = await loadDeadlines(
34
+ uniqueReleases,
35
+ deadlinesFile,
36
+ !!options.dry,
37
+ logger
15
38
  );
16
39
 
17
40
  const preliminaryTestingRequested = boardIssues.filter(
@@ -26,11 +49,6 @@ export async function runAuto(options: OptionValues): Promise<void> {
26
49
  l.outwardIssue?.fields?.status.name !== 'Closed'
27
50
  )
28
51
  );
29
- if (preliminaryTestingRequested.length > 0) {
30
- logger.log(
31
- `${chalk.green('Preliminary Testing Requested')} - ${preliminaryTestingRequested.length}`
32
- );
33
- }
34
52
 
35
53
  const preliminaryTestingFailed = boardIssues.filter(
36
54
  issue =>
@@ -44,11 +62,6 @@ export async function runAuto(options: OptionValues): Promise<void> {
44
62
  l.outwardIssue?.fields?.status.name !== 'Closed'
45
63
  )
46
64
  );
47
- if (preliminaryTestingFailed.length > 0) {
48
- logger.log(
49
- `${chalk.red('Preliminary Testing Failed')} - ${preliminaryTestingFailed.length}`
50
- );
51
- }
52
65
 
53
66
  const issuesInIntegration = boardIssues.filter(
54
67
  issue =>
@@ -60,30 +73,66 @@ export async function runAuto(options: OptionValues): Promise<void> {
60
73
  l.outwardIssue?.fields?.status.name !== 'Closed'
61
74
  )
62
75
  );
63
- if (issuesInIntegration.length > 0) {
64
- logger.log(
65
- `${chalk.yellow('Issues in Integration w/o QE Task')} - ${issuesInIntegration.length}`
66
- );
76
+
77
+ const issuesInReleasePending = boardIssues.filter(
78
+ issue =>
79
+ issue.fields?.status.name === 'Release Pending' &&
80
+ issue.fields?.issuelinks?.some(
81
+ l =>
82
+ l.type?.outward === 'split to' &&
83
+ l.outwardIssue?.fields?.summary.startsWith(jira.qeTask.summary) &&
84
+ l.outwardIssue?.fields?.status.name !== 'Closed'
85
+ )
86
+ );
87
+
88
+ const summary = [
89
+ {
90
+ label: 'Preliminary Testing Requested',
91
+ count: preliminaryTestingRequested.length,
92
+ },
93
+ {
94
+ label: 'Preliminary Testing Failed',
95
+ count: preliminaryTestingFailed.length,
96
+ },
97
+ { label: 'Integration w/o QE Task', count: issuesInIntegration.length },
98
+ {
99
+ label: 'Release Pending w/ QE Task',
100
+ count: issuesInReleasePending.length,
101
+ },
102
+ ].filter(entry => entry.count > 0);
103
+
104
+ for (const { label, count } of summary) {
105
+ logger.log(` ${chalk.cyan(label)}: ${chalk.bold(count)}`);
67
106
  }
68
107
 
69
- if (preliminaryTestingRequested.length > 0) {
70
- logger.log(
71
- `${chalk.cyan('Creating split tasks for Preliminary Testing Requested...')}`
72
- );
108
+ if (summary.length === 0) {
109
+ logger.log(` ${chalk.green('Nothing to do')}`);
73
110
  }
74
111
 
112
+ type DueDateJob = {
113
+ parentKey: string;
114
+ taskName: string;
115
+ summaryPrefix: string;
116
+ dueDate: string;
117
+ };
118
+ const dueDateJobs: DueDateJob[] = [];
119
+
75
120
  for (const issue of preliminaryTestingRequested) {
76
121
  if (!issue.key) {
77
122
  continue;
78
123
  }
79
124
 
80
125
  await jira.createTasks(issue.key, [jira.preliminaryTestingTask.value]);
81
- }
82
126
 
83
- if (preliminaryTestingFailed.length > 0) {
84
- logger.log(
85
- `${chalk.red('Closing Preliminary Testing Task - Testing Failed...')}`
86
- );
127
+ const dueDate = resolveDueDate(issue, 'preliminary', deadlinesDb, logger);
128
+ if (dueDate) {
129
+ dueDateJobs.push({
130
+ parentKey: issue.key,
131
+ taskName: jira.preliminaryTestingTask.name,
132
+ summaryPrefix: jira.preliminaryTestingTask.summary,
133
+ dueDate,
134
+ });
135
+ }
87
136
  }
88
137
 
89
138
  for (const issue of preliminaryTestingFailed) {
@@ -102,7 +151,7 @@ export async function runAuto(options: OptionValues): Promise<void> {
102
151
 
103
152
  if (!preliminaryTestingTask || !preliminaryTestingTask.outwardIssue?.key) {
104
153
  logger.log(
105
- `${chalk.red('Preliminary Testing Task not found')} - ${issue.key}`
154
+ ` ${chalk.red('Preliminary Testing Task not found')} - ${issue.key}`
106
155
  );
107
156
  continue;
108
157
  }
@@ -110,19 +159,184 @@ export async function runAuto(options: OptionValues): Promise<void> {
110
159
  await jira.closeTask(preliminaryTestingTask.outwardIssue?.key);
111
160
  }
112
161
 
113
- if (issuesInIntegration.length > 0) {
114
- logger.log(
115
- `${chalk.cyan('Creating split tasks for Issues in Integration w/o QE Task...')}`
116
- );
117
- }
118
-
119
162
  for (const issue of issuesInIntegration) {
120
163
  if (!issue.key) {
121
164
  continue;
122
165
  }
123
166
 
124
167
  await jira.createTasks(issue.key, [jira.qeTask.value]);
168
+
169
+ const dueDate = resolveDueDate(issue, 'qe', deadlinesDb, logger);
170
+ if (dueDate) {
171
+ dueDateJobs.push({
172
+ parentKey: issue.key,
173
+ taskName: jira.qeTask.name,
174
+ summaryPrefix: jira.qeTask.summary,
175
+ dueDate,
176
+ });
177
+ }
178
+ }
179
+
180
+ for (const issue of issuesInReleasePending) {
181
+ if (!issue.key) {
182
+ continue;
183
+ }
184
+
185
+ const qeTask = issue.fields?.issuelinks?.find(
186
+ l =>
187
+ l.type?.outward === 'split to' &&
188
+ l.outwardIssue?.fields?.summary.startsWith(jira.qeTask.summary) &&
189
+ l.outwardIssue?.fields?.status.name !== 'Closed'
190
+ );
191
+
192
+ if (!qeTask || !qeTask.outwardIssue?.key) {
193
+ logger.log(` ${chalk.red('QE Task not found')} - ${issue.key}`);
194
+ continue;
195
+ }
196
+
197
+ await jira.closeTask(qeTask.outwardIssue?.key);
198
+ }
199
+
200
+ const dueDateResults = await Promise.allSettled(
201
+ dueDateJobs.map(job =>
202
+ setDueDateOnSplitTask(
203
+ jira,
204
+ job.parentKey,
205
+ job.taskName,
206
+ job.summaryPrefix,
207
+ job.dueDate,
208
+ logger
209
+ )
210
+ )
211
+ );
212
+ for (const result of dueDateResults) {
213
+ if (result.status === 'rejected') {
214
+ logger.log(chalk.yellow(` Due date operation failed: ${result.reason}`));
215
+ }
125
216
  }
126
217
 
127
218
  process.exit(0);
128
219
  }
220
+
221
+ function extractUniqueReleases(
222
+ issues: { fields?: Record<string, any> }[]
223
+ ): string[] {
224
+ const releases = new Set<string>();
225
+ for (const issue of issues) {
226
+ const fixVersions = issue.fields?.fixVersions;
227
+ if (Array.isArray(fixVersions)) {
228
+ for (const v of fixVersions as { name?: string }[]) {
229
+ if (v?.name) releases.add(v.name);
230
+ }
231
+ }
232
+ }
233
+ return [...releases];
234
+ }
235
+
236
+ async function loadDeadlines(
237
+ releases: string[],
238
+ deadlinesFile: string,
239
+ dry: boolean,
240
+ logger: Logger
241
+ ): Promise<Record<string, ReleaseDeadlines>> {
242
+ const pp = ProductPages.getInstance(dry, logger);
243
+
244
+ // If authentication fails, fall back to the entire cached file.
245
+ try {
246
+ const whoami = await pp.whoami();
247
+ logger.log(chalk.dim(`Authenticated as: ${whoami.username}`));
248
+ } catch {
249
+ logger.log(
250
+ chalk.yellow('Product Pages unavailable, using cached deadlines')
251
+ );
252
+ const cached = readDeadlines(deadlinesFile);
253
+ if (!cached) {
254
+ logger.log(chalk.red(`No cached deadlines found at ${deadlinesFile}`));
255
+ return {};
256
+ }
257
+ logger.log(chalk.dim(`Using cached data from ${cached.updated_at}`));
258
+ return cached.releases;
259
+ }
260
+
261
+ // Fetch per-release — skip individual failures so a single bad release
262
+ // does not discard fresh data already retrieved for other releases.
263
+ const result: Record<string, ReleaseDeadlines> = {};
264
+ for (const release of releases) {
265
+ try {
266
+ const tasks = await pp.getScheduleTasks(release, {
267
+ name__regex: SCHEDULE_TASK_REGEX,
268
+ });
269
+ result[release] = classifyScheduleTasks(tasks);
270
+ } catch {
271
+ logger.log(
272
+ chalk.yellow(` Could not fetch deadlines for ${release}, skipping`)
273
+ );
274
+ }
275
+ }
276
+ return result;
277
+ }
278
+
279
+ async function setDueDateOnSplitTask(
280
+ jira: Jira,
281
+ parentKey: string,
282
+ taskName: string,
283
+ summaryPrefix: string,
284
+ dueDate: string,
285
+ logger: Logger
286
+ ): Promise<void> {
287
+ for (let attempt = 1; attempt <= 10; attempt++) {
288
+ const tasks = await jira.getlinkedTasks(parentKey, [taskName]);
289
+ const splitTask = tasks.find(t =>
290
+ t.fields?.summary?.startsWith(summaryPrefix)
291
+ );
292
+
293
+ if (splitTask?.key) {
294
+ await jira.setDueDate(splitTask.key, dueDate);
295
+ return;
296
+ }
297
+
298
+ if (attempt < 10) {
299
+ logger.log(chalk.dim(` Waiting for ${taskName} on ${parentKey}...`));
300
+ await new Promise(resolve => setTimeout(resolve, 5000));
301
+ }
302
+ }
303
+
304
+ logger.log(
305
+ chalk.yellow(
306
+ ` ${taskName} not found for ${parentKey} — could not set due date`
307
+ )
308
+ );
309
+ }
310
+
311
+ function resolveDueDate(
312
+ issue: { key?: string; fields?: Record<string, any> },
313
+ taskType: 'preliminary' | 'qe',
314
+ deadlinesDb: Record<string, ReleaseDeadlines>,
315
+ logger: Logger
316
+ ): string | null {
317
+ const fixVersion = issue.fields?.fixVersions?.[0]?.name;
318
+ if (!fixVersion) {
319
+ logger.log(
320
+ chalk.dim(` No fixVersion on ${issue.key} — skipping due date`)
321
+ );
322
+ return null;
323
+ }
324
+
325
+ const deadlines = deadlinesDb[fixVersion];
326
+ if (!deadlines) {
327
+ logger.log(
328
+ chalk.dim(
329
+ ` No deadlines for ${fixVersion} — skipping due date on ${issue.key}`
330
+ )
331
+ );
332
+ return null;
333
+ }
334
+
335
+ const isZStream = fixVersion.endsWith('.z');
336
+
337
+ if (taskType === 'preliminary') {
338
+ return computePreliminaryTestingDueDate(deadlines, isZStream);
339
+ }
340
+
341
+ return computeQeTaskDueDate(deadlines, isZStream);
342
+ }
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { Jira } from './jira';
7
7
  import { Logger } from './logger';
8
8
  import { getDefaultValue, getOptions } from './util';
9
9
  import { runAuto } from './auto';
10
+ import { runPpSync } from './pp-sync';
10
11
 
11
12
  import { SearchResults } from 'jira.js/dist/esm/types/agile/models';
12
13
  import {
@@ -24,7 +25,7 @@ export function cli(): Command {
24
25
  program
25
26
  .name('jira-sprinter')
26
27
  .description('🏃 Small CLI tool to manage sprints in JIRA Board')
27
- .version('2.2.0');
28
+ .version('2.4.0');
28
29
 
29
30
  program.addCommand(
30
31
  new Command('auto')
@@ -42,11 +43,36 @@ export function cli(): Command {
42
43
  'Jira Components',
43
44
  getDefaultValue('COMPONENTS')
44
45
  )
46
+ .option(
47
+ '--deadlines-file [path]',
48
+ 'Path to deadlines JSON file',
49
+ getDefaultValue('DEADLINES_FILE')
50
+ )
45
51
  .action(async (_opts, command) => {
46
52
  await runAuto(command.optsWithGlobals());
47
53
  })
48
54
  );
49
55
 
56
+ program.addCommand(
57
+ new Command('pp-sync')
58
+ .description(
59
+ 'Fetch REL_PREP and ITM 26 deadlines from Product Pages and save to a local file'
60
+ )
61
+ .argument(
62
+ '<releases...>',
63
+ 'Release shortnames (e.g. rhel-9.9 rhel-9.8.z)'
64
+ )
65
+ .option('-x, --dry', 'dry run', getDefaultValue('DRY'))
66
+ .option(
67
+ '--deadlines-file [path]',
68
+ 'Path to deadlines JSON file',
69
+ getDefaultValue('DEADLINES_FILE')
70
+ )
71
+ .action(async (releases: string[], _opts, command) => {
72
+ await runPpSync(releases, command.optsWithGlobals());
73
+ })
74
+ );
75
+
50
76
  program
51
77
  .option('-b, --board [board]', 'Jira Board ID', getDefaultValue('BOARD'))
52
78
  .option(
@@ -106,7 +132,7 @@ const runProgram = async () => {
106
132
  }
107
133
 
108
134
  if (issues.length === 0) {
109
- logger.log(`${chalk.green('No issues found')}.`);
135
+ logger.log(chalk.green('No issues found'));
110
136
  process.exit(0);
111
137
  }
112
138
 
@@ -179,7 +205,7 @@ const runProgram = async () => {
179
205
 
180
206
  if (Array.isArray(tasks) && tasks.length >= answer.length) break;
181
207
  if (attempt < 10) {
182
- logger.log(`Waiting for tasks to be created...`);
208
+ logger.log(chalk.dim('Waiting for tasks to be created...'));
183
209
  await new Promise(resolve => setTimeout(resolve, 5000));
184
210
  }
185
211
  }
@@ -190,7 +216,7 @@ const runProgram = async () => {
190
216
  continue;
191
217
  }
192
218
 
193
- logger.log(`${chalk.italic(task.fields.summary)}`);
219
+ logger.log(chalk.italic(task.fields.summary));
194
220
 
195
221
  let storyPointsAnswer: Size | undefined;
196
222
  let addToSprintAnswer: boolean | undefined;
@@ -248,7 +274,7 @@ const runProgram = async () => {
248
274
  storyPointsAnswer = issue.fields?.[jira.fields.storyPoints];
249
275
  addToSprintAnswer = sprintOrBacklog != -1;
250
276
  logger.log(
251
- `Setting story points to ${colorSizeSchema.parse(storyPointsAnswer)} and adding it to the sprint.`
277
+ ` ${chalk.cyan('Story points')}: ${colorSizeSchema.parse(storyPointsAnswer)}, ${chalk.cyan('Sprint')}: ${addToSprintAnswer ? chalk.green('yes') : chalk.red('no')}`
252
278
  );
253
279
  }
254
280
 
@@ -263,7 +289,7 @@ const runProgram = async () => {
263
289
  }
264
290
 
265
291
  logger.log(
266
- `Dropping ${chalk.bold(issue.key)} from sprint and setting story points to ${chalk.bold(0)}...`
292
+ `${chalk.dim(`Resetting ${issue.key} story points 0, removing from sprint`)}`
267
293
  );
268
294
  await jira.setValues(issue.key!, { size: 0, sprint: null });
269
295
  }
package/src/jira.ts CHANGED
@@ -2,6 +2,8 @@ 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 chalk from 'chalk';
6
+
5
7
  import { raise, tokenUnavailable } from './util';
6
8
  import { Size } from './schema/jira';
7
9
  import { Logger } from './logger';
@@ -178,6 +180,7 @@ export class Jira {
178
180
  'assignee',
179
181
  'priority',
180
182
  'components',
183
+ 'fixVersions',
181
184
  this.fields.storyPoints,
182
185
  this.fields.severity,
183
186
  this.fields.preliminaryTesting,
@@ -194,27 +197,15 @@ export class Jira {
194
197
 
195
198
  async getlinkedTasks(issue: string, expectedTasks: string[]) {
196
199
  if (this.dry) {
197
- this.logger.log(`Would get linked tasks for issue: ${issue}`);
198
- return [
199
- {
200
- key: 'RHEL-1234',
201
- fields: {
202
- summary: '[DEV Task] Test Task',
203
- },
204
- },
205
- {
206
- key: 'RHEL-1235',
207
- fields: {
208
- summary: '[QE Task] Test Task',
209
- },
210
- },
211
- {
212
- key: 'RHEL-1236',
213
- fields: {
214
- summary: '[Upstream] Test Task',
215
- },
200
+ this.logger.log(
201
+ ` ${chalk.dim(`Fetching linked tasks for ${issue} (dry-run)`)}`
202
+ );
203
+ return expectedTasks.map((name, i) => ({
204
+ key: `${issue}-SPLIT-${i + 1}`,
205
+ fields: {
206
+ summary: `[${name}]: ${issue} (dry-run)`,
216
207
  },
217
- ] as unknown as Issue[];
208
+ })) as unknown as Issue[];
218
209
  }
219
210
 
220
211
  const response =
@@ -237,12 +228,14 @@ export class Jira {
237
228
  async createTasks(issue: string, tasks: string[]) {
238
229
  if (this.dry) {
239
230
  this.logger.log(
240
- `Would create tasks: ${tasks.join(', ')} for issue: ${issue}`
231
+ ` ${chalk.dim(`Creating tasks ${tasks.join(', ')} for ${issue} (dry-run)`)}`
241
232
  );
242
233
  return;
243
234
  }
244
235
 
245
- this.logger.log(`Creating tasks: ${tasks.join(', ')} for issue: ${issue}`);
236
+ this.logger.log(
237
+ ` ${chalk.cyan(`Creating tasks ${tasks.join(', ')} for ${issue}`)}`
238
+ );
246
239
  await this.api.issues.editIssue({
247
240
  issueIdOrKey: issue,
248
241
  fields: {
@@ -254,11 +247,11 @@ export class Jira {
254
247
 
255
248
  async closeTask(issue: string) {
256
249
  if (this.dry) {
257
- this.logger.log(`Would close task: ${issue}`);
250
+ this.logger.log(` ${chalk.dim(`Closing task ${issue} (dry-run)`)}`);
258
251
  return;
259
252
  }
260
253
 
261
- this.logger.log(`Closing task: ${issue}`);
254
+ this.logger.log(` ${chalk.cyan(`Closing task ${issue}`)}`);
262
255
 
263
256
  await this.api.issues.doTransition({
264
257
  issueIdOrKey: issue,
@@ -294,6 +287,23 @@ export class Jira {
294
287
  });
295
288
  }
296
289
 
290
+ async setDueDate(issue: string, dueDate: string) {
291
+ if (this.dry) {
292
+ this.logger.log(
293
+ ` ${chalk.dim(`Setting due date ${dueDate} on ${issue} (dry-run)`)}`
294
+ );
295
+ return;
296
+ }
297
+
298
+ this.logger.log(
299
+ ` ${chalk.cyan(`Setting due date ${dueDate} on ${issue}`)}`
300
+ );
301
+ await this.api.issues.editIssue({
302
+ issueIdOrKey: issue,
303
+ fields: { duedate: dueDate },
304
+ });
305
+ }
306
+
297
307
  getIssueURL(issue: string) {
298
308
  return `${this.instance}/browse/${issue}`;
299
309
  }
@@ -310,7 +320,7 @@ export class Jira {
310
320
  );
311
321
 
312
322
  const version = await instance.getVersion();
313
- console.debug(`JIRA Version: ${version}`);
323
+ logger.log(chalk.dim(`JIRA v${version}`));
314
324
 
315
325
  return instance;
316
326
  }
package/src/pp-sync.ts ADDED
@@ -0,0 +1,73 @@
1
+ import chalk from 'chalk';
2
+ import { OptionValues } from 'commander';
3
+
4
+ import { Logger } from './logger';
5
+ import { ProductPages } from './product-pages';
6
+ import {
7
+ readDeadlines,
8
+ writeDeadlines,
9
+ DEFAULT_DEADLINES_PATH,
10
+ classifyScheduleTasks,
11
+ SCHEDULE_TASK_REGEX,
12
+ type DeadlinesFile,
13
+ ReleaseDeadlines,
14
+ } from './schema/deadlines';
15
+
16
+ export async function runPpSync(
17
+ releases: string[],
18
+ options: OptionValues
19
+ ): Promise<void> {
20
+ const logger = new Logger(!!options.nocolor);
21
+ const pp = ProductPages.getInstance(!!options.dry, logger);
22
+ const filePath: string = options.deadlinesFile ?? DEFAULT_DEADLINES_PATH;
23
+
24
+ const whoami = await pp.whoami();
25
+ logger.log(chalk.dim(`Authenticated as: ${whoami.username}`));
26
+
27
+ const existing = readDeadlines(filePath);
28
+ const mergedReleases: Record<string, ReleaseDeadlines> = {
29
+ ...(existing?.releases ?? {}),
30
+ };
31
+
32
+ for (const release of releases) {
33
+ logger.log(`\n${chalk.cyan('Syncing')} ${chalk.bold(release)}...`);
34
+
35
+ try {
36
+ const tasks = await pp.getScheduleTasks(release, {
37
+ name__regex: SCHEDULE_TASK_REGEX,
38
+ });
39
+
40
+ const deadlines = classifyScheduleTasks(tasks);
41
+ mergedReleases[release] = deadlines;
42
+
43
+ if (deadlines.rel_prep.length > 0) {
44
+ for (const entry of deadlines.rel_prep) {
45
+ logger.log(
46
+ ` ${chalk.cyan(entry.name)}: ${chalk.bold(entry.date_finish)}`
47
+ );
48
+ }
49
+ } else {
50
+ logger.log(` REL_PREP: ${chalk.dim('not found')}`);
51
+ }
52
+ const itm26 = deadlines.itm_26
53
+ ? chalk.bold(deadlines.itm_26)
54
+ : chalk.dim('not found');
55
+ logger.log(` ITM 26: ${itm26}`);
56
+ } catch (error) {
57
+ logger.log(chalk.yellow(` Failed to sync ${release}: ${error}`));
58
+ }
59
+ }
60
+
61
+ const data: DeadlinesFile = {
62
+ updated_at: new Date().toISOString(),
63
+ releases: mergedReleases,
64
+ };
65
+
66
+ if (options.dry) {
67
+ logger.log(chalk.dim(`\nDry run — would write to ${filePath}`));
68
+ logger.log(chalk.dim(JSON.stringify(data, null, 2)));
69
+ } else {
70
+ writeDeadlines(data, filePath);
71
+ logger.log(`\n${chalk.green('Saved')} ${chalk.underline(filePath)}`);
72
+ }
73
+ }
@@ -0,0 +1,89 @@
1
+ import ProductPagesAPI, {
2
+ isError,
3
+ type ReleasesScheduleTasksResponse,
4
+ type WhoamiResponse,
5
+ type ScheduleTasksQueryOptions,
6
+ } from 'product-pages';
7
+
8
+ import chalk from 'chalk';
9
+
10
+ import { Logger } from './logger';
11
+
12
+ const PP_INSTANCE = 'https://pp.engineering.redhat.com/api/v7';
13
+
14
+ export class ProductPages {
15
+ readonly api: ProductPagesAPI;
16
+
17
+ constructor(
18
+ readonly dry: boolean,
19
+ readonly logger: Logger,
20
+ instance: string = PP_INSTANCE
21
+ ) {
22
+ this.api = new ProductPagesAPI(instance, { type: 'kerberos' });
23
+ }
24
+
25
+ async whoami(): Promise<WhoamiResponse> {
26
+ if (this.dry) {
27
+ this.logger.log(chalk.dim('Fetching whoami (dry-run)'));
28
+ return { username: 'dry-run@redhat.com' };
29
+ }
30
+
31
+ const response = await this.api.whoami();
32
+
33
+ if (isError(response)) {
34
+ throw new Error(
35
+ `Product Pages whoami failed: ${(response as any).message}`
36
+ );
37
+ }
38
+
39
+ return response;
40
+ }
41
+
42
+ async getScheduleTasks(
43
+ release: string,
44
+ options: ScheduleTasksQueryOptions
45
+ ): Promise<ReleasesScheduleTasksResponse> {
46
+ if (this.dry) {
47
+ this.logger.log(
48
+ chalk.dim(`Fetching schedule tasks for ${release} (dry-run)`)
49
+ );
50
+ // Return example tasks that match the REL_PREP and ITM 26 patterns so
51
+ // the dry-run accurately simulates the due-date logic.
52
+ const soon = new Date();
53
+ soon.setDate(soon.getDate() + 10);
54
+ const soonStr = `${soon.getFullYear()}-${String(soon.getMonth() + 1).padStart(2, '0')}-${String(soon.getDate()).padStart(2, '0')}`;
55
+ return [
56
+ {
57
+ id: 1,
58
+ name: 'Package Advisory REL_PREP Deadline (dry-run)',
59
+ path: ['REL_PREP'],
60
+ date_start: soonStr,
61
+ date_finish: soonStr,
62
+ release_shortname: release,
63
+ },
64
+ {
65
+ id: 2,
66
+ name: 'ITM 26 DevTestDoc (dry-run)',
67
+ path: ['ITM 26'],
68
+ date_start: soonStr,
69
+ date_finish: soonStr,
70
+ release_shortname: release,
71
+ },
72
+ ];
73
+ }
74
+
75
+ const response = await this.api.releasesScheduleTasks(release, options);
76
+
77
+ if (isError(response)) {
78
+ throw new Error(
79
+ `Product Pages schedule tasks failed: ${(response as any).message}`
80
+ );
81
+ }
82
+
83
+ return response;
84
+ }
85
+
86
+ static getInstance(dry: boolean, logger: Logger): ProductPages {
87
+ return new ProductPages(dry, logger);
88
+ }
89
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { z } from 'zod';
5
+
6
+ const relPrepEntrySchema = z.object({
7
+ name: z.string(),
8
+ date_finish: z.string(),
9
+ });
10
+
11
+ export type RelPrepEntry = z.infer<typeof relPrepEntrySchema>;
12
+
13
+ const releaseDeadlinesSchema = z.object({
14
+ rel_prep: z.array(relPrepEntrySchema),
15
+ itm_26: z.string().nullable(),
16
+ });
17
+
18
+ const deadlinesFileSchema = z.object({
19
+ updated_at: z.string(),
20
+ releases: z.record(z.string(), releaseDeadlinesSchema),
21
+ });
22
+
23
+ export type ReleaseDeadlines = z.infer<typeof releaseDeadlinesSchema>;
24
+ export type DeadlinesFile = z.infer<typeof deadlinesFileSchema>;
25
+
26
+ export const DEFAULT_DEADLINES_PATH = path.resolve(
27
+ os.homedir(),
28
+ '.config',
29
+ 'jira-sprinter',
30
+ 'deadlines.json'
31
+ );
32
+
33
+ export const SCHEDULE_TASK_REGEX =
34
+ '.*(Package Advisory REL_PREP Deadline|ITM 26 DevTestDoc).*';
35
+
36
+ export function readDeadlines(
37
+ filePath: string = DEFAULT_DEADLINES_PATH
38
+ ): DeadlinesFile | null {
39
+ try {
40
+ const raw = fs.readFileSync(filePath, 'utf-8');
41
+ return deadlinesFileSchema.parse(JSON.parse(raw));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function formatDate(date: Date): string {
48
+ const y = date.getFullYear();
49
+ const m = String(date.getMonth() + 1).padStart(2, '0');
50
+ const d = String(date.getDate()).padStart(2, '0');
51
+ return `${y}-${m}-${d}`;
52
+ }
53
+
54
+ export function classifyScheduleTasks(
55
+ tasks: { name: string; date_finish: string }[]
56
+ ): ReleaseDeadlines {
57
+ const deadlines: ReleaseDeadlines = { rel_prep: [], itm_26: null };
58
+ for (const task of tasks) {
59
+ if (task.name.includes('REL_PREP')) {
60
+ deadlines.rel_prep.push({
61
+ name: task.name,
62
+ date_finish: task.date_finish,
63
+ });
64
+ } else if (task.name.includes('ITM 26')) {
65
+ deadlines.itm_26 = task.date_finish;
66
+ }
67
+ }
68
+ return deadlines;
69
+ }
70
+
71
+ export function closestFutureDate(
72
+ dates: string[],
73
+ today: Date = new Date()
74
+ ): string | null {
75
+ const todayStr = formatDate(today);
76
+ const future = dates.filter(d => d >= todayStr).sort();
77
+ return future.length > 0 ? future[0] : null;
78
+ }
79
+
80
+ export function computePreliminaryTestingDueDate(
81
+ deadlines: ReleaseDeadlines,
82
+ isZStream: boolean,
83
+ today: Date = new Date()
84
+ ): string | null {
85
+ const twoWeeks = new Date(today);
86
+ twoWeeks.setDate(twoWeeks.getDate() + 14);
87
+ const twoWeeksStr = formatDate(twoWeeks);
88
+
89
+ if (isZStream) {
90
+ const closest = closestFutureDate(
91
+ deadlines.rel_prep.map(e => e.date_finish),
92
+ today
93
+ );
94
+ if (!closest) return twoWeeksStr;
95
+ return closest < twoWeeksStr ? closest : twoWeeksStr;
96
+ }
97
+
98
+ if (deadlines.itm_26) {
99
+ const todayStr = formatDate(today);
100
+ if (deadlines.itm_26 >= todayStr && deadlines.itm_26 < twoWeeksStr) {
101
+ return deadlines.itm_26;
102
+ }
103
+ }
104
+
105
+ return twoWeeksStr;
106
+ }
107
+
108
+ export function computeQeTaskDueDate(
109
+ deadlines: ReleaseDeadlines,
110
+ isZStream: boolean,
111
+ today: Date = new Date()
112
+ ): string | null {
113
+ if (isZStream) {
114
+ return closestFutureDate(
115
+ deadlines.rel_prep.map(e => e.date_finish),
116
+ today
117
+ );
118
+ }
119
+
120
+ if (deadlines.itm_26 && deadlines.itm_26 >= formatDate(today)) {
121
+ return deadlines.itm_26;
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ export function writeDeadlines(
128
+ data: DeadlinesFile,
129
+ filePath: string = DEFAULT_DEADLINES_PATH
130
+ ): void {
131
+ const dir = path.dirname(filePath);
132
+ fs.mkdirSync(dir, { recursive: true });
133
+
134
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
135
+ }
package/src/util.ts CHANGED
@@ -22,7 +22,14 @@ export function isDefaultValuesDisabled(): boolean {
22
22
 
23
23
  export function getDefaultValue(
24
24
  envName:
25
- 'ASSIGNEE' | 'BOARD' | 'TEAM' | 'COMPONENTS' | 'NOCOLOR' | 'DRY' | 'YOLO'
25
+ | 'ASSIGNEE'
26
+ | 'BOARD'
27
+ | 'TEAM'
28
+ | 'COMPONENTS'
29
+ | 'NOCOLOR'
30
+ | 'DRY'
31
+ | 'YOLO'
32
+ | 'DEADLINES_FILE'
26
33
  ) {
27
34
  if (isDefaultValuesDisabled()) {
28
35
  return undefined;