fixdiscover 1.1.0 → 1.2.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 +22968 -12493
- package/package.json +6 -5
- package/src/bugzilla.ts +42 -0
- package/src/cli.ts +59 -5
- package/src/jira.ts +49 -2
- package/src/linkfinder.ts +8 -9
- package/src/logger.ts +4 -4
- package/src/schema/bugzilla.ts +36 -0
- package/src/schema/link.ts +12 -0
- package/src/util.ts +13 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fixdiscover",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Small CLI tool to search for Jira issues with linked PRs and Issues that are fixed in an upstream projects.",
|
|
5
5
|
"main": "src/main.ts",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
"@actions/core": "^1.11.1",
|
|
38
38
|
"@octokit/core": "^6.1.2",
|
|
39
39
|
"@octokit/plugin-throttling": "^9.3.2",
|
|
40
|
-
"@octokit/types": "^13.6.
|
|
40
|
+
"@octokit/types": "^13.6.2",
|
|
41
41
|
"@total-typescript/ts-reset": "0.6.1",
|
|
42
|
+
"bugzilla": "^3.1.4",
|
|
42
43
|
"chalk": "5.3.0",
|
|
43
44
|
"commander": "12.1.0",
|
|
44
45
|
"dotenv": "16.4.5",
|
|
@@ -46,12 +47,12 @@
|
|
|
46
47
|
"zod": "3.23.8"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@types/node": "22.
|
|
50
|
+
"@types/node": "22.10.0",
|
|
50
51
|
"@vitest/coverage-v8": "2.1.5",
|
|
51
52
|
"esbuild": "0.24.0",
|
|
52
|
-
"prettier": "3.
|
|
53
|
+
"prettier": "3.4.0",
|
|
53
54
|
"ts-node": "10.9.2",
|
|
54
|
-
"typescript": "5.
|
|
55
|
+
"typescript": "5.7.2",
|
|
55
56
|
"vitest": "2.1.5"
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/bugzilla.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import BugzillaAPI from 'bugzilla';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
import { Bugs, bugsSchema } from './schema/bugzilla';
|
|
5
|
+
|
|
6
|
+
export class Bugzilla {
|
|
7
|
+
readonly api: BugzillaAPI;
|
|
8
|
+
|
|
9
|
+
readonly tips = {
|
|
10
|
+
approval: 'Bugzilla is approved if it has `release` flag set to `+`',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
readonly instance: string,
|
|
15
|
+
private readonly apiToken: string
|
|
16
|
+
) {
|
|
17
|
+
this.api = new BugzillaAPI(instance, apiToken);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getVersion(): Promise<string> {
|
|
21
|
+
return this.api.version();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async getBugs(bugId: number): Promise<Bugs['bugs']> {
|
|
25
|
+
const command = `bugzilla --bugzilla ${this.instance}/xmlrpc.cgi query --json --bug_id ${bugId}`;
|
|
26
|
+
|
|
27
|
+
let stdout = '';
|
|
28
|
+
try {
|
|
29
|
+
stdout = execSync(command).toString();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(error);
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const raw = bugsSchema.safeParse(JSON.parse(stdout));
|
|
36
|
+
return raw.success ? raw.data.bugs : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getIssueURL(bug: number): string {
|
|
40
|
+
return `${this.instance}/show_bug.cgi?id=${bug}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { Comment } from 'jira.js/out/version2/models';
|
|
3
3
|
|
|
4
|
+
import { Bugzilla } from './bugzilla';
|
|
4
5
|
import { Jira } from './jira';
|
|
5
6
|
import { Logger } from './logger';
|
|
6
7
|
import { getOctokit } from './octokit';
|
|
7
8
|
import { getDefaultValue, getOptions, tokenUnavailable } from './util';
|
|
8
|
-
import { LinkFinder
|
|
9
|
+
import { LinkFinder } from './linkfinder';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
import { IssueLinks, LinkObject } from './schema/link';
|
|
11
12
|
|
|
12
13
|
export function cli(): Command {
|
|
13
14
|
const program = new Command();
|
|
@@ -17,7 +18,7 @@ export function cli(): Command {
|
|
|
17
18
|
.description(
|
|
18
19
|
'🔍 A small CLI tool is used to search for Jira issues with linked PRs and issues that are fixed in upstream projects'
|
|
19
20
|
)
|
|
20
|
-
.version('1.
|
|
21
|
+
.version('1.2.0');
|
|
21
22
|
|
|
22
23
|
program
|
|
23
24
|
.requiredOption(
|
|
@@ -30,6 +31,7 @@ export function cli(): Command {
|
|
|
30
31
|
'upstream project',
|
|
31
32
|
getDefaultValue('UPSTREAM')
|
|
32
33
|
)
|
|
34
|
+
.option('--migrate', 'migrate links from Bugzilla to Jira')
|
|
33
35
|
.option('-n, --nocolor', 'disable color output', getDefaultValue('NOCOLOR'))
|
|
34
36
|
.option('-x, --dry', 'dry run', getDefaultValue('DRY'));
|
|
35
37
|
|
|
@@ -46,6 +48,11 @@ const runProgram = async () => {
|
|
|
46
48
|
const jiraToken = process.env.JIRA_API_TOKEN ?? tokenUnavailable('jira');
|
|
47
49
|
const jira = new Jira('https://issues.redhat.com', jiraToken, options.dry);
|
|
48
50
|
|
|
51
|
+
const bugzillaToken = options.migrate
|
|
52
|
+
? (process.env.BUGZILLA_API_TOKEN ?? tokenUnavailable('bugzilla'))
|
|
53
|
+
: '';
|
|
54
|
+
const bugzilla = new Bugzilla('https://bugzilla.redhat.com', bugzillaToken);
|
|
55
|
+
|
|
49
56
|
const githubToken =
|
|
50
57
|
process.env.GITHUB_API_TOKEN ?? tokenUnavailable('github');
|
|
51
58
|
const octokit = getOctokit(githubToken);
|
|
@@ -56,10 +63,14 @@ const runProgram = async () => {
|
|
|
56
63
|
|
|
57
64
|
const issues = await jira.getIssues(options.component);
|
|
58
65
|
|
|
59
|
-
let data:
|
|
66
|
+
let data: IssueLinks = [];
|
|
60
67
|
|
|
61
68
|
for (const issue of issues) {
|
|
62
69
|
let links: LinkObject[] = [];
|
|
70
|
+
|
|
71
|
+
const bugzillaBug: { bugid: number } | null =
|
|
72
|
+
issue.fields[jira.fields.bugzillaBug];
|
|
73
|
+
|
|
63
74
|
const externalLinks = await jira.getLinks(issue.id);
|
|
64
75
|
|
|
65
76
|
for (const comment of issue.fields.comment.comments as (Comment & {
|
|
@@ -84,9 +95,52 @@ const runProgram = async () => {
|
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
if (options.migrate && bugzillaBug !== null) {
|
|
99
|
+
const bug = (await bugzilla.getBugs(bugzillaBug.bugid))[0];
|
|
100
|
+
|
|
101
|
+
let linksForMigration: LinkObject[] = [];
|
|
102
|
+
|
|
103
|
+
for (const comment of bug.comments) {
|
|
104
|
+
const commentLinks = linkFinder.getLinks(
|
|
105
|
+
comment.text,
|
|
106
|
+
`Bugzilla comment #${comment.count}`
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (commentLinks.length > 0) {
|
|
110
|
+
linksForMigration.push(
|
|
111
|
+
...(await linkFinder.checkUpstream(commentLinks, octokit))
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const link of bug.external_bugs) {
|
|
117
|
+
const upstreamLink = linkFinder.getLinks(link.url, link.description);
|
|
118
|
+
|
|
119
|
+
if (upstreamLink.length > 0) {
|
|
120
|
+
linksForMigration.push(
|
|
121
|
+
...(await linkFinder.checkUpstream(upstreamLink, octokit))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
linksForMigration.forEach(async link => {
|
|
127
|
+
await jira.addLink(
|
|
128
|
+
issue.key,
|
|
129
|
+
link.url,
|
|
130
|
+
`Bugzilla upstream link${link.description ? ` - ${link.description}` : ''}`
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
links.push(...linksForMigration);
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
if (links.length > 0) {
|
|
88
138
|
await jira.setLabels(issue.key, ['backport']);
|
|
89
|
-
data.push({
|
|
139
|
+
data.push({
|
|
140
|
+
key: jira.getIssueURL(issue.key),
|
|
141
|
+
bz: bugzillaBug ? bugzilla.getIssueURL(bugzillaBug.bugid) : undefined,
|
|
142
|
+
links,
|
|
143
|
+
});
|
|
90
144
|
}
|
|
91
145
|
}
|
|
92
146
|
|
package/src/jira.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { raise } from './util';
|
|
|
4
4
|
|
|
5
5
|
export class Jira {
|
|
6
6
|
readonly api: Version2Client;
|
|
7
|
+
readonly fields = {
|
|
8
|
+
bugzillaBug: 'customfield_12316840',
|
|
9
|
+
};
|
|
10
|
+
|
|
7
11
|
readonly baseJQL = 'Project = RHEL AND statusCategory = "To Do"';
|
|
8
12
|
JQL = '';
|
|
9
13
|
|
|
@@ -32,7 +36,14 @@ export class Jira {
|
|
|
32
36
|
|
|
33
37
|
const response = await this.api.issueSearch.searchForIssuesUsingJqlPost({
|
|
34
38
|
jql: this.JQL,
|
|
35
|
-
fields: [
|
|
39
|
+
fields: [
|
|
40
|
+
'id',
|
|
41
|
+
'issuetype',
|
|
42
|
+
'summary',
|
|
43
|
+
'assignee',
|
|
44
|
+
'comment',
|
|
45
|
+
this.fields.bugzillaBug,
|
|
46
|
+
],
|
|
36
47
|
// We should paginate this, let's set 300 for now.
|
|
37
48
|
maxResults: 300,
|
|
38
49
|
});
|
|
@@ -50,7 +61,7 @@ export class Jira {
|
|
|
50
61
|
|
|
51
62
|
async setLabels(key: string, labels: string[]) {
|
|
52
63
|
if (this.dry) {
|
|
53
|
-
console.debug(`DRY: setLabels(${key}, ${labels})`);
|
|
64
|
+
//console.debug(`DRY: setLabels(${key}, ${labels})`);
|
|
54
65
|
return;
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -65,4 +76,40 @@ export class Jira {
|
|
|
65
76
|
getIssueURL(issue: string) {
|
|
66
77
|
return `${this.instance}/browse/${issue}`;
|
|
67
78
|
}
|
|
79
|
+
|
|
80
|
+
async addLink(issue: string, url: string, title: string) {
|
|
81
|
+
const links = await this.api.issueRemoteLinks.getRemoteIssueLinks({
|
|
82
|
+
issueIdOrKey: issue,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for (const link of links) {
|
|
86
|
+
if (link.object === undefined) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (link.object.url === url) {
|
|
91
|
+
console.debug(
|
|
92
|
+
`Link ${url} is already linked with Jira issue ${issue}.`
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.dry) {
|
|
99
|
+
console.debug(`DRY: addLink(${issue}, ${url}, ${title})`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await this.api.issueRemoteLinks.createOrUpdateRemoteIssueLink({
|
|
104
|
+
issueIdOrKey: issue,
|
|
105
|
+
object: {
|
|
106
|
+
title,
|
|
107
|
+
url,
|
|
108
|
+
icon: {
|
|
109
|
+
title: 'GitHub',
|
|
110
|
+
url16x16: 'https://github.githubassets.com/favicon.ico',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
68
115
|
}
|
package/src/linkfinder.ts
CHANGED
|
@@ -4,13 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { CustomOctokit } from './octokit';
|
|
5
5
|
import { escapeRegex } from './util';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
url: z.string(),
|
|
9
|
-
type: z.enum(['pull', 'issues', 'commit']),
|
|
10
|
-
id: z.string(),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
export type LinkObject = z.infer<typeof linkObjectSchema>;
|
|
7
|
+
import { LinkObject, linkObjectSchema } from './schema/link';
|
|
14
8
|
|
|
15
9
|
export class LinkFinder {
|
|
16
10
|
readonly upstream: { org: string; repo: string };
|
|
@@ -33,16 +27,21 @@ export class LinkFinder {
|
|
|
33
27
|
);
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
getLinks(message: string): LinkObject[] {
|
|
37
|
-
|
|
30
|
+
getLinks(message: string, description?: string): LinkObject[] {
|
|
31
|
+
const links = z.array(linkObjectSchema).parse(
|
|
38
32
|
[...message.matchAll(this.regex)].map(match => {
|
|
39
33
|
return {
|
|
40
34
|
url: match[0],
|
|
35
|
+
description,
|
|
41
36
|
type: match[2],
|
|
42
37
|
id: match[3],
|
|
43
38
|
};
|
|
44
39
|
})
|
|
45
40
|
);
|
|
41
|
+
|
|
42
|
+
return links.filter(
|
|
43
|
+
(link, index, self) => index === self.findIndex(el => el.url === link.url)
|
|
44
|
+
);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
async checkUpstream(links: LinkObject[], octokit: CustomOctokit) {
|
package/src/logger.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { IssueLinks } from './schema/link';
|
|
4
4
|
|
|
5
5
|
export class Logger {
|
|
6
6
|
static readonly colorRegex = /\[\d+m/gm;
|
|
@@ -16,7 +16,7 @@ export class Logger {
|
|
|
16
16
|
console.log(message.replace(Logger.colorRegex, ''));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
logResult(data:
|
|
19
|
+
logResult(data: IssueLinks): void {
|
|
20
20
|
if (data.length === 0) {
|
|
21
21
|
this.log(chalk.greenBright('No links found'));
|
|
22
22
|
return;
|
|
@@ -25,8 +25,8 @@ export class Logger {
|
|
|
25
25
|
let issueData = '';
|
|
26
26
|
const result: string[] = [];
|
|
27
27
|
|
|
28
|
-
for (const { key, links } of data) {
|
|
29
|
-
issueData = `${chalk.green(key)}\n`;
|
|
28
|
+
for (const { key, bz, links } of data) {
|
|
29
|
+
issueData = `${chalk.green(key)}\n${bz ? `${chalk.red(bz)}\n` : ''}`;
|
|
30
30
|
|
|
31
31
|
for (const link of links) {
|
|
32
32
|
issueData = issueData.concat(` - ${link.type}: ${link.url}\n`);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const externalLinkSchema = z.array(
|
|
4
|
+
z.object({
|
|
5
|
+
ext_description: z.string(),
|
|
6
|
+
ext_bz_bug_id: z.string(),
|
|
7
|
+
type: z.object({ url: z.string(), type: z.string() }),
|
|
8
|
+
})
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export type ExternalLink = z.infer<typeof externalLinkSchema>;
|
|
12
|
+
|
|
13
|
+
export const linkTransform = (link: ExternalLink[number]) => {
|
|
14
|
+
return {
|
|
15
|
+
description: link.ext_description,
|
|
16
|
+
url: `${link.type.url}${link.ext_bz_bug_id}`,
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const bugsSchema = z.object({
|
|
21
|
+
bugs: z.array(
|
|
22
|
+
z.object({
|
|
23
|
+
id: z.number(),
|
|
24
|
+
summary: z.string(),
|
|
25
|
+
comments: z.array(z.object({ text: z.string(), count: z.number() })),
|
|
26
|
+
external_bugs: externalLinkSchema.transform(links => {
|
|
27
|
+
return Array.from(
|
|
28
|
+
links.filter(link => link.type.type === 'GitHub'),
|
|
29
|
+
linkTransform
|
|
30
|
+
);
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type Bugs = z.infer<typeof bugsSchema>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const linkObjectSchema = z.object({
|
|
4
|
+
url: z.string(),
|
|
5
|
+
description: z.string().optional(),
|
|
6
|
+
type: z.enum(['pull', 'issues', 'commit']),
|
|
7
|
+
id: z.string(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type LinkObject = z.infer<typeof linkObjectSchema>;
|
|
11
|
+
|
|
12
|
+
export type IssueLinks = { key: string; bz?: string; links: LinkObject[] }[];
|
package/src/util.ts
CHANGED
|
@@ -4,13 +4,19 @@ export function raise(error: string): never {
|
|
|
4
4
|
throw new Error(error);
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export function tokenUnavailable(type: 'jira' | 'github'): never {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
export function tokenUnavailable(type: 'jira' | 'bugzilla' | 'github'): never {
|
|
8
|
+
let tokenType: string;
|
|
9
|
+
switch (type) {
|
|
10
|
+
case 'jira':
|
|
11
|
+
tokenType = 'JIRA_API_TOKEN';
|
|
12
|
+
break;
|
|
13
|
+
case 'bugzilla':
|
|
14
|
+
tokenType = 'BUGZILLA_API_TOKEN';
|
|
15
|
+
break;
|
|
16
|
+
case 'github':
|
|
17
|
+
tokenType = 'GITHUB_API_TOKEN';
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
return raise(
|
|
16
22
|
`${tokenType} not set.\nPlease set the ${tokenType} environment variable in '~/.config/fixdiscover/.env' or '~/.env.fixdiscover' or '~/.env.'`
|