diff --git a/github-actions/add-update-label-weekly/add-label.js b/github-actions/add-update-label-weekly/add-label.js index 85f5b28c8a..c0d189fdcb 100644 --- a/github-actions/add-update-label-weekly/add-label.js +++ b/github-actions/add-update-label-weekly/add-label.js @@ -7,9 +7,9 @@ var context; const statusUpdatedLabel = 'Status: Updated'; const toUpdateLabel = 'To Update !'; const inactiveLabel = '2 weeks inactive'; -const updatedByDays = 3; // number of days ago to check for to update label -const inactiveUpdatedByDays = 14; // number of days ago to check for inactive label -const commentByDays = 7; // number of days ago to check for comment by assignee +const updatedByDays = 3; // If there is an update within 3 days, the issue is considered updated +const inactiveUpdatedByDays = 14; // If no update within 14 days, the issue is considered '2 weeks inactive' +const commentByDays = 7; // If there is an update within 14 days but no update within 7 days, the issue is considered outdated and the assignee needs 'To Update !' it const threeDayCutoffTime = new Date() threeDayCutoffTime.setDate(threeDayCutoffTime.getDate() - updatedByDays) const sevenDayCutoffTime = new Date() @@ -18,49 +18,53 @@ const fourteenDayCutoffTime = new Date() fourteenDayCutoffTime.setDate(fourteenDayCutoffTime.getDate() - inactiveUpdatedByDays) /** - * The main function, which retrieves issues from a specific column in a specific project, before examining the timeline of each issue for outdatedness. If outdated, the old status label is removed, and an updated is requested. Otherwise, the issue is labeled as updated. + * The main function, which retrieves issues from a specific column in a specific project, before examining the timeline of each issue for outdatedness. + * An update to an issue is either 1. a comment by the assignee, or 2. assigning an assignee to the issue. If the last update is not within 7 days or 14 days, apply the according outdate label, and request an update. + * However, if the assignee has submitted a PR that fixed the issue regardless of when, all update-related labels should be removed. + * @param {Object} g github object from actions/github-script * @param {Object} c context object from actions/github-script * @param {Number} columnId a number presenting a specific column to examine, supplied by GitHub secrets */ async function main({ g, c }, columnId) { - github = g; - context = c; - // Retrieve all issue numbers from a column - const issueNums = getIssueNumsFromColumn(columnId); - for await (let issueNum of issueNums) { - const timeline = await getTimeline(issueNum); - const timelineArray = Array.from(timeline); - const assignees = await getAssignees(issueNum); - // Error catching. - if (assignees.length === 0) { - console.log(`Assignee not found, skipping issue #${issueNum}`) - continue - } - - // Add and remove labels as well as post comment if the issue's timeline indicates the issue is inactive, to be updated or up to date accordingly - const responseObject = await isTimelineOutdated(timeline, issueNum, assignees) - if (responseObject.result === true && responseObject.labels === toUpdateLabel) { - console.log(`Going to ask for an update now for issue #${issueNum}`); - await removeLabels(issueNum, statusUpdatedLabel, inactiveLabel); - await addLabels(issueNum, responseObject.labels); - await postComment(issueNum, assignees, toUpdateLabel); - } else if (responseObject.result === true && responseObject.labels === statusUpdatedLabel) { - await removeLabels(issueNum, toUpdateLabel, inactiveLabel); - await addLabels(issueNum, responseObject.labels); - } else if (responseObject.result === true && responseObject.labels === inactiveLabel) { - console.log(`Going to ask for an update now for issue #${issueNum}`); - await removeLabels(issueNum, toUpdateLabel, statusUpdatedLabel); - await addLabels(issueNum, responseObject.labels); - await postComment(issueNum, assignees, inactiveLabel); - } else { - console.log(`No updates needed for issue #${issueNum}`); - await removeLabels(issueNum, toUpdateLabel, inactiveLabel); - await addLabels(issueNum, responseObject.labels); - } - } -} - + github = g; + context = c; + // Retrieve all issue numbers from a column + const issueNums = getIssueNumsFromColumn(columnId); + for await (let issueNum of issueNums) { + const timeline = await getTimeline(issueNum); + const timelineArray = Array.from(timeline); + const assignees = await getAssignees(issueNum); + // Error catching. + if (assignees.length === 0) { + console.log(`Assignee not found, skipping issue #${issueNum}`) + continue + } + + // Add and remove labels as well as post comment if the issue's timeline indicates the issue is inactive, to be updated or up to date accordingly + const responseObject = await isTimelineOutdated(timeline, issueNum, assignees) + + + if (responseObject.result === true && responseObject.labels === toUpdateLabel) { // 7-day outdated, add 'To Update !' label + console.log(`Going to ask for an update now for issue #${issueNum}`); + await removeLabels(issueNum, statusUpdatedLabel, inactiveLabel); + await addLabels(issueNum, responseObject.labels); + await postComment(issueNum, assignees, toUpdateLabel); + } else if (responseObject.result === true && responseObject.labels === inactiveLabel) { // 14-day outdated, add '2 Weeks Inactive' label + console.log(`Going to ask for an update now for issue #${issueNum}`); + await removeLabels(issueNum, toUpdateLabel, statusUpdatedLabel); + await addLabels(issueNum, responseObject.labels); + await postComment(issueNum, assignees, inactiveLabel); + } else if (responseObject.result === false && responseObject.labels === statusUpdatedLabel) { // Updated within 3 days, retain 'Status: Updated' label if there is one + console.log(`Updated within 3 days, retain updated label for issue #${issueNum}`); + await removeLabels(issueNum, toUpdateLabel, inactiveLabel); + } else if (responseObject.result === false && responseObject.labels === '') { // Updated between 3 and 7 days, or recently assigned, or fixed by a PR by assignee, remove all three update-related labels + console.log(`No updates needed for issue #${issueNum}, will remove all labels`); + await removeLabels(issueNum, toUpdateLabel, inactiveLabel, statusUpdatedLabel); + } + } +} + /** * Generator that returns issue numbers from cards in a column. * @param {Number} columnId the id of the column in GitHub's database @@ -99,8 +103,8 @@ async function* getIssueNumsFromColumn(columnId) { */ async function getTimeline(issueNum) { - let arra = [] - let page = 1 + let arra = [] + let page = 1 while (true) { try { const results = await github.issues.listEventsForTimeline({ @@ -111,19 +115,19 @@ async function getTimeline(issueNum) { page: page, }); if (results.data.length) { - arra = arra.concat(results.data); + arra = arra.concat(results.data); } else { break } } catch (err) { console.log(error); - continue + continue } finally { page++ } } - return arra + return arra } /** @@ -131,87 +135,68 @@ async function getTimeline(issueNum) { * @param {Array} timeline a list of events in the timeline of an issue, retrieved from the issues API * @param {Number} issueNum the issue's number * @param {String} assignees a list of the issue's assignee's username - * @returns true if timeline indicates the issue is outdated and inactive, false if not; also returns appropriate labels - * Note: Outdated means that the assignee did not make a linked PR or comment within the threedaycutoffTime (see global variables), while inactive is for 14 days + * @returns true if timeline indicates the issue is outdated/inactive, false if not; also returns appropriate labels that should be retained or added to the issue */ -async function isTimelineOutdated(timeline, issueNum, assignees) { - assignedWithinFourteenDays = false; - for await (let [index, moment] of timeline.entries()) { - if (isMomentRecent(moment.created_at, threeDayCutoffTime)) { // all the events of an issue within last three days will return true - if (moment.event == 'cross-referenced' && isLinkedIssue(moment, issueNum) && assignees == moment.actor.login) { // checks if cross referenced within last three days - return {result: false, labels: statusUpdatedLabel} - } - else if (moment.event == 'commented' && isCommentByAssignees(moment, assignees)) { // checks if commented within last three days - return {result: false, labels: statusUpdatedLabel} - } - else if (moment.event == 'assigned' && assignees == moment.assignee) - { - assignedWithinFourteenDays = true; - } - else if (index === timeline.length-1 && (Date.parse(timeline[0].created_at) < fourteenDayCutoffTime.valueOf())) { // returns true if issue was created before 14 days after comparing the two dates in millisecond format - return {result: true, labels: inactiveLabel} - } - else if (index === timeline.length-1 && (Date.parse(timeline[0].created_at) < threeDayCutoffTime.valueOf())) { // returns true if issue was created before 3 days - return {result: true, labels: toUpdateLabel} - } - else if (index === timeline.length-1) { // returns true if above two else ifs are false meaning issue was created within last 3 days - return {result: true, labels: statusUpdatedLabel} - } - } - else if (isMomentRecent(moment.created_at, sevenDayCutoffTime)) { // all the events of an issue between three and seven days will return true - if (moment.event == 'cross-referenced' && isLinkedIssue(moment, issueNum) && assignees == moment.actor.login) { // checks if cross referenced between 3 and 7 days - console.log('between 3 and 7 cross referenced'); - return {result: false, labels: statusUpdatedLabel} - } - else if (moment.event == 'commented' && isCommentByAssignees(moment, assignees)) { // checks if commented between 3 and 7 days - console.log('between 3 and 7 commented'); - return {result: false, labels: statusUpdatedLabel} - } - else if (moment.event == 'assigned' && assignees == moment.assignee) - { - assignedWithinFourteenDays = true; - } - else if (index === timeline.length-1 && (Date.parse(timeline[0].created_at) < fourteenDayCutoffTime.valueOf())) { // returns true if issue was created before 14 days after comparing the two dates in millisecond format - return {result: true, labels: inactiveLabel} - } - else if (index === timeline.length-1) { // returns true if the latest event created is between 3 and 7 days - return {result: true, labels: toUpdateLabel} - } - } - else if (isMomentRecent(moment.created_at, fourteenDayCutoffTime)) { // all the events of an issue between seven and fourteen days will return true - if (moment.event == 'cross-referenced' && isLinkedIssue(moment, issueNum) && assignees == moment.actor.login) { // checks if cross referenced between 7 and 14 days - console.log('between 7 and 14 cross referenced'); - return {result: false, labels: statusUpdatedLabel} - } - else if (moment.event == 'commented' && isCommentByAssignees(moment, assignees)) { // checks if commented between 3 and 7 days - console.log('between 7 and 14 commented'); - return {result: false, labels: statusUpdatedLabel} - } - else if (moment.event == 'assigned' && assignees == moment.assignee) - { - assignedWithinFourteenDays = true; - } - else if (index === timeline.length-1 && (Date.parse(timeline[0].created_at) < fourteenDayCutoffTime.valueOf())) { // returns true if issue was created before 14 days after comparing the two dates in millisecond format - return {result: true, labels: inactiveLabel} - } - else if (index === timeline.length-1) { // returns true if the latest event created is between 7 and 14 days - return {result: true, labels: toUpdateLabel} - } - } - else { // all the events of an issue older than fourteen days will be processed here - if (moment.event == 'cross-referenced' && isLinkedIssue(moment, issueNum) && assignees == moment.actor.login) { // checks if cross referenced older than fourteen days - console.log('14 day event cross referenced'); - return {result: false, labels: statusUpdatedLabel} - } - else if (index === timeline.length-1) { // returns true if the latest event created is older than 14 days - return {result: true, labels: inactiveLabel} - } - } - } - if (assignedWithinFourteenDays) - return {result: true, labels: toUpdateLabel} -} +function isTimelineOutdated(timeline, issueNum, assignees) { // assignees is an arrays of `login`'s + let lastAssignedTimestamp = null; + let lastCommentTimestamp = null; + + for (let i = timeline.length - 1; i >= 0; i--) { + let eventObj = timeline[i]; + let eventType = eventObj.event; + + // if cross-referenced and fixed/resolved/closed by assignee, remove all update-related labels, remove all three labels + if (eventType === 'cross-referenced' && isLinkedIssue(eventObj, issueNum) && assignees.includes(eventObj.actor.login)) { // isLinkedIssue checks if the 'body'(comment) of the event mentioned closing/fixing/resolving this current issue + console.log(`Issue #${issueNum} fixed/resolved/closed by assignee, remove all update-related labels`); + return { result: false, labels: '' } // remove all three labels + } + + let eventTimestamp = eventObj.updated_at || eventObj.created_at; + + // update the lastCommentTimestamp if this is the last (most recent) comment by an assignee + if (!lastCommentTimestamp && eventType === 'commented' && isCommentByAssignees(eventObj, assignees)) { + lastCommentTimestamp = eventTimestamp; + } + + // update the lastAssignedTimestamp if this is the last (most recent) time an assignee was assigned to the issue + else if (!lastAssignedTimestamp && eventType === 'assigned' && assignees.includes(eventObj.assignee.login)) { + lastAssignedTimestamp = eventTimestamp; + } + } + + if (lastCommentTimestamp && isMomentRecent(lastCommentTimestamp, threeDayCutoffTime)) { // if commented by assignee within 3 days + console.log(`Issue #${issueNum} commented by assignee within 3 days, retain 'Status: Updated' label`); + return { result: false, labels: statusUpdatedLabel } // retain (don't add) updated label, remove the other two + } + + if (lastAssignedTimestamp && isMomentRecent(lastAssignedTimestamp, threeDayCutoffTime)) { // if an assignee was assigned within 3 days + console.log(`Issue #${issueNum} assigned to assignee within 3 days, no update-related labels should be used`); + return { result: false, labels: '' } // remove all three labels + } + + if ((lastCommentTimestamp && isMomentRecent(lastCommentTimestamp, sevenDayCutoffTime)) || (lastAssignedTimestamp && isMomentRecent(lastAssignedTimestamp, sevenDayCutoffTime))) { // if updated within 7 days + if ((lastCommentTimestamp && isMomentRecent(lastCommentTimestamp, sevenDayCutoffTime))) { + console.log(`Issue #${issueNum} commented by assignee between 3 and 7 days, no update-related labels should be used; timestamp: ${lastCommentTimestamp}`) + } else if (lastAssignedTimestamp && isMomentRecent(lastAssignedTimestamp, sevenDayCutoffTime)) { + console.log(`Issue #${issueNum} assigned between 3 and 7 days, no update-related labels should be used; timestamp: ${lastAssignedTimestamp}`) + } + return { result: false, labels: '' } // remove all three labels + } + + if ((lastCommentTimestamp && isMomentRecent(lastCommentTimestamp, fourteenDayCutoffTime)) || (lastAssignedTimestamp && isMomentRecent(lastAssignedTimestamp, fourteenDayCutoffTime))) { // if last comment was between 7-14 days, or no comment but an assginee was assigned during this period, issue is outdated and add 'To Update !' label + if ((lastCommentTimestamp && isMomentRecent(lastCommentTimestamp, fourteenDayCutoffTime))) { + console.log(`Issue #${issueNum} commented by assignee between 7 and 14 days, use 'To Update !' label; timestamp: ${lastCommentTimestamp}`) + } else if (lastAssignedTimestamp && isMomentRecent(lastAssignedTimestamp, fourteenDayCutoffTime)) { + console.log(`Issue #${issueNum} assigned between 7 and 14 days, use 'To Update !' label; timestamp: ${lastAssignedTimestamp}`) + } + return { result: true, labels: toUpdateLabel } // outdated, add 'To Update!' label + } + + // if no comment or assigning found within 14 days, issue is outdated and add '2 weeks inactive' label + console.log(`Issue #${issueNum} has no update within 14 days, use '2 weeks inactive' label`) + return { result: true, labels: inactiveLabel } +} /** * Removes labels from a specified issue @@ -229,7 +214,7 @@ async function removeLabels(issueNum, ...labels) { name: label, }); console.log(`Removed "${label}" from issue #${issueNum}`); - } catch (err) { + } catch (err) { console.error(`Function failed to remove labels. Please refer to the error below: \n `, err); } } @@ -240,7 +225,7 @@ async function removeLabels(issueNum, ...labels) { * @param {Array} labels an array containing the labels to add (captures the rest of the parameters) */ async function addLabels(issueNum, ...labels) { - try { + try { // https://octokit.github.io/rest.js/v18#issues-add-labels await github.issues.addLabels({ owner: context.repo.owner, @@ -250,7 +235,7 @@ async function addLabels(issueNum, ...labels) { }); console.log(`Added these labels to issue #${issueNum}: ${labels}`); // If an error is found, the rest of the script does not stop. - } catch (err){ + } catch (err) { console.error(`Function failed to add labels. Please refer to the error below: \n `, err); } } @@ -301,7 +286,7 @@ async function getAssignees(issueNum) { } catch (err) { console.error(`Function failed to get assignees. Please refer to the error below: \n `, err); return null - } + } } function filterForAssigneesLogins(data) { logins = []; @@ -309,7 +294,7 @@ function filterForAssigneesLogins(data) { logins.push(item.login); } return logins -} +} function createAssigneeString(assignees) { const assigneeString = []; for (let assignee of assignees) { @@ -330,5 +315,5 @@ function formatComment(assignees, labelString) { let completedInstuctions = text.replace('${assignees}', assignees).replace('${cutoffTime}', cutoffTimeString).replace('${label}', labelString); return completedInstuctions } - + module.exports = main \ No newline at end of file