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 +1 -0
- package/dist/main.js +58 -42
- package/package.json +4 -2
- package/src/auto.ts +245 -31
- package/src/cli.ts +32 -6
- package/src/jira.ts +35 -25
- package/src/pp-sync.ts +73 -0
- package/src/product-pages.ts +89 -0
- package/src/schema/deadlines.ts +135 -0
- package/src/util.ts +8 -1
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(
|
|
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
|
-
`
|
|
47757
|
+
` ${source_default.dim(`Creating tasks ${tasks.join(", ")} for ${issue2} (dry-run)`)}`
|
|
47756
47758
|
);
|
|
47757
47759
|
return;
|
|
47758
47760
|
}
|
|
47759
|
-
this.logger.log(
|
|
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(`
|
|
47774
|
+
this.logger.log(` ${source_default.dim(`Closing task ${issue2} (dry-run)`)}`);
|
|
47771
47775
|
return;
|
|
47772
47776
|
}
|
|
47773
|
-
this.logger.log(`Closing task
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
47863
|
-
|
|
47864
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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.
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 (
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
231
|
+
` ${chalk.dim(`Creating tasks ${tasks.join(', ')} for ${issue} (dry-run)`)}`
|
|
241
232
|
);
|
|
242
233
|
return;
|
|
243
234
|
}
|
|
244
235
|
|
|
245
|
-
this.logger.log(
|
|
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(`
|
|
250
|
+
this.logger.log(` ${chalk.dim(`Closing task ${issue} (dry-run)`)}`);
|
|
258
251
|
return;
|
|
259
252
|
}
|
|
260
253
|
|
|
261
|
-
this.logger.log(`Closing task
|
|
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
|
-
|
|
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
|
-
|
|
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;
|