Skip to content

Commit

Permalink
Fixes issue 58 install 2 week inactive GitHub automation (#123)
Browse files Browse the repository at this point in the history
* copied from & refactored hackforla/website/github-actions/utils/get-timeline.js

* copied from hackforla/website/github-actions/utils/find-linked-issue.js

* copied from & refactored hackforla/website/github-actions/trigger-schedule/add-update-label-weekly

* copied over & changed schedule to run on Sundays

* copied from & refactored hackforla/website/github-actions/utils/get-timeline.js

* copied from hackforla/website/github-actions/utils/find-linked-issue.js

* copied from & refactored hackforla/website/github-actions/trigger-schedule/add-update-label-weekly

* copied over & changed schedule to run on Sundays

* Update add-update-label-weekly.yml

try to trigger work flow on push

* fix typo

* add error handling for type error and retries

* small refactor

* copied from & refactored hackforla/website/github-actions/trigger-schedule/add-update-label-weekly

* changed schedule to run on Sundays

* add run workflow on push

* use issueNum = 1 for testing

* set issueNums for testing only

* test log COLUMN_ID

* refactor fn isTimelineOutdated to improve readability & better handle when eventObj.source is undefined

* add error handling for expected behavior when attempting to remove a label that does not exist

* clean up console log

* clean up console log

* remove temp value used for testing, add error logging for additional insight later on
  • Loading branch information
joey-ma authored May 9, 2024
1 parent 712b3e8 commit 2b4ac46
Show file tree
Hide file tree
Showing 5 changed files with 391 additions and 0 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/add-update-label-weekly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Schedule Sunday 0700

on:
schedule:
- cron: '0 7 * * 0'

jobs:
Add-Update-Label-to-Issues-Weekly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v7
env:
IN_PROGRESS_COLUMN_ID: ${{ secrets.IN_PROGRESS_COLUMN_ID }}
with:
script: |
const { IN_PROGRESS_COLUMN_ID } = process.env;
const script = require('./github-actions/trigger-schedule/add-update-label-weekly/add-label.js');
script({ g: github, c: context }, IN_PROGRESS_COLUMN_ID);
288 changes: 288 additions & 0 deletions github-actions/trigger-schedule/add-update-label-weekly/add-label.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
// Import modules
const findLinkedIssue = require('../../utils/find-linked-issue');
const getTimeline = require('../../utils/get-timeline');
var fs = require("fs");
// Global variables
var github;
var context;
const statusUpdatedLabel = 'Status: Updated';
const toUpdateLabel = 'To Update !';
const inactiveLabel = '2 weeks inactive';
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()
sevenDayCutoffTime.setDate(sevenDayCutoffTime.getDate() - commentByDays)
const fourteenDayCutoffTime = new Date()
fourteenDayCutoffTime.setDate(fourteenDayCutoffTime.getDate() - inactiveUpdatedByDays)

/**
* The main function retrieves issues from a specific column in a specific project and examines the timeline of each issue for staleness.
* An update to an issue is either (1) a comment by the assignee, or (2) a user assignment to the issue.
* If the last update is not within 7 days or 14 days, apply the appropriate "to update" 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 representing the 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 (const issueNum of issueNums) {
console.log("🚀 ~ main ~ issueNums:", issueNum)
const timeline = await getTimeline(issueNum, github, context);
const assignees = await getAssignees(issueNum);
// Error handling
if (assignees.length === 0) {
console.error(`Assignee not found, skipping issue #${issueNum}`);
continue;
}

// Add, remove labels, and post comment if the issue's timeline indicates the issue is inactive, needs an update, or is current.
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);
}
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
* @returns {Array} of issue numbers
*/
async function* getIssueNumsFromColumn(columnId) {
if (!columnId) console.error(`column id "${columnId}" is falsy.`);

let page = 1;
while (page < 100) {
try {
const results = await github.rest.projects.listCards({
column_id: columnId,
per_page: 100,
page: page
});
console.log("🚀 ~ function*getIssueNumsFromColumn ~ results:", results)
if (results.data.length) {
for (let card of results.data) {
if (card.hasOwnProperty('content_url')) {
const arr = card.content_url.split('/');
console.log("🚀 ~ function*getIssueNumsFromColumn ~ arr:", arr)
yield arr.pop()
}
}
} else {
return
}
} catch {
continue
} finally {
page++;
}
}
}

/**
* Assesses whether the timeline is outdated.
* @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 login username
* @returns {Object} { result, labels }
* - result: a boolean if timeline indicates the issue is outdated/inactive.
* - labels: a string label that should be removed, retained or added to the issue.
*/
function isTimelineOutdated(timeline, issueNum, assignees) {
let lastAssignedTimestamp = null;
let lastCommentTimestamp = null;

for (let i = timeline.length - 1; i >= 0; i--) {
const eventObj = timeline[i];
const eventType = eventObj.event;

const isCrossReferencedEvent = eventType === 'cross-referenced';
// Checks if the 'body' (comment) of the event mentions fixes/resolves/closes this current issue.
const isLinkedIssue = isCrossReferencedEvent ? findLinkedIssue(eventObj.source.issue.body) == issueNum : false;
// If cross-referenced and fixed/resolved/closed by assignee and the pull request is open, remove all update-related labels.
const isOpenLinkedPullRequest = isCrossReferencedEvent && isLinkedIssue && eventObj.source.issue.state === 'open';

if (isOpenLinkedPullRequest) {
// Once a PR is opened, we remove labels because we focus on the PR not the issue.
if (isOpenLinkedPullRequest && assignees.includes(eventObj.actor.login)) {
console.log(`Assignee fixes/resolves/closes Issue #${issueNum} in with an open pull request, remove all update-related labels`);
return { result: false, labels: '' } // Remove all three labels
}
}

// If the event is a linked PR and the PR is closed, continue the conditional checks to return the appropriate { result, labels }.
if (isCrossReferencedEvent && eventObj.source.issue.state === 'closed') {
console.log(`Pull request linked to Issue #${issueNum} is closed.`);
}

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' && assignees.includes(eventObj.actor.login)) {
lastCommentTimestamp = eventTimestamp;
}

// Update the lastAssignedTimestamp if this is the last (most recent) time an assignee was assigned to the issue
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 last comment was between 7-14 days, or no comment but an assignee was assigned during this period, issue is outdated and add 'To Update !' label
if ((lastCommentTimestamp && isMomentRecent(lastCommentTimestamp, fourteenDayCutoffTime)) || (lastAssignedTimestamp && isMomentRecent(lastAssignedTimestamp, fourteenDayCutoffTime))) {
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
* @param {Number} issueNum an issue's number
* @param {Array} labels an array containing the labels to remove (captures the rest of the parameters)
*/
async function removeLabels(issueNum, ...labels) {
for (let label of labels) {
try {
// https://octokit.github.io/rest.js/v18#issues-remove-label
const response = await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNum,
name: label,
});
console.log(`Removed "${label}" from issue #${issueNum}`);
console.log(`Remaining labels: "${response}"`);
} catch (err) {
const { message } = err;
if (err.status === 404) console.log({ status: 404, message, label })
else console.error(`Function failed to remove labels. Please refer to the error below: \n `, err);
}
}
}

/**
* Adds labels to a specified issue
* @param {Number} issueNum an issue's number
* @param {Array} labels an array containing the labels to add (captures the rest of the parameters)
*/

async function addLabels(issueNum, ...labels) {
try {
// https://octokit.github.io/rest.js/v18#issues-add-labels
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNum,
labels: 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) {
console.error(`Function failed to add labels. Please refer to the error below: \n `, err);
}
}

async function postComment(issueNum, assignees, labelString) {
try {
const assigneeString = assignees.map(assignee => `@${assignee}`).join(', '); // createAssigneesString
const instructions = formatComment(assigneeString, labelString);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNum,
body: instructions,
});
} catch (err) {
console.error(`Function failed to post comments. Please refer to the error below: \n `, err);
}
}

/***********************
*** HELPER FUNCTIONS ***
***********************/
function isMomentRecent(dateString, cutoffTime) {
const dateStringObj = new Date(dateString);

if (dateStringObj >= cutoffTime) return true
else return false
}
async function getAssignees(issueNum) {
try {
const results = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNum,
});
const assigneesData = results.data.assignees;
assigneesLogins = assigneesData.map(item => item.login); // filter for assignees logins
return assigneesLogins
} catch (err) {
console.error(`Function failed to get assignees. Please refer to the error below: \n `, err);
return null
}
}
function formatComment(assignees, labelString) {
const path = './github-actions/trigger-schedule/add-update-label-weekly/update-instructions-template.md'
const text = fs.readFileSync(path).toString('utf-8');
const options = {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'America/Los_Angeles',
}
const cutoffTimeString = threeDayCutoffTime.toLocaleString('en-US', options);
let completedInstructions = text.replace('${assignees}', assignees).replace('${cutoffTime}', cutoffTimeString).replace('${label}', labelString);
return completedInstructions
}

module.exports = main
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
${assignees}

Please add update using the below template (even if you have a pull request). Afterwards, remove the '${label}' label and add the 'Status: Updated' label.
1. Progress: "What is the current status of your project? What have you completed and what is left to do?"
2. Blockers: "Difficulties or errors encountered."
3. Availability: "How much time will you have this week to work on this issue?"
4. ETA: "When do you expect this issue to be completed?"
5. Pictures (optional): "Add any pictures of the visual changes made to the site so far."

If you need help, be sure to either: (1) place your issue in the `Questions/In Review` column of the Project Board and ask for help at your next meeting, (2) put a "Status: Help Wanted" label on your issue and pull request, or (3) put up a request for assistance on the #hfla-site channel. Please note that including your questions in the issue comments- along with screenshots, if applicable- will help us to help you. [Here](https://github.com/hackforla/website/issues/1619#issuecomment-897315561) and [here](https://github.com/hackforla/website/issues/1908#issuecomment-877908152) are examples of well-formed questions.

<sub>You are receiving this comment because your last comment was before ${cutoffTime} PST.</sub>
28 changes: 28 additions & 0 deletions github-actions/utils/find-linked-issue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Function that returns a linked issue.
* @param text the text to match keywords
* @returns
*/
function findLinkedIssue(text) {
// Create RegEx for capturing KEYWORD #ISSUE-NUMBER syntax (i.e. resolves #1234)
const KEYWORDS = ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved']
let reArr = [] // an array used to store multiple RegExp patterns
for (const word of KEYWORDS) {
reArr.push(`[\\n|\\s|^]${word} #\\d+\\s|^${word} #\\d+\\s|\\s${word} #\\d+$|^${word} #\\d+$`)
}

// Receive and unpack matches into an Array of Array objs
let re = new RegExp(reArr.join('|'), 'gi')
let matches = text.matchAll(re)
matches = [...matches]

// If only one match is found, return the issue number & console.log results.
if (matches.length == 1) {
const issueNumber = matches[0][0].match(/\d+/)
return issueNumber[0]
} else {
return null
}
}

module.exports = findLinkedIssue
44 changes: 44 additions & 0 deletions github-actions/utils/get-timeline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Function that returns the issue's timeline of events.
* https://octokit.github.io/rest.js/v20#issues-list-events-for-timeline
* @param {number} issue_number
* @returns {Array} of Objects containing the issue's timeline of events
*/
async function getTimeline(issue_number, github, context) {
const { owner, repo } = context.repo;
let timelineArr = [];
let page = 1, retries = 0;
const per_page = 100, maxRetries = 3;

while (true) {
try {
const { data } = await github.rest.issues.listEventsForTimeline({
owner,
repo,
issue_number,
per_page,
page,
});
if (data.length) {
timelineArr = timelineArr.concat(data);
page++;
} else {
break;
}
} catch (err) {
if (err instanceof TypeError) throw new Error(err);
if (retries < maxRetries) {
const delay = Math.pow(2, retries);
console.log(`Retrying in ${delay} seconds...`);
await new Promise(resolve => setTimeout(resolve, delay * 1000));
retries++;
} else {
console.error(err);
throw new Error(`Failed to fetch timeline for issue #${issue_number}`);
}
}
}
return timelineArr;
}

module.exports = getTimeline;

0 comments on commit 2b4ac46

Please sign in to comment.