Skip to content

Commit a4e38c8

Browse files
jamietannaClaude Sonnet 4.6
andauthored
ci: add script to sync Issue Fields (renovatebot#42132)
Similar to changes in f2546b8, we can introduce a script that will sync the Issue Fields as part of renovatebot#42131. Due to https://github.com/orgs/community/discussions/190545 we need to handle the update appropriately, making sure to send all existing fields on an update. Due to https://github.com/orgs/community/discussions/190548, we can't yet sync Managers, as we have >100 Managers to add to the list. However, we can still add the implementation, in a similar way to test/other/sync-module-labels.spec.ts. Co-authored-by: Claude Sonnet 4.6 <jamie.tanna+claude-code@mend.io>
1 parent 3f9b23d commit a4e38c8

File tree

2 files changed

+219
-1
lines changed

2 files changed

+219
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"pretest": "run-s 'generate:*'",
5151
"prettier": "PRETTIER_EXPERIMENTAL_CLI=1 prettier --cache --check '**/*.{ts,js,mjs,json,md,yml}'",
5252
"prettier-fix": "PRETTIER_EXPERIMENTAL_CLI=1 prettier --write --cache '**/*.{ts,js,mjs,json,md,yml}'",
53-
"labels:check": "node tools/sync-module-labels.ts",
53+
"labels:check": "node tools/sync-module-labels.ts && node tools/sync-org-issue-fields.ts",
5454
"labels:show-commands": "node tools/sync-module-labels.ts --show-commands",
5555
"release:prepare": "node tools/prepare-release.ts",
5656
"release:publish": "node tools/publish-release.ts",

tools/sync-org-issue-fields.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Command } from 'commander';
2+
import { quote } from 'shlex';
3+
import { init, logger } from '../lib/logger/index.ts';
4+
import { getDatasourceList } from '../lib/modules/datasource/index.ts';
5+
import { allManagersList } from '../lib/modules/manager/index.ts';
6+
import { getPlatformList } from '../lib/modules/platform/index.ts';
7+
import { exec } from './utils/exec.ts';
8+
9+
export type IssueFieldKind = 'Datasource' | 'Manager' | 'Platform';
10+
11+
export interface IssueFieldOption {
12+
name: string;
13+
description: string;
14+
color: string;
15+
priority: number;
16+
}
17+
18+
export interface OrgIssueField {
19+
id: number;
20+
name: string;
21+
data_type: string;
22+
description: string;
23+
options: IssueFieldOption[];
24+
}
25+
26+
export interface ExpectedIssueField {
27+
name: IssueFieldKind;
28+
data_type: 'single_select';
29+
description: string;
30+
options: IssueFieldOption[];
31+
}
32+
33+
export interface CliOptions {
34+
org: string;
35+
showCommands?: boolean;
36+
}
37+
38+
export interface MissingOptions {
39+
field: OrgIssueField;
40+
missingOptions: IssueFieldOption[];
41+
}
42+
43+
const defaultOrg = 'renovatebot';
44+
45+
function getSortedUnique(values: string[]): string[] {
46+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
47+
}
48+
49+
export function createIssueFieldOption(
50+
kind: IssueFieldKind,
51+
moduleId: string,
52+
): IssueFieldOption {
53+
return {
54+
name: moduleId,
55+
description: `Related to the ${moduleId} ${kind.toLowerCase()}`,
56+
color: 'gray',
57+
priority: 1,
58+
};
59+
}
60+
61+
export function getExpectedIssueFields(): ExpectedIssueField[] {
62+
return [
63+
{
64+
name: 'Datasource',
65+
data_type: 'single_select',
66+
description: 'The datasource module related to this issue',
67+
options: getSortedUnique(getDatasourceList()).map((id) =>
68+
createIssueFieldOption('Datasource', id),
69+
),
70+
},
71+
{
72+
name: 'Manager',
73+
data_type: 'single_select',
74+
description: 'The manager module related to this issue',
75+
options: getSortedUnique(allManagersList).map((id) =>
76+
createIssueFieldOption('Manager', id),
77+
),
78+
},
79+
{
80+
name: 'Platform',
81+
data_type: 'single_select',
82+
description: 'The platform module related to this issue',
83+
options: getSortedUnique(getPlatformList()).map((id) =>
84+
createIssueFieldOption('Platform', id),
85+
),
86+
},
87+
];
88+
}
89+
90+
export function getMissingIssueFields(
91+
expectedFields: ExpectedIssueField[],
92+
existingFields: OrgIssueField[],
93+
): ExpectedIssueField[] {
94+
const existingNames = new Set(existingFields.map((f) => f.name));
95+
return expectedFields.filter((f) => !existingNames.has(f.name));
96+
}
97+
98+
export function getMissingFieldOptions(
99+
expectedFields: ExpectedIssueField[],
100+
existingFields: OrgIssueField[],
101+
): MissingOptions[] {
102+
const result: MissingOptions[] = [];
103+
104+
for (const expected of expectedFields) {
105+
const existing = existingFields.find((f) => f.name === expected.name);
106+
if (!existing) {
107+
continue;
108+
}
109+
110+
const existingOptions = new Set(existing.options.map((o) => o.name));
111+
const missingOptions = expected.options.filter(
112+
(o) => !existingOptions.has(o.name),
113+
);
114+
115+
if (missingOptions.length > 0) {
116+
result.push({ field: existing, missingOptions });
117+
}
118+
}
119+
120+
return result;
121+
}
122+
123+
export function getCreateFieldCommand(
124+
org: string,
125+
field: ExpectedIssueField,
126+
): string {
127+
const body = JSON.stringify({
128+
name: field.name,
129+
data_type: field.data_type,
130+
description: field.description,
131+
options: field.options,
132+
});
133+
return `echo ${quote(body)} | gh api -X POST /orgs/${quote(org)}/issue-fields --input -`;
134+
}
135+
136+
export function getUpdateFieldOptionsCommand(
137+
org: string,
138+
missing: MissingOptions,
139+
allOptions: IssueFieldOption[],
140+
): string {
141+
const body = JSON.stringify({ options: allOptions });
142+
return `echo ${quote(body)} | gh api -X PATCH /orgs/${quote(org)}/issue-fields/${missing.field.id} --input -`;
143+
}
144+
145+
async function getOrgIssueFields(org: string): Promise<OrgIssueField[]> {
146+
const result = await exec('gh', ['api', `/orgs/${org}/issue-fields`]);
147+
return JSON.parse(result.stdout) as OrgIssueField[];
148+
}
149+
150+
await init();
151+
152+
process.on('unhandledRejection', (err) => {
153+
logger.error({ err }, 'unhandledRejection');
154+
process.exit(-1);
155+
});
156+
157+
const program = new Command('node tools/sync-org-issue-fields.ts')
158+
.description('Check that datasource/manager/platform org issue fields exist.')
159+
.option('--org <name>', `Organization to query`, defaultOrg)
160+
.option(
161+
'--show-commands',
162+
'Print gh api commands for any missing fields or options',
163+
)
164+
.action(async (options: CliOptions) => {
165+
const expectedFields = getExpectedIssueFields();
166+
const existingFields = await getOrgIssueFields(options.org);
167+
168+
const missingFields = getMissingIssueFields(expectedFields, existingFields);
169+
const missingFieldOptions = getMissingFieldOptions(
170+
expectedFields,
171+
existingFields,
172+
);
173+
174+
if (missingFields.length === 0 && missingFieldOptions.length === 0) {
175+
logger.info(
176+
`All datasource/manager/platform issue fields exist in ${options.org}.`,
177+
);
178+
return;
179+
}
180+
181+
if (missingFields.length > 0) {
182+
logger.error(
183+
`Missing ${missingFields.length} issue fields in ${options.org}:\n${missingFields.map((f) => f.name).join(', ')}`,
184+
);
185+
}
186+
187+
for (const { field, missingOptions } of missingFieldOptions) {
188+
logger.error(
189+
`Missing ${missingOptions.length} options in issue field "${field.name}" in ${options.org}:\n- ${missingOptions.map((o) => o.name).join('\n- ')}`,
190+
);
191+
}
192+
193+
if (options.showCommands) {
194+
const commands: string[] = [];
195+
196+
for (const field of missingFields) {
197+
commands.push(getCreateFieldCommand(options.org, field));
198+
}
199+
200+
for (const missing of missingFieldOptions) {
201+
const allOptions = [
202+
...missing.field.options,
203+
...missing.missingOptions,
204+
];
205+
commands.push(
206+
getUpdateFieldOptionsCommand(options.org, missing, allOptions),
207+
);
208+
}
209+
210+
logger.info(
211+
`Run the following commands to sync the missing fields/options:\n${commands.join('\n')}`,
212+
);
213+
}
214+
215+
process.exitCode = 1;
216+
});
217+
218+
void program.parseAsync();

0 commit comments

Comments
 (0)