Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b2d9079
Create member-activity-trigger.yml
t-will-gillis Jul 22, 2025
d108995
Rename member-activity-trigger.yml to activity-trigger.yml
t-will-gillis Jul 22, 2025
9f912c6
Create activity-trigger.js
t-will-gillis Jul 22, 2025
9557aef
Create post-to-skills-issue.js
t-will-gillis Jul 22, 2025
297b97c
Create get-skills-issue.js
t-will-gillis Jul 22, 2025
d198314
change 'retrieve' --> 'get'
t-will-gillis Jul 22, 2025
f99e604
revise comments
t-will-gillis Jul 22, 2025
01154d1
Declare variables
t-will-gillis Jul 28, 2025
190f7bb
declared variables; team --> TEAM; add semicolon; remove variable dec…
t-will-gillis Jul 28, 2025
bbca6e8
Add conditional for GHA to run only if 'hackforla/website'
t-will-gillis Jul 28, 2025
424dcd3
Merge branch 'hackforla:gh-pages' into gha-add-comments-skills-4820
t-will-gillis Aug 11, 2025
047e852
edits to match existing Skills Issue comments
t-will-gillis Aug 11, 2025
1736461
reformat history and add error handling
t-will-gillis Aug 13, 2025
e6ffb12
major updates to get graphQL working
t-will-gillis Aug 14, 2025
e13dcd3
revert to previous
t-will-gillis Aug 15, 2025
2e72548
Merge branch 'hackforla:gh-pages' into gha-add-comments-skills-4820
t-will-gillis Aug 15, 2025
704cb2f
change 'pull request' to 'PR'
t-will-gillis Aug 17, 2025
6c83608
major change- 2 actors PR closed
t-will-gillis Aug 19, 2025
ad7183a
tweak for pr.closed if actor same for both, return one
t-will-gillis Aug 19, 2025
b7b0874
Update activity-trigger.js
t-will-gillis Aug 20, 2025
e67917d
Bump `actions/checkout@v4` --> `@v5` (Dependabot)
t-will-gillis Aug 24, 2025
ef5987a
add error handling at fieldValues
t-will-gillis Aug 25, 2025
926d2bf
add in error handling, minor changes
t-will-gillis Aug 27, 2025
b541df6
more error catching, change to eventActor
t-will-gillis Aug 27, 2025
dc14fb4
Create activity-history-post.yml
t-will-gillis Aug 31, 2025
0c608a9
Merge branch 'gh-pages' into gha-add-comments-skills-4820
t-will-gillis Aug 31, 2025
3c3ec75
Delete .github/workflows/activity-history-post.yml
t-will-gillis Aug 31, 2025
19d31ce
Update check-team-membership.js
t-will-gillis Aug 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions .github/workflows/activity-history-post.yml

This file was deleted.

44 changes: 44 additions & 0 deletions .github/workflows/activity-trigger.yml
Original file line number Diff line number Diff line change
@@ -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);
}
152 changes: 152 additions & 0 deletions github-actions/activity-trigger/activity-trigger.js
Original file line number Diff line number Diff line change
@@ -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;
115 changes: 115 additions & 0 deletions github-actions/activity-trigger/post-to-skills-issue.js
Original file line number Diff line number Diff line change
@@ -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 = '<!-- Skills Issue Activity Record -->';
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)

Check notice

Code scanning / CodeQL

Semicolon insertion Note

Avoid automated semicolon insertion (97% of all statements in
the enclosing function
have an explicit semicolon).
}
}

module.exports = postToSkillsIssue;
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ const [
er,
epic,
dependency,
skillsIssueCompleted,
] = [
"statusUpdated",
"statusInactive1",
"statusInactive2",
"draft",
"er",
"epic",
"dependency"
"dependency",
"skillsIssueCompleted",
].map(retrieveLabelDirectory);

const updatedByDays = 3; // If last update update 3 days, the issue is considered updated
Expand Down Expand Up @@ -100,7 +102,7 @@ async function main({ g, c }) {
* @returns {Promise<Array>} 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 = [];
Expand Down Expand Up @@ -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 = '<!-- Skills Issue Activity Record -->';
if (data.body.includes(MARKER)) {
console.log(`Found "Skills Issue Activity Record" - do not minimize`);
Expand Down
Loading
Loading