Skip to content

Commit 574a473

Browse files
author
Viktor Lukashov
authored
chore: add a transferFlowIssues script [skip ci] (#1975)
Intended usage: `node scripts/transferFlowIssues.js` to transfer all open issues with the `flow` label from the `web-components` repo into `flow-components`. In order to run with the `vaadin-bot` credentials, run this script via the [Transfer issues to the flow-components monorepo](https://bender.vaadin.com/buildConfiguration/VaadinWebComponents_TransferIssuesToTheFlowComponentsMonorepo/232264?buildTab=log&expandAll=true&focusLine=2909) CI build. Labels on the transferred issues are transferred as well (except for the `flow` label, which is dropped). The transferred issues remain in the same pipelines (columns) on all ZenHub boards. If a label was missing in the web-components repo, it will be created (with the same color as in the original repo).
1 parent aaa8e02 commit 574a473

File tree

1 file changed

+368
-0
lines changed

1 file changed

+368
-0
lines changed

scripts/transferFlowIssues.js

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
const dotenv = require('dotenv');
2+
const { Octokit } = require('octokit');
3+
const axios = require('axios');
4+
5+
dotenv.config();
6+
7+
// if DRY_RUN then no actual changes are made
8+
const DRY_RUN = process.env.PRODUCTION_RUN !== 'true';
9+
console.log(
10+
DRY_RUN
11+
? `PRODUCTION_RUN is not set to 'true', no actuall changes will be made`
12+
: `PRODUCTION_RUN is set to 'true', making changes for real`
13+
);
14+
15+
// stop after processing ISSUE_LIMIT issues
16+
const ISSUE_LIMIT = +process.env.ISSUE_LIMIT || Number.MAX_SAFE_INTEGER;
17+
18+
// GitHub API client (both REST and GraphQL)
19+
const octokit = new Octokit({ auth: process.env.GITHUB_API_TOKEN });
20+
21+
// ZenHub API client
22+
const zhApi = axios.create({
23+
baseURL: 'https://api.zenhub.com/',
24+
headers: {
25+
'X-Authentication-Token': process.env.ZENHUB_API_TOKEN
26+
},
27+
adapter: zhRateLimitingAdapter(axios.defaults.adapter)
28+
});
29+
30+
// All open issues from the source repos will be transferred to _this_ repo:
31+
const TARGET_REPO = {
32+
owner: 'vaadin',
33+
name: 'flow-components'
34+
};
35+
36+
async function getSourceRepo() {
37+
const { data: repo } = await octokit.rest.repos.get({
38+
owner: 'vaadin',
39+
repo: 'web-components'
40+
});
41+
return repo;
42+
}
43+
44+
const zhWorkspaceNameById = new Map();
45+
async function getZenHubWorkspaceName(workspace_id, repo_id) {
46+
if (!zhWorkspaceNameById.has(workspace_id)) {
47+
zhWorkspaceNameById.set(
48+
workspace_id,
49+
(async (workspace_id, repo_id) => {
50+
const { data: workspaces } = await zhApi.get(`/p2/repositories/${repo_id}/workspaces`);
51+
const idx = workspaces.findIndex((workspace) => workspace.id === workspace_id);
52+
if (idx > -1) {
53+
return workspaces[idx].name;
54+
} else {
55+
throw new Error(`Cannot find ZenHub workspace with ID ${workspace_id} for the repo with ID ${repo_id}`);
56+
}
57+
})(workspace_id, repo_id)
58+
);
59+
}
60+
61+
return zhWorkspaceNameById.get(workspace_id);
62+
}
63+
64+
// Special handling for ZenHub REST API rate limiting
65+
// (see https://github.com/ZenHubIO/API#api-rate-limit)
66+
function zhRateLimitingAdapter(adapter) {
67+
return async (config) => {
68+
let response;
69+
let status = 403;
70+
while (status === 403) {
71+
try {
72+
response = await adapter(config);
73+
status = response.status;
74+
} catch (e) {
75+
if (e.isAxiosError && e.response.status === 403) {
76+
const resetAtMs = e.response.headers['x-ratelimit-reset'] * 1000;
77+
const sentAtMs = new Date(e.response.headers['date']).getTime();
78+
const timeoutMs = resetAtMs - sentAtMs;
79+
console.log(`timeout until ZenHub API request rate reset: ${timeoutMs} ms`);
80+
await new Promise((r) => setTimeout(r, timeoutMs));
81+
} else {
82+
throw e;
83+
}
84+
}
85+
}
86+
87+
return response;
88+
};
89+
}
90+
91+
async function createLabelInRepo(label, repo) {
92+
let newLabel;
93+
if (DRY_RUN) {
94+
newLabel = await Promise.resolve(label);
95+
} else {
96+
const { data } = await octokit.rest.issues.createLabel({
97+
owner: repo.owner.login,
98+
repo: repo.name,
99+
name: label.name,
100+
description: label.description,
101+
color: label.color
102+
});
103+
newLabel = data;
104+
}
105+
106+
return {
107+
...newLabel,
108+
transferred: true
109+
};
110+
}
111+
112+
async function transferIssue(issue, targetRepo) {
113+
if (DRY_RUN) {
114+
return { ...issue };
115+
} else {
116+
const response = await octokit.graphql(
117+
`mutation TransferIssue($issueNodeId: ID!, $targetRepoNodeId: ID!, $clientMutationId: String) {
118+
transferIssue(input: {
119+
clientMutationId: $clientMutationId,
120+
issueId: $issueNodeId,
121+
repositoryId: $targetRepoNodeId
122+
}) {
123+
issue {
124+
number
125+
repository {
126+
name
127+
databaseId
128+
owner {
129+
login
130+
}
131+
}
132+
}
133+
}
134+
}`,
135+
{
136+
clientMutationId: issue.url,
137+
issueNodeId: issue.node_id,
138+
targetRepoNodeId: targetRepo.node_id
139+
}
140+
);
141+
return response.transferIssue.issue;
142+
}
143+
}
144+
145+
async function transferLabels(labels, issue) {
146+
if (DRY_RUN) {
147+
return [...labels];
148+
} else {
149+
const { data } = await octokit.rest.issues.addLabels({
150+
owner: issue.repository.owner.login,
151+
repo: issue.repository.name,
152+
issue_number: issue.number,
153+
labels: labels.map((label) => label.name)
154+
});
155+
return data;
156+
}
157+
}
158+
159+
async function transferZhPipelines(pipelines, issue) {
160+
if (DRY_RUN) {
161+
return Promise.resolve();
162+
} else {
163+
await Promise.all(
164+
pipelines.map(async (pipeline) => {
165+
zhApi.post(
166+
`/p2/workspaces/${pipeline.workspace_id}/repositories/${issue.repository.databaseId}/issues/${issue.number}/moves`,
167+
{
168+
pipeline_id: pipeline.pipeline_id,
169+
position: 'bottom'
170+
}
171+
);
172+
})
173+
);
174+
}
175+
}
176+
177+
async function makeRepoLabelsMap(repo) {
178+
const map = new Map();
179+
const repoLabels = {
180+
get: (labelName) => map.get(labelName.toLowerCase()),
181+
set: (labelName, label) => map.set(labelName.toLowerCase(), label),
182+
has: (labelName) => map.has(labelName.toLowerCase()),
183+
values: () => map.values(),
184+
ensure: async (label) => {
185+
if (!repoLabels.has(label.name)) {
186+
// First store a promise to remember that a transfer of _this_ label has
187+
// been scheduled to avoid transferring the same label several times.
188+
const promise = createLabelInRepo(label, repo);
189+
repoLabels.set(label.name, promise);
190+
191+
// Eventually, store the transferred label itself
192+
console.log(`creating a label '${label.name}' in the ${repo.full_name} repo`);
193+
repoLabels.set(label.name, await promise);
194+
} else if ('then' in repoLabels.get(label.name)) {
195+
console.log(`waiting until the label '${label.name}' is created in the ${repo.full_name} repo`);
196+
await repoLabels.get(label.name);
197+
}
198+
}
199+
};
200+
201+
// fetch the list of all existing labels on the target repo
202+
const iterator = octokit.paginate.iterator(octokit.rest.issues.listLabelsForRepo, {
203+
owner: repo.owner.login,
204+
repo: repo.name,
205+
per_page: 100
206+
});
207+
208+
for await (const { data: labels } of iterator) {
209+
for (const label of labels) {
210+
repoLabels.set(label.name, { ...label, transferred: false });
211+
}
212+
}
213+
214+
return repoLabels;
215+
}
216+
217+
async function main() {
218+
const {
219+
data: { login }
220+
} = await octokit.rest.users.getAuthenticated();
221+
console.log(`Logged-in to GitHub as ${login}`);
222+
223+
const { data: targetRepo } = await octokit.rest.repos.get({
224+
owner: TARGET_REPO.owner,
225+
repo: TARGET_REPO.name
226+
});
227+
console.log(`Transferring issues to the ${targetRepo.full_name} repo (GraphQL Node ID: ${targetRepo.node_id})`);
228+
229+
const targetRepoLabels = await makeRepoLabelsMap(targetRepo);
230+
console.log(`Existing labels in the ${targetRepo.full_name} repo:`);
231+
for (const { name, color, description } of targetRepoLabels.values()) {
232+
console.log(`\t${JSON.stringify({ name, color, description })}`);
233+
}
234+
235+
const { data: targetRepoZhWorkspaces } = await zhApi.get(`/p2/repositories/${targetRepo.id}/workspaces`);
236+
237+
console.time('issues');
238+
let issueCount = 0;
239+
let totalZhEpics = 0;
240+
let totalIssuesWithZhEstimate = 0;
241+
242+
const repo = await getSourceRepo();
243+
console.log(`Transferring issues from the repo ${repo.full_name}...`);
244+
245+
const iterator = octokit.paginate.iterator(octokit.rest.issues.listForRepo, {
246+
owner: repo.owner.login,
247+
repo: repo.name,
248+
labels: ['flow'],
249+
per_page: 100
250+
});
251+
252+
// iterate through each page of issues
253+
for await (const { data: issues } of iterator) {
254+
// stop processing when reached the issue limit
255+
if (issueCount >= ISSUE_LIMIT) {
256+
break;
257+
}
258+
// iterate through each issue in a page
259+
for (const issue of issues) {
260+
// stop processing when reached the issue limit
261+
if (issueCount >= ISSUE_LIMIT) {
262+
console.log(`stoppig because reached the ISSUE_LIMIT (${ISSUE_LIMIT})`);
263+
break;
264+
}
265+
266+
// do not transfer open PRs
267+
if (issue.html_url.indexOf('/pull/') > -1) {
268+
continue;
269+
}
270+
271+
const [{ data: labels }, zhIssue] = await Promise.all([
272+
// fetch all labels on the issue
273+
// (no need for pagination as there is never too many)
274+
octokit.rest.issues.listLabelsOnIssue({
275+
owner: repo.owner.login,
276+
repo: repo.name,
277+
issue_number: issue.number
278+
}),
279+
// AND at the same time fetch ZenHub pipelines for the issue
280+
(async () => {
281+
const { data: zhIssue } = await zhApi.get(`/p1/repositories/${repo.id}/issues/${issue.number}`);
282+
const pipelinesWithWorkspaceName = await Promise.all(
283+
zhIssue.pipelines.map(async (pipeline) => ({
284+
...pipeline,
285+
workspace: await getZenHubWorkspaceName(pipeline.workspace_id, repo.id)
286+
}))
287+
);
288+
return { ...zhIssue, pipelines: pipelinesWithWorkspaceName };
289+
})()
290+
]);
291+
292+
if (zhIssue.is_epic) {
293+
console.log(`Skipping ${repo.name}#${issue.number} because it's a ZH Epic`);
294+
totalZhEpics += 1;
295+
continue;
296+
}
297+
298+
if (zhIssue.estimate) {
299+
console.log(
300+
`${repo.name}#${issue.number} has a ZH estimate of ${zhIssue.estimate.value}. ` +
301+
`The estimate won't be transferred automatically, please do it manually.`
302+
);
303+
totalIssuesWithZhEstimate += 1;
304+
}
305+
306+
const transferredIssue = await transferIssue(issue, targetRepo);
307+
308+
// remove the `flow` label
309+
const idx = labels.findIndex((label) => label.name === 'flow');
310+
if (idx > -1) {
311+
labels.splice(idx, 1);
312+
}
313+
314+
await Promise.all(labels.map(targetRepoLabels.ensure));
315+
await transferLabels(labels, transferredIssue);
316+
317+
if (zhIssue.pipelines.length > 0) {
318+
await transferZhPipelines(
319+
zhIssue.pipelines.filter((pipeline) => {
320+
const isTargetRepoInPipelineWorkspace =
321+
targetRepoZhWorkspaces.findIndex((workspace) => workspace.id === pipeline.workspace_id) > -1;
322+
if (!isTargetRepoInPipelineWorkspace) {
323+
console.log(
324+
`ZenHub pipeline ${pipeline.name} in ${pipeline.workspace} on the ` +
325+
`issue ${repo.name}#${issue.number} cannot be transferred because ` +
326+
`the target repo ${targetRepo.name} is not included into the ` +
327+
`${pipeline.workspace} ZenHub workspace.`
328+
);
329+
}
330+
return isTargetRepoInPipelineWorkspace;
331+
}),
332+
transferredIssue
333+
);
334+
}
335+
336+
console.log('%s#%d: %s', repo.name, issue.number, issue.title);
337+
console.log(`\ttransferred to ---> ${targetRepo.name}#${transferredIssue.number}`);
338+
if (labels.length > 0) {
339+
console.log(
340+
`\tlabels: [${labels
341+
.map((label) => label.name + (targetRepoLabels.get(label.name).transferred ? ' [CREATED]' : ''))
342+
.join(', ')}]`
343+
);
344+
}
345+
if (zhIssue.pipelines.length > 0) {
346+
console.log(
347+
`\tpipelines: [${zhIssue.pipelines
348+
.map((pipeline) => {
349+
const isTargetRepoInPipelineWorkspace =
350+
targetRepoZhWorkspaces.findIndex((workspace) => workspace.id === pipeline.workspace_id) > -1;
351+
return `${pipeline.name} in ${pipeline.workspace}${
352+
isTargetRepoInPipelineWorkspace ? '' : ' [UNTRANSFERRED]'
353+
}`;
354+
})
355+
.join(', ')}]`
356+
);
357+
}
358+
issueCount += 1;
359+
}
360+
}
361+
362+
console.log(`total issues in the ${repo.name} repo: ${issueCount}`);
363+
console.log(`total ZenHub Epics skipped: ${totalZhEpics}`);
364+
console.log(`total issues with ZenHub estimates: ${totalIssuesWithZhEstimate}`);
365+
console.timeEnd(`issues`);
366+
}
367+
368+
main().catch(console.log);

0 commit comments

Comments
 (0)