mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-24 17:35:10 +03:00
69b276922a
The tool isn't (yet) intended to maintain PR labels, and the workflow doesn't have permissions to update PRs.
536 lines
12 KiB
JavaScript
536 lines
12 KiB
JavaScript
/*! @license
|
|
* Shaka Player
|
|
* Copyright 2016 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* @fileoverview A set of classes to represent GitHub issues & comments.
|
|
*/
|
|
|
|
const github = require('@actions/github');
|
|
const core = require('@actions/core');
|
|
|
|
const octokit = github.getOctokit(process.env.GITHUB_TOKEN);
|
|
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
|
|
|
|
// Values of "author_association" that indicate a team member:
|
|
const TEAM_ASSOCIATIONS = [
|
|
'OWNER',
|
|
'MEMBER',
|
|
'COLLABORATOR',
|
|
];
|
|
|
|
const ACTIONS_BOT = 'github-actions[bot]';
|
|
|
|
/**
|
|
* Parse a version string into an array of numbers with an optional string tag
|
|
* at the end. A string tag will be preceded by a negative one (-1) so that
|
|
* any tagged version (like -beta or -rc1) will be sorted before the final
|
|
* release version.
|
|
*
|
|
* @param {string} versionString
|
|
* @return {Array<number|string>}
|
|
*/
|
|
function parseVersion(versionString) {
|
|
const matches = /^v?([0-9]+(?:\.[0-9]+)*)(?:-(.*))?$/.exec(versionString);
|
|
if (!matches) {
|
|
return null;
|
|
}
|
|
|
|
// If there is a tag, append it as a string after a negative one. This will
|
|
// ensure that versions like "-beta" sort above their production
|
|
// counterparts.
|
|
const version = matches[1].split('.').map(x => parseInt(x));
|
|
if (matches[2]) {
|
|
version.push(-1);
|
|
version.push(matches[2]);
|
|
}
|
|
|
|
return version;
|
|
}
|
|
|
|
/**
|
|
* Compare two version arrays. Can be used as a callback to
|
|
* Array.prototype.sort to sort by version numbers (ascending).
|
|
*
|
|
* The last item in a version array may be a string (a tag like "beta"), but
|
|
* the rest are numbers. See notes in parseVersion above for details on tags.
|
|
*
|
|
* @param {Array<number|string>} a
|
|
* @param {Array<number|string>} b
|
|
* @return {number}
|
|
*/
|
|
function compareVersions(a, b) {
|
|
// If a milestone's version can't be parsed, it will be null. Push those to
|
|
// the end of any sorted list.
|
|
if (!a && !b) {
|
|
return 0;
|
|
} else if (!a) {
|
|
return 1;
|
|
} else if (!b) {
|
|
return -1;
|
|
}
|
|
|
|
for (let i = 0; i < Math.min(a.length, b.length); ++i) {
|
|
if (a[i] < b[i]) {
|
|
return -1;
|
|
} else if (a[i] > b[i]) {
|
|
return 1;
|
|
}
|
|
// If equal, keep going through the array.
|
|
}
|
|
|
|
// If one has a tag that the other does not, the one with the tag (the longer
|
|
// one) comes first.
|
|
if (a.length > b.length) {
|
|
return -1;
|
|
} else if (a.length < b.length) {
|
|
return 1;
|
|
} else {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare two Numbers. Can be used as a callback to Array.prototype.sort to
|
|
* sort by number (ascending).
|
|
*
|
|
* @param {Number} a
|
|
* @param {Number} b
|
|
* @return {number}
|
|
*/
|
|
function compareNumbers(a, b) {
|
|
// Sort NaNs to the end.
|
|
if (isNaN(a) && isNaN(b)) {
|
|
return 0;
|
|
} else if (isNaN(a)) {
|
|
return 1;
|
|
} else if (isNaN(b)) {
|
|
return -1;
|
|
}
|
|
|
|
if (a < b) {
|
|
return -1;
|
|
} else if (a > b) {
|
|
return 1;
|
|
} else {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a Date to an age in days (by comparing with the current time).
|
|
*
|
|
* @param {!Date} d
|
|
* @return {number} Time passed since d, in days.
|
|
*/
|
|
function dateToAgeInDays(d) {
|
|
// getTime() and now() both return milliseconds, which we diff and then
|
|
// convert to days.
|
|
return (Date.now() - d.getTime()) / (86400 * 1000);
|
|
}
|
|
|
|
/**
|
|
* A base class for objects returned by the GitHub API.
|
|
*/
|
|
class GitHubObject {
|
|
/** @param {!Object} obj */
|
|
constructor(obj) {
|
|
/** @type {number} */
|
|
this.id = obj.id;
|
|
/** @type {number} */
|
|
this.number = obj.number;
|
|
/** @type {number} */
|
|
this.ageInDays = NaN;
|
|
/** @type {number} */
|
|
this.closedDays = NaN;
|
|
|
|
if (obj.created_at != null) {
|
|
this.ageInDays = dateToAgeInDays(new Date(obj.created_at));
|
|
}
|
|
|
|
if (obj.closed_at != null) {
|
|
this.closedDays = dateToAgeInDays(new Date(obj.closed_at));
|
|
}
|
|
}
|
|
|
|
/** @return {string} */
|
|
toString() {
|
|
return JSON.stringify(this, null, ' ');
|
|
}
|
|
|
|
/**
|
|
* @param {Function} listMethod A method from the octokit API, which will be
|
|
* passed to octokit.paginate.
|
|
* @param {function(new:T, !Object)} SubClass
|
|
* @param {!Object} parameters
|
|
* @return {!Promise<!Array<!T>>}
|
|
* @template T
|
|
*/
|
|
static async getAll(listMethod, SubClass, parameters) {
|
|
const query = { owner, repo, ...parameters };
|
|
return (await octokit.paginate(listMethod, query))
|
|
.map(obj => new SubClass(obj));
|
|
}
|
|
}
|
|
|
|
class Milestone extends GitHubObject {
|
|
/** @param {!Object} obj */
|
|
constructor(obj) {
|
|
super(obj);
|
|
/** @type {string} */
|
|
this.title = obj.title;
|
|
/** @type {Array<number|string>} */
|
|
this.version = parseVersion(obj.title);
|
|
/** @type {boolean} */
|
|
this.closed = obj.state == 'closed';
|
|
}
|
|
|
|
/** @return {boolean} */
|
|
isBacklog() {
|
|
return this.title.toLowerCase() == 'backlog';
|
|
}
|
|
|
|
/** @return {!Promise<!Array<!Milestone>>} */
|
|
static async getAll() {
|
|
return GitHubObject.getAll(
|
|
octokit.rest.issues.listMilestones, Milestone, {});
|
|
}
|
|
|
|
/**
|
|
* Compare two Milestones. Can be used as a callback to Array.prototype.sort
|
|
* to sort by version numbers (ascending).
|
|
*
|
|
* @param {!Milestone} a
|
|
* @param {!Milestone} b
|
|
* @return {number}
|
|
*/
|
|
static compare(a, b) {
|
|
return compareVersions(a.version, b.version);
|
|
}
|
|
}
|
|
|
|
class Comment extends GitHubObject {
|
|
/** @param {!Object} obj */
|
|
constructor(obj) {
|
|
super(obj);
|
|
/** @type {string} */
|
|
this.author = obj.user.login;
|
|
/** @type {string} */
|
|
this.body = obj.body;
|
|
/** @type {string} */
|
|
this.authorAssociation = obj.author_association;
|
|
/** @type {boolean} */
|
|
this.fromTeam =
|
|
TEAM_ASSOCIATIONS.includes(obj.author_association) ||
|
|
this.author == ACTIONS_BOT;
|
|
}
|
|
|
|
/**
|
|
* @param {number} issueNumber
|
|
* @return {!Promise<!Array<!Comment>>}
|
|
*/
|
|
static async getAll(issueNumber) {
|
|
return GitHubObject.getAll(octokit.rest.issues.listComments, Comment, {
|
|
issue_number: issueNumber,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compare two Comments. Can be used as a callback to Array.prototype.sort
|
|
* to sort by creation time (descending, newest comments first).
|
|
*
|
|
* @param {!Comment} a
|
|
* @param {!Comment} b
|
|
* @return {number}
|
|
*/
|
|
static compare(a, b) {
|
|
// Put most recent comments first.
|
|
return compareNumbers(a.ageInDays, b.ageInDays);
|
|
}
|
|
}
|
|
|
|
class Event extends GitHubObject {
|
|
/** @param {!Object} obj */
|
|
constructor(obj) {
|
|
super(obj);
|
|
|
|
/** @type {string} */
|
|
this.event = obj.event;
|
|
|
|
if (obj.event == 'labeled') {
|
|
/** @type {string} */
|
|
this.label = obj.label.name;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} issueNumber
|
|
* @return {!Promise<!Array<!Event>>}
|
|
*/
|
|
static async getAll(issueNumber) {
|
|
return GitHubObject.getAll(octokit.rest.issues.listEvents, Event, {
|
|
issue_number: issueNumber,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compare two Events. Can be used as a callback to Array.prototype.sort
|
|
* to sort by creation time (descending, newest events first).
|
|
*
|
|
* @param {!Event} a
|
|
* @param {!Event} b
|
|
* @return {number}
|
|
*/
|
|
static compare(a, b) {
|
|
// Put most recent events first.
|
|
return compareNumbers(a.ageInDays, b.ageInDays);
|
|
}
|
|
}
|
|
|
|
class Issue extends GitHubObject {
|
|
/** @param {!Object} obj */
|
|
constructor(obj) {
|
|
super(obj);
|
|
/** @type {string} */
|
|
this.author = obj.user.login;
|
|
/** @type {!Array<string>} */
|
|
this.labels = obj.labels.map(l => l.name);
|
|
/** @type {boolean} */
|
|
this.closed = obj.state == 'closed';
|
|
/** @type {boolean} */
|
|
this.locked = obj.locked;
|
|
/** @type {Milestone} */
|
|
this.milestone = obj.milestone ? new Milestone(obj.milestone) : null;
|
|
/** @type {boolean} */
|
|
this.isPR = !!obj.pull_request;
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @return {boolean}
|
|
*/
|
|
hasLabel(name) {
|
|
return this.labels.includes(name);
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<string>} names
|
|
* @return {boolean}
|
|
*/
|
|
hasAnyLabel(names) {
|
|
return this.labels.some(l => names.includes(l));
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @return {!Promise<number}
|
|
*/
|
|
async getLabelAgeInDays(name) {
|
|
const events = await Event.getAll(this.number);
|
|
// Put the most recent events first.
|
|
events.sort(Event.compare);
|
|
|
|
for (const event of events) {
|
|
if (event.label == name) {
|
|
return event.ageInDays;
|
|
}
|
|
}
|
|
|
|
throw new Error(`Unable to find age of label "${name}"!`);
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @return {!Promise}
|
|
*/
|
|
async addLabel(name) {
|
|
if (this.hasLabel(name)) {
|
|
return;
|
|
}
|
|
|
|
core.notice(`Adding label "${name}" to issue #${this.number}`);
|
|
await octokit.rest.issues.addLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
labels: [name],
|
|
});
|
|
this.labels.push(name);
|
|
}
|
|
|
|
/**
|
|
* @param {string} name
|
|
* @return {!Promise}
|
|
*/
|
|
async removeLabel(name) {
|
|
if (!this.hasLabel(name)) {
|
|
return;
|
|
}
|
|
|
|
core.notice(`Removing label "${name}" from issue #${this.number}`);
|
|
await octokit.rest.issues.removeLabel({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
name,
|
|
});
|
|
this.labels = this.labels.filter(l => l != name);
|
|
}
|
|
|
|
/** @return {!Promise} */
|
|
async lock() {
|
|
if (this.locked) {
|
|
return;
|
|
}
|
|
|
|
core.notice(`Locking issue #${this.number}`);
|
|
await octokit.rest.issues.lock({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
lock_reason: 'resolved',
|
|
});
|
|
this.locked = true;
|
|
}
|
|
|
|
/** @return {!Promise} */
|
|
async unlock() {
|
|
if (!this.locked) {
|
|
return;
|
|
}
|
|
|
|
core.notice(`Unlocking issue #${this.number}`);
|
|
await octokit.rest.issues.unlock({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
});
|
|
this.locked = false;
|
|
}
|
|
|
|
/** @return {!Promise} */
|
|
async close() {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
|
|
core.notice(`Closing issue #${this.number}`);
|
|
await octokit.rest.issues.update({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
state: 'closed',
|
|
});
|
|
this.closed = true;
|
|
}
|
|
|
|
/** @return {!Promise} */
|
|
async reopen() {
|
|
if (!this.closed) {
|
|
return;
|
|
}
|
|
|
|
core.notice(`Reopening issue #${this.number}`);
|
|
await octokit.rest.issues.update({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
state: 'open',
|
|
});
|
|
this.closed = false;
|
|
}
|
|
|
|
/**
|
|
* @param {!Milestone} milestone
|
|
* @return {!Promise}
|
|
*/
|
|
async setMilestone(milestone) {
|
|
if (this.milestone && this.milestone.number == milestone.number) {
|
|
return;
|
|
}
|
|
|
|
core.notice(
|
|
`Adding issue #${this.number} to milestone ${milestone.title}`);
|
|
await octokit.rest.issues.update({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
milestone: milestone.number,
|
|
});
|
|
this.milestone = milestone;
|
|
}
|
|
|
|
/** @return {!Promise} */
|
|
async removeMilestone() {
|
|
if (!this.milestone) {
|
|
return;
|
|
}
|
|
|
|
core.notice(
|
|
`Removing issue #${this.number} ` +
|
|
`from milestone ${this.milestone.title}`);
|
|
await octokit.rest.issues.update({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
milestone: null,
|
|
});
|
|
this.milestone = null;
|
|
}
|
|
|
|
/**
|
|
* @param {string} body
|
|
* @return {!Promise}
|
|
*/
|
|
async postComment(body) {
|
|
core.notice(`Posting to issue #${this.number}: "${body}"`);
|
|
await octokit.rest.issues.createComment({
|
|
owner,
|
|
repo,
|
|
issue_number: this.number,
|
|
body,
|
|
});
|
|
|
|
if (this.comments) {
|
|
this.comments.push(new Comment({
|
|
created_at: (new Date()).toJSON(),
|
|
user: {login: 'shaka-bot'},
|
|
body,
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Important: Don't load comments by default! Only some issues need
|
|
* comments checked, and we don't want to exceed our query quota by loading
|
|
* all comments for all issues.
|
|
*
|
|
* @return {!Promise}
|
|
*/
|
|
async loadComments() {
|
|
if (this.comments) {
|
|
return;
|
|
}
|
|
|
|
this.comments = await Comment.getAll(this.number);
|
|
// Puts most recent comments first.
|
|
this.comments.sort(Comment.compare);
|
|
}
|
|
|
|
/** @return {!Promise<!Array<!Issue>>} */
|
|
static async getAll() {
|
|
const all = await GitHubObject.getAll(
|
|
octokit.rest.issues.listForRepo, Issue, {
|
|
state: 'all',
|
|
});
|
|
return all.filter(issue => !issue.isPR);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
Issue,
|
|
Milestone,
|
|
};
|