Files
shaka-player/.github/workflows/tools/update-issues/main.js
T
Joey Parrish d1f00b897c ci: Add tests for new update-issues tool (#3900)
This ports over internal tests suggested in #3889.
2022-01-25 10:17:47 -08:00

280 lines
7.9 KiB
JavaScript

/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview A workflow tool to maintain GitHub issues.
*/
const core = require('@actions/core');
const { Issue, Milestone } = require('./issues.js');
const TYPE_ACCESSIBILITY = 'type: accessibility';
const TYPE_ANNOUNCEMENT = 'type: announcement';
const TYPE_BUG = 'type: bug';
const TYPE_CI = 'type: CI';
const TYPE_CODE_HEALTH = 'type: code health';
const TYPE_DOCS = 'type: docs';
const TYPE_ENHANCEMENT = 'type: enhancement';
const TYPE_PERFORMANCE = 'type: performance';
const TYPE_PROCESS = 'type: process';
const TYPE_QUESTION = 'type: question';
const PRIORITY_P0 = 'priority: P0';
const PRIORITY_P1 = 'priority: P1';
const PRIORITY_P2 = 'priority: P2';
const PRIORITY_P3 = 'priority: P3';
const PRIORITY_P4 = 'priority: P4';
const STATUS_ARCHIVED = 'status: archived';
const STATUS_WAITING = 'status: waiting on response';
const FLAG_IGNORE = 'flag: bot ignore';
// Issues of these types default to the next milestone. See also
// BACKLOG_PRIORITIES below, which can override the type.
const LABELS_FOR_NEXT_MILESTONE = [
TYPE_ACCESSIBILITY,
TYPE_BUG,
TYPE_DOCS,
];
// Issues of these types default to the backlog.
const LABELS_FOR_BACKLOG = [
TYPE_CI,
TYPE_CODE_HEALTH,
TYPE_ENHANCEMENT,
TYPE_PERFORMANCE,
];
// An issue with one of these priorities will default to the backlog, even if
// it has one of the types in LABELS_FOR_NEXT_MILESTONE.
const BACKLOG_PRIORITIES = [
PRIORITY_P3,
PRIORITY_P4,
];
const PING_QUESTION_TEXT =
'Does this answer all your questions? ' +
'If so, would you please close the issue?';
const CLOSE_STALE_TEXT =
'Closing due to inactivity. If this is still an issue for you or if you ' +
'have further questions, the OP can ask shaka-bot to reopen it by ' +
'including `@shaka-bot reopen` in a comment.';
const PING_INACTIVE_QUESTION_DAYS = 4;
const CLOSE_AFTER_WAITING_DAYS = 7;
const ARCHIVE_AFTER_CLOSED_DAYS = 60;
async function archiveOldIssues(issue) {
// If the issue has been closed for a while, archive it.
// Exclude locked issues, so that this doesn't conflict with unarchiveIssues
// below.
if (!issue.locked && issue.closed &&
issue.closedDays >= ARCHIVE_AFTER_CLOSED_DAYS) {
await issue.addLabel(STATUS_ARCHIVED);
await issue.lock();
}
}
async function unarchiveIssues(issue) {
// If the archive label is removed from an archived issue, unarchive it.
if (issue.locked && !issue.hasLabel(STATUS_ARCHIVED)) {
await issue.unlock();
await issue.reopen();
}
}
async function reopenIssues(issue) {
// If the original author wants an issue reopened, reopen it.
if (issue.closed && !issue.hasLabel(STATUS_ARCHIVED)) {
// Important: only load comments if prior filters pass!
// If we loaded them on every issue, we could exceed our query quota!
await issue.loadComments();
for (const comment of issue.comments) {
body = comment.body.toLowerCase();
if (comment.author == issue.author &&
comment.ageInDays <= issue.closedDays &&
body.includes('@shaka-bot') &&
(body.includes('reopen') || body.includes('re-open'))) {
core.notice(`Found reopen request for issue #${issue.number}`);
await issue.reopen();
break;
}
}
}
}
async function manageWaitingIssues(issue) {
// Filter for waiting issues.
if (!issue.closed && issue.hasLabel(STATUS_WAITING)) {
const labelAgeInDays = await issue.getLabelAgeInDays(STATUS_WAITING);
// If an issue has been replied to, remove the waiting tag.
// Important: only load comments if prior filters pass!
// If we loaded them on every issue, we could exceed our query quota!
await issue.loadComments();
const latestNonTeamComment = issue.comments.find(c => !c.fromTeam);
if (latestNonTeamComment &&
latestNonTeamComment.ageInDays < labelAgeInDays) {
await issue.removeLabel(STATUS_WAITING);
return;
}
// If an issue has been in a waiting state for too long, close it as stale.
if (labelAgeInDays >= CLOSE_AFTER_WAITING_DAYS) {
await issue.postComment(CLOSE_STALE_TEXT);
await issue.close();
}
}
}
async function cleanUpIssueTags(issue) {
// If an issue with the waiting tag was closed, remove the tag.
if (issue.closed && issue.hasLabel(STATUS_WAITING)) {
await issue.removeLabel(STATUS_WAITING);
}
}
async function pingQuestions(issue) {
// If a question hasn't been responded to recently, ping it.
if (!issue.closed &&
issue.hasLabel(TYPE_QUESTION) &&
!issue.hasLabel(STATUS_WAITING)) {
// Important: only load comments if prior filters pass!
// If we loaded them on every issue, we could exceed our query quota!
await issue.loadComments();
// Most recent ones are first.
const lastComment = issue.comments[0];
if (lastComment &&
lastComment.fromTeam &&
// If the last comment was from the team, but not from the OP (in case
// the OP was a member of the team).
lastComment.author != issue.author &&
lastComment.ageInDays >= PING_INACTIVE_QUESTION_DAYS) {
await issue.postComment(`@${issue.author} ${PING_QUESTION_TEXT}`);
await issue.addLabel(STATUS_WAITING);
}
}
}
async function maintainMilestones(issue, nextMilestone, backlog) {
// Set or remove milestones based on type labels.
if (!issue.closed) {
if (issue.hasAnyLabel(LABELS_FOR_NEXT_MILESTONE)) {
if (!issue.milestone) {
// Some (low) priority flags will indicate that an issue should go to
// the backlog, in spite of its type.
if (issue.hasAnyLabel(BACKLOG_PRIORITIES)) {
await issue.setMilestone(backlog);
} else {
await issue.setMilestone(nextMilestone);
}
}
} else if (issue.hasAnyLabel(LABELS_FOR_BACKLOG)) {
if (!issue.milestone) {
await issue.setMilestone(backlog);
}
} else {
if (issue.milestone) {
await issue.removeMilestone();
}
}
}
}
const ALL_ISSUE_TASKS = [
reopenIssues,
archiveOldIssues,
unarchiveIssues,
manageWaitingIssues,
cleanUpIssueTags,
pingQuestions,
maintainMilestones,
];
async function processIssues(issues, nextMilestone, backlog) {
let success = true;
for (const issue of issues) {
if (issue.hasLabel(FLAG_IGNORE)) {
core.info(`Ignoring issue #${issue.number}`);
continue;
}
core.info(`Processing issue #${issue.number}`);
for (const task of ALL_ISSUE_TASKS) {
try {
await task(issue, nextMilestone, backlog);
} catch (error) {
// Make this show up in the Actions UI without needing to search the
// logs.
core.error(
`Failed to process issue #${issue.number} in task ${task.name}: ` +
`${error}\n${error.stack}`);
success = false;
}
}
}
return success;
}
async function main() {
const milestones = await Milestone.getAll();
const issues = await Issue.getAll();
const backlog = milestones.find(m => m.isBacklog());
if (!backlog) {
core.error('No backlog milestone found!');
process.exit(1);
}
milestones.sort(Milestone.compare);
const nextMilestone = milestones[0];
if (nextMilestone.version == null) {
core.error('No version milestone found!');
process.exit(1);
}
const success = await processIssues(issues, nextMilestone, backlog);
if (!success) {
process.exit(1);
}
}
// If this file is the entrypoint, run main. Otherwise, export certain pieces
// to the tests.
if (require.main == module) {
main();
} else {
module.exports = {
processIssues,
TYPE_ACCESSIBILITY,
TYPE_ANNOUNCEMENT,
TYPE_BUG,
TYPE_CODE_HEALTH,
TYPE_DOCS,
TYPE_ENHANCEMENT,
TYPE_PROCESS,
TYPE_QUESTION,
PRIORITY_P0,
PRIORITY_P1,
PRIORITY_P2,
PRIORITY_P3,
PRIORITY_P4,
STATUS_ARCHIVED,
STATUS_WAITING,
FLAG_IGNORE,
};
}