diff --git a/.github/workflows/activity-history-post.yml b/.github/workflows/activity-history-post.yml deleted file mode 100644 index 915d7d7359..0000000000 --- a/.github/workflows/activity-history-post.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Post Activity History to all Users - -on: - schedule: - - cron: 25 4 10 8 * - workflow_dispatch: - -jobs: - Read-CSV: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Parse CSV - id: parse-csv - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }} - script: | - const script = require('./github-actions/activity-trigger/first-post-to-skills-issue.js') - return script({g: github, c: context}) diff --git a/.github/workflows/activity-trigger.yml b/.github/workflows/activity-trigger.yml new file mode 100644 index 0000000000..ee1a6a71f2 --- /dev/null +++ b/.github/workflows/activity-trigger.yml @@ -0,0 +1,44 @@ +name: Member Activity Trigger + +on: + workflow_call: + issues: + types: [opened, assigned, unassigned, closed, reopened] + issue_comment: + types: [created] + pull_request: + types: [opened, closed, reopened] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + +jobs: + Gather-Activity-Event-Information: + runs-on: ubuntu-latest + if: github.repository == 'hackforla/website' + steps: + - uses: actions/checkout@v5 + + - name: Gather Event Details + id: gather-event-details + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }} + script: | + const script = require('./github-actions/activity-trigger/activity-trigger.js'); + const activities = script({github, context}); + return activities; + + - if: ${{ steps.gather-event-details.outputs.result != '[]' }} + name: Post to Skills Issue + id: post-to-skills-issue + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }} + script: | + const activities = JSON.parse(${{ steps.gather-event-details.outputs.result }}); + const script = require('./github-actions/activity-trigger/post-to-skills-issue.js'); + for (const activity of activities) { + await script({github, context}, activity); + } diff --git a/github-actions/activity-trigger/activity-trigger.js b/github-actions/activity-trigger/activity-trigger.js new file mode 100644 index 0000000000..8e71a577c9 --- /dev/null +++ b/github-actions/activity-trigger/activity-trigger.js @@ -0,0 +1,152 @@ +/** + * This function parses the triggered event to determine the trigger eventName and eventAction + * and from this information decide the eventActor (user who is credited for the event). + * @param {Object} github - GitHub object from function calling activity-trigger.js + * @param {Object} context - Context of the function calling activity-trigger.js + * @returns {Object} - An object containing the eventActor and a message + */ +async function activityTrigger({github, context}) { + + let issueNum = ''; + let assignee = ''; + let timeline = ''; + + let eventName = context.eventName; + let eventAction = context.payload.action; + let eventActor = context.actor; + let eventPRAuthor = ''; + let activities = []; + + // Exclude all bot actors from being recorded as a guardrail against infinite loops + const EXCLUDED_ACTORS = ['HackforLABot', 'elizabethhonest', 'github-actions', 'github-advanced-security', 'github-pages', 'dependabot[bot]', 'dependabot-preview[bot]', 'dependabot', 'dependabot-preview']; + + if (eventName === 'issues') { + issueNum = context.payload.issue.number; + eventUrl = context.payload.issue.html_url; + timeline = context.payload.issue.updated_at; + // If issue action is not opened and an assignee exists, then change + // the eventActor to the issue assignee, else retain issue author + assignee = context.payload.assignee?.login; + if (eventAction != 'opened' && assignee != null ) { + console.log(`Issue is ${eventAction}. Change eventActor => ${assignee}`); + eventActor = assignee; + } else { + eventActor = context.payload.issue.user.login; + } + if (eventAction === 'closed') { + let reason = context.payload.issue.state_reason; + eventActor = context.payload.issue.user.login; + eventAction = 'Closed-' + reason; + } + } else if (eventName === 'issue_comment') { + // Check if the comment is on an issue or a pull request + let isPullRequest = context.payload.issue?.pull_request; + if (isPullRequest) { + eventName = 'pull_request_comment'; + } + issueNum = context.payload.issue.number; + eventUrl = context.payload.comment.html_url; + timeline = context.payload.comment.updated_at; + } else if (eventName === 'pull_request') { + issueNum = context.payload.pull_request.number; + eventUrl = context.payload.pull_request.html_url; + timeline = context.payload.pull_request.updated_at; + // If PR closed, check if 'merged' and save 'eventActor' & 'eventPRAuthor' + if (eventAction === 'closed') { + eventAction = context.payload.pull_request.merged ? 'PRmerged' : 'PRclosed'; + eventActor = context.actor; + eventPRAuthor = context.payload.pull_request.user.login; + } + } else if (eventName === 'pull_request_review') { + issueNum = context.payload.pull_request.number; + eventUrl = context.payload.review.html_url; + timeline = context.payload.review.updated_at; + } + + // Return immediately if the issueNum is a Skills Issue- to discourage + // infinite loop (recording comment, recording the recording of comment, etc.) + const isSkillsIssue = await checkIfSkillsIssue(issueNum); + if (isSkillsIssue) { + console.log(`- issueNum: ${issueNum} identified as Skills Issue`); + // return activities; <-- confirm before uncommenting + } + + // Message templates to post on Skills Issue + const actionMap = { + 'issues.opened': 'opened', + 'issues.Closed-completed': 'closed as completed', + 'issues.Closed-not_planned': 'closed as not planned', + 'issues.Closed-duplicate': 'closed as duplicate', + 'issues.reopened': 'reopened', + 'issues.assigned': 'assigned', + 'issues.unassigned': 'unassigned', + 'issue_comment.created': 'commented', + 'pull_request_review.created': 'submitted review', + 'pull_request_comment.created': 'commented', + 'pull_request.opened': 'opened', + 'pull_request.PRclosed': 'closed', + 'pull_request.PRmerged': 'merged', + 'pull_request.reopened': 'reopened' + }; + + let localTime = getDateTime(timeline); + let action = actionMap[`${eventName}.${eventAction}`]; + let message = `- ${eventActor} ${action}: ${eventUrl} at ${localTime}`; + + // Check to confirm the eventActor isn't a bot + const isExcluded = (eventActor) => EXCLUDED_ACTORS.includes(eventActor); + if (!isExcluded(eventActor)) { + console.log(`Not a bot. Message to post: ${message}`); + activities.push([eventActor, message]); + } + + // Only if issue is closed, and eventActor != assignee, return assignee and message + if (eventAction.includes('Closed-') && (eventActor !== assignee)) { + message = `- ${assignee} issue ${action}: ${eventUrl} at ${localTime}`; + activities.push([assignee, message]); + } + // Only if PRclosed or PRmerged, and PRAuthor != eventActor, return PRAuthor and message + if ((eventAction === 'PRclosed' || eventAction === 'PRmerged') && (eventActor != eventPRAuthor)) { + let messagePRAuthor = `- ${eventPRAuthor} PR was ${action}: ${eventUrl} at ${localTime}`; + if (!isExcluded(eventPRAuthor)) { + console.log(`Not a bot. Message to post: ${messagePRAuthor}`); + activities.push([eventPRAuthor, messagePRAuthor]); + } + } + + return JSON.stringify(activities); + + + + /** + * Helper function to check if issueNum (that triggered the event) is a Skills Issue + * @param {Number} issueNum - issueNum to check + * @returns {Boolean} - true if Skills Issue, false if not + */ + async function checkIfSkillsIssue(issueNum) { + // https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#list-labels-for-an-issue + const labelData = await github.request('GET /repos/{owner}/{repo}/issues/{issue_number}/labels', { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNum + }); + const isSkillsIssue = labelData.data.some(label => label.name === "Complexity: Prework"); + return isSkillsIssue; + } + + + + /** + * Helper function to get the date and time in a readable format + * @param {String} timeline - the date and time string from the event + * @returns {String} dateTime - formatted date and time string + */ + function getDateTime(timeline) { + const date = new Date(timeline); + const options = { timeZone: 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: true, timeZoneName: 'short' }; + return date.toLocaleString('en-US', options); + } + +} + +module.exports = activityTrigger; diff --git a/github-actions/activity-trigger/post-to-skills-issue.js b/github-actions/activity-trigger/post-to-skills-issue.js new file mode 100644 index 0000000000..ced2abcd33 --- /dev/null +++ b/github-actions/activity-trigger/post-to-skills-issue.js @@ -0,0 +1,115 @@ +// Import modules +const retrieveLabelDirectory = require('../utils/retrieve-label-directory'); +const querySkillsIssue = require('../utils/query-skills-issue'); +const postComment = require('../utils/post-issue-comment'); +const checkTeamMembership = require('../utils/check-team-membership'); +const statusFieldIds = require('../utils/_data/status-field-ids'); +const mutateIssueStatus = require('../utils/mutate-issue-status'); + +// `complexity0` refers `Complexity: Prework` label +const SKILLS_LABEL = retrieveLabelDirectory("complexity0"); + + + +/** + * Function to get eventActor's Skills Issue and post message + * @param {Object} github - GitHub object + * @param {Object} context - Context object + * @param {Object} activity - eventActor and message + * + */ +async function postToSkillsIssue({github, context}, activity) { + + const owner = context.repo.owner; + const repo = context.repo.repo; + const TEAM = 'website-write'; + + const [eventActor, message] = activity; + const MARKER = ''; + const IN_PROGRESS_ID = statusFieldIds('In_Progress'); + + // If eventActor undefined, exit + if (!eventActor) { + console.log(`eventActor is undefined (likely a bot). Cannot post message.`); + return; + } + + // Get eventActor's Skills Issue number, nodeId, current statusId (all null if no Skills Issue found) + const skillsInfo = await querySkillsIssue(github, context, eventActor, SKILLS_LABEL); + const skillsIssueNum = skillsInfo.issueNum; + const skillsIssueNodeId = skillsInfo.issueId; + const skillsStatusId = skillsInfo.statusId; + + // Return immediately if Skills Issue not found + if (skillsIssueNum) { + console.log(`Found Skills Issue for ${eventActor}: #${skillsIssueNum}`); + } else { + console.log(`Did not find Skills Issue for ${eventActor}. Cannot post message.`); + return; + } + + // Get all comments from the Skills Issue + let commentData; + try { + // https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#list-issue-comments + commentData = await github.request('GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { + owner, + repo, + issue_number: skillsIssueNum, + }); + } catch (err) { + console.error(`GET comments failed for issue #${skillsIssueNum}:`, err); + return; + } + + // Find the comment that includes the MARKER text and append message + const commentFound = commentData.data.find(comment => comment.body.includes(MARKER)); + const commentFoundId = commentFound ? commentFound.id : null; + + if (commentFound) { + console.log(`Found comment with MARKER: ${MARKER}`); + const commentId = commentFoundId; + const originalBody = commentFound.body; + const updatedBody = `${originalBody}\n${message}`; + try { + // https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#update-an-issue-comment + await github.request('PATCH /repos/{owner}/{repo}/issues/comments/{commentId}', { + owner, + repo, + commentId, + body: updatedBody + }); + } catch (err) { + console.error(`Something went wrong updating comment:`, err); + } + + } else { + console.log(`MARKER not found in comments, creating new comment with MARKER...`); + const body = `${MARKER}\n## Activity Log: ${eventActor}\n### Repo: https://github.com/hackforla/website\n\n##### ⚠ Important note: The bot updates this comment automatically - do not edit\n\n${message}`; + await postComment(skillsIssueNum, body, github, context); + } + + // If eventActor is team member, open issue and move to "In progress". Else, close issue + const isActiveMember = await checkTeamMembership(github, context, eventActor, TEAM); + let skillsIssueState = "closed"; + + if (isActiveMember) { + skillsIssueState = "open"; + // Update item's status to "In progress (actively working)" if not already + if (skillsIssueNodeId && skillsStatusId !== IN_PROGRESS_ID) { + await mutateIssueStatus(github, context, skillsIssueNodeId, IN_PROGRESS_ID); + } + } + try { + await github.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', { + owner, + repo, + issue_number: skillsIssueNum, + state: skillsIssueState, + }); + } catch (err) { + console.error(`Failed to update issue #${skillsIssueNum} state:`, err) + } +} + +module.exports = postToSkillsIssue; diff --git a/github-actions/trigger-schedule/add-update-label-weekly/add-label.js b/github-actions/trigger-schedule/add-update-label-weekly/add-label.js index 6c6faaf6e2..224b23448e 100644 --- a/github-actions/trigger-schedule/add-update-label-weekly/add-label.js +++ b/github-actions/trigger-schedule/add-update-label-weekly/add-label.js @@ -19,6 +19,7 @@ const [ er, epic, dependency, + skillsIssueCompleted, ] = [ "statusUpdated", "statusInactive1", @@ -26,7 +27,8 @@ const [ "draft", "er", "epic", - "dependency" + "dependency", + "skillsIssueCompleted", ].map(retrieveLabelDirectory); const updatedByDays = 3; // If last update update 3 days, the issue is considered updated @@ -100,7 +102,7 @@ async function main({ g, c }) { * @returns {Promise} issueNums - an array of open, assigned, and statused issue numbers */ async function getIssueNumsFromRepo() { - const labelsToExclude = [draft, er, epic, dependency]; + const labelsToExclude = [draft, er, epic, dependency, skillsIssueCompleted]; let issueNums = []; let pageNum = 1; let result = []; @@ -369,7 +371,7 @@ function formatComment(assignees, labelString) { function isCommentByBot(data) { let botLogin = "github-actions[bot]"; let hflaBotLogin = "HackforLABot"; - // If the comment includes the MARKER, return false + // If the comment includes the MARKER, return false so it is not minimized let MARKER = ''; if (data.body.includes(MARKER)) { console.log(`Found "Skills Issue Activity Record" - do not minimize`); diff --git a/github-actions/utils/check-team-membership.js b/github-actions/utils/check-team-membership.js index cf6557a243..7977fe3785 100644 --- a/github-actions/utils/check-team-membership.js +++ b/github-actions/utils/check-team-membership.js @@ -1,35 +1,26 @@ -/** - * @param {octokit} github - Octokit object used to access GitHub API - * @param {Object} context - context object from actions/github-script - * @param {String} githubUsername - The GitHub username of the user whose membership is to be checked. - * @param {String} team - The HFLA team the username's membership is checked against. Example: 'website-write' - * - * Returns true or false depending on whether the username is found on the passed team, 404 means the user passed - * wasn't found on the team passed. Any other type of error will be thrown. - * - * Need read:org permission to use this function. Lack of permission will result in a 403 error. - * - * The method of obtaining the GitHub username will vary depending on the contents of the context object. See GitHub - * action docs on printing context information into the log. - */ -async function isMemberOfTeam(github, context, githubUsername, team) { +/** +* Checks whether user is on the specified project team +* @param {octokit} github - Octokit object used to access GitHub API +* @param {String} username - The GitHub username of the user whose membership is to be checked. +* @param {String} team - The team the username's membership is checked against. Example: 'website-write' +* @returns {Boolean} +*/ + +async function isMemberOfTeam(github, context, username, team) { try { await github.rest.teams.getMembershipForUserInOrg({ org: context.repo.owner, team_slug: team, - username: githubUsername + username: username, }); - console.log(`User '${githubUsername}' is member of team '${team}'`); + console.log(`User '${username}' is member of team '${team}'`); return true; } catch (verificationError) { if (verificationError.status === 404) { - console.log(`User '${githubUsername}' is not a team member`); + console.log(`User '${username}' is not a team member`); return false; } else { throw verificationError; } } } - -module.exports = isMemberOfTeam; - diff --git a/github-actions/utils/query-issue-info.js b/github-actions/utils/query-issue-info.js index 8583d0e2fa..41777fc6e4 100644 --- a/github-actions/utils/query-issue-info.js +++ b/github-actions/utils/query-issue-info.js @@ -60,4 +60,4 @@ async function queryIssueInfo(github, context, issueNum) { } } -module.exports = queryIssueInfo; +module.exports = queryIssueInfo; \ No newline at end of file diff --git a/github-actions/utils/query-skills-issue.js b/github-actions/utils/query-skills-issue.js new file mode 100644 index 0000000000..8f56853c3f --- /dev/null +++ b/github-actions/utils/query-skills-issue.js @@ -0,0 +1,78 @@ +/** + * @description Query for Skills Issue information using assignee and label + * @param {Object} github - GitHub object from function calling queryIssueInfo() + * @params {Object} context - Context of the function calling queryIssueInfo() + * @params {String} assignee - The GitHub username of the assignee + * @params {String} label - The label to filter issues by (e.g., "Complexity: Prework") + * @returns {Object} - An object containing the item ID and its status name + */ +async function querySkillsIssue(github, context, assignee, label) { + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + + const query = `query($owner: String!, $repo: String!, $assignee: String!, $label: String!) { + repository(owner: $owner, name: $repo) { + issues( + first: 5 + filterBy: {assignee: $assignee, labels: [$label]} + states: [OPEN, CLOSED] + ) { + nodes { + number + projectItems(first: 5) { + nodes { + id + project { + id + title + } + fieldValues(first: 15) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + } + } + } + } + } + } + } + } + }`; + + const variables = { + owner: repoOwner, + repo: repoName, + assignee: assignee, + label: label + }; + + try { + const response = await github.graphql(query, variables); + + // Extract the list of project items associated with the issue + const issueNode = response.repository.issues.nodes[0]; + + const issueNum = issueNode.number; + const issueId = issueNode.projectItems.nodes[0]?.id; + + const fieldValues = response.repository.issues.nodes[0].projectItems.nodes[0].fieldValues?.nodes ?? []; + const statusField = fieldValues.find(node => node.name && node.optionId); + const statusName = statusField?.name; + const statusId = statusField?.optionId; + + return { issueNum, issueId, statusName, statusId }; + } catch (error) { + // If an error occurs, log it and return an object with null values + console.error(`Error querying skills issue: ${error.message}`); + return { + issueNum: null, + issueId: null, + statusName: "Unknown Status", + statusId: null, + }; + } +} + +module.exports = querySkillsIssue; \ No newline at end of file