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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fixdiscover",
3
- "version": "1.1.0",
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.1",
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.9.0",
50
+ "@types/node": "22.10.0",
50
51
  "@vitest/coverage-v8": "2.1.5",
51
52
  "esbuild": "0.24.0",
52
- "prettier": "3.3.3",
53
+ "prettier": "3.4.0",
53
54
  "ts-node": "10.9.2",
54
- "typescript": "5.6.3",
55
+ "typescript": "5.7.2",
55
56
  "vitest": "2.1.5"
56
57
  }
57
58
  }
@@ -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, LinkObject } from './linkfinder';
9
+ import { LinkFinder } from './linkfinder';
9
10
 
10
- export type Data = { key: string; links: LinkObject[] }[];
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.1.0');
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: 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({ key: jira.getIssueURL(issue.key), links });
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: ['id', 'issuetype', 'summary', 'assignee', 'comment'],
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
- const linkObjectSchema = z.object({
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
- return z.array(linkObjectSchema).parse(
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 { Data } from './cli';
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: Data): void {
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
- const tokenType =
9
- type === 'jira'
10
- ? 'JIRA_API_TOKEN'
11
- : type === 'github'
12
- ? 'GITHUB_API_TOKEN'
13
- : undefined;
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.'`