Skip to content

Commit 2d37124

Browse files
authored
Creates GHA workflow to notify on deletion of in-use issue labels (#8321)
* Creates new Flag Issues Unlabeled After Deletion workflow, with JS hooks This new workflow is called by Update Label Directory if a label is deleted, and creates a notification issue if the deleted label was applied to any other issues in the repo. It then posts a comment to the agenda issue linking to the notification and providing some high level details. If the agenda issue isn't present, the workflow will create a new issue notifying that the agenda issue is missing. * Adds JS scripts and templates for the new Flag Issues Unlabeled After Deletion workflow Includes some utility helper scripts * Moves flag-unlabeled-after-deletion scripts out of sub-directory * Fixes post-to-agenda.js CodeQL warning * Updates missing agenda issue title * Updates flag-issues-unlabeled workflow to latest actions ver * Updates flag issues js scripts for formatting and notification trigger The post-to-agenda script will now create a new notification issue if the status issue is closed, in addition to if it is missing. * Adds labels to label deletion-related issues For both the primary "Label Deletion Notification Issue" and the possible subsequent "Error Posting to Agenda Issue", added ['Complexity: Small', 'size: 0.5pt','Feature: Administrative','role: back end/devOps', 'Ready for product']
1 parent db9a702 commit 2d37124

15 files changed

Lines changed: 551 additions & 0 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# This workflow checks if a deleted label was used by any issues
2+
# and if so, flags the issues that were affected by the deletion
3+
4+
# This workflow is triggered by update-label-directory.yml when
5+
# a label is deleted
6+
7+
name: Flag Issues Unlabeled After Deletion
8+
9+
on:
10+
workflow_call:
11+
secrets:
12+
HACKFORLA_GRAPHQL_TOKEN:
13+
required: true
14+
HACKFORLA_BOT_PA_TOKEN:
15+
required: true
16+
17+
jobs:
18+
Flag-Issues-Unlabeled-After-Deletion:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v5
22+
23+
# Get the issues that had the label prior to the label being deleted
24+
- name: Get recently unlabeled issues
25+
id: get-unlabeled-issues
26+
uses: actions/github-script@v8
27+
with:
28+
github-token: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }}
29+
script: |
30+
const script = require('./github-actions/flag-issues-unlabeled-after-deletion/get-unlabeled-issues.js')
31+
return script({g: github, c: context})
32+
33+
# Create a notification issue detailing the label deleted and affected issues
34+
# Skip if no affected issues
35+
- name: Create notification issue
36+
if: ${{ steps.get-unlabeled-issues.outputs.result != '[]' }}
37+
id: create-notification-issue
38+
uses: actions/github-script@v8
39+
with:
40+
github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }}
41+
script: |
42+
const script = require('./github-actions/flag-issues-unlabeled-after-deletion/create-notification-issue.js')
43+
const unlabeledIssues = ${{ steps.get-unlabeled-issues.outputs.result }}
44+
return script({
45+
g: github,
46+
c: context,
47+
unlabeledIssues: unlabeledIssues
48+
})
49+
50+
# Make a post on the Agenda issue about the label deletion. If there is an
51+
# issue posting to the agenda, create an issue about that.
52+
# Skip if no affected issues
53+
- name: Post to agenda
54+
if: ${{ steps.get-unlabeled-issues.outputs.result != '[]' }}
55+
id: post-to-agenda
56+
uses: actions/github-script@v8
57+
with:
58+
github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }}
59+
script: |
60+
const script = require('./github-actions/flag-issues-unlabeled-after-deletion/post-to-agenda.js')
61+
const unlabeledIssues = ${{ steps.get-unlabeled-issues.outputs.result }}
62+
const notificationIssueNum = ${{ steps.create-notification-issue.outputs.result }}
63+
return script({
64+
g: github,
65+
c: context,
66+
unlabeledIssues,
67+
notificationIssueNum,
68+
});

.github/workflows/update-label-directory.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ on:
88
types: [edited, created, deleted]
99

1010
jobs:
11+
# Check if the deleted label was in use. If so, flag the deletion and affected issues
12+
Flag-Issues-Unlabeled-After-Deletion:
13+
uses: ./.github/workflows/flag-issues-unlabeled-after-deletion.yml
14+
if: ${{ github.event.action == 'deleted' }}
15+
secrets:
16+
HACKFORLA_GRAPHQL_TOKEN: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }}
17+
HACKFORLA_BOT_PA_TOKEN: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }}
18+
19+
# If a label was edited, created, or deleted, update label directory
1120
Update-Label-Directory:
1221
runs-on: ubuntu-latest
1322
if: github.repository == 'hackforla/website'
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const path = require("path");
2+
const getLATimestamp = require("../utils/get-la-timestamp");
3+
const retrieveLabelDirectory = require("../utils/retrieve-label-directory");
4+
const formatIssueList = require("./format-issue-list");
5+
const createTemplatedIssue = require("../utils/create-templated-issue");
6+
const postTemplatedComment = require("../utils/post-templated-comment");
7+
8+
const complexitySmall = retrieveLabelDirectory("complexity2");
9+
const size05pt = retrieveLabelDirectory("size05pt");
10+
const featureAdministrative = retrieveLabelDirectory("featureAdministrative");
11+
const roleBackEndDevOps = retrieveLabelDirectory("roleBackEndDevOps");
12+
const readyForPM = retrieveLabelDirectory("readyForPM");
13+
14+
/**
15+
* Creates new GitHub issue to notify PM team about label deletion
16+
* and request comment from the label deleter
17+
* @param {Object} github - github object from actions/github-script
18+
* @param {Object} context - context opbject from actions/github-script
19+
* @param {Array} unlabeledIssues - Array of issue numbers affected by label deletion
20+
*/
21+
async function createUnlabelNotificationIssue({
22+
g: github,
23+
c: context,
24+
unlabeledIssues,
25+
}) {
26+
// Extract payload data and capture timestamp
27+
const {
28+
name: labelName,
29+
color: labelColor,
30+
description: labelDesc,
31+
id: labelId,
32+
} = context.payload.label;
33+
const labelDeleter = context.payload.sender.login;
34+
const timestamp = getLATimestamp();
35+
36+
// Create the notification issue
37+
const notificationIssueNum = await createTemplatedIssue({
38+
title: `Review Needed - Label \`${labelName}\` Deleted`,
39+
templatePath: path.resolve(
40+
__dirname,
41+
"./templates/notification-issue-body.md",
42+
),
43+
templateVars: {
44+
"${label-name}": labelName,
45+
"${timestamp}": timestamp,
46+
"${deleter}": labelDeleter,
47+
"${label-color}": labelColor,
48+
"${label-description}": labelDesc,
49+
"${label-id}": labelId,
50+
"${affected-issues}": formatIssueList(unlabeledIssues),
51+
},
52+
labels: [complexitySmall, size05pt, featureAdministrative, roleBackEndDevOps, readyForPM],
53+
github: github,
54+
context: context,
55+
});
56+
console.log(`Deleted label issue #${notificationIssueNum} created`);
57+
58+
// Create a comment on the issue to ping the deleter
59+
await postTemplatedComment({
60+
issueNum: notificationIssueNum,
61+
templatePath: path.resolve(
62+
__dirname,
63+
"./templates/notification-issue-comment.md",
64+
),
65+
templateVars: {
66+
"${deleter}": labelDeleter,
67+
"${label-name}": labelName,
68+
"${timestamp}": timestamp,
69+
},
70+
github: github,
71+
context: context,
72+
});
73+
74+
// Return the notification issue number
75+
return notificationIssueNum;
76+
}
77+
78+
module.exports = createUnlabelNotificationIssue;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Format a list of issues into a bullet-point string, e.g.:
3+
* - #123
4+
* - #456
5+
*
6+
* @param {Array>} issues -- Array of JS issues with a `number` field
7+
* @returns {string} -- Formatted list of issues
8+
*/
9+
function formatIssueList(issues) {
10+
if (!Array.isArray(issues)) {
11+
throw new Error("Expected issues to be an array");
12+
}
13+
14+
const issueItems = issues.map((issue) => `- #${issue.number}`);
15+
16+
return issueItems.join("\n");
17+
}
18+
19+
module.exports = formatIssueList;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Function to return list of recently unlabeled issues
3+
* @param {Object} github - github object from actions/github-script
4+
* @param {Object} context - context opbject from actions/github-script
5+
* @returns {Array} recentUnlabels - issues that were recently unlabeled
6+
*/
7+
async function getRecentlyUnlabeledIssues({ g: github, c: context }) {
8+
// Get the name of the label that was just deleted
9+
const deletedLabelName = context.payload.label.name;
10+
11+
// Set the timeout for issue update querying
12+
let fiveMinutesAgo = new Date();
13+
fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 5);
14+
15+
// Set query variables
16+
const variables = {
17+
owner: context.repo.owner,
18+
repo: context.repo.repo,
19+
since: fiveMinutesAgo.toISOString(),
20+
};
21+
22+
// Define GraphQL query. This query returns all issues that were updated within
23+
// the last 5 minutes, and the last 10 unlabeling events for those issues, but does
24+
// NOT ensure that the unlabeling events were within last 5 minutes or that the
25+
// unlabeling was with the most recently deleted label, so post-processing is required
26+
const query = `query ($owner: String!, $repo: String!, $since: DateTime!) {
27+
repository(owner: $owner, name: $repo) {
28+
issues(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}, filterBy: { since: $since }) {
29+
nodes {
30+
number
31+
timelineItems(itemTypes: [UNLABELED_EVENT], last: 10) {
32+
nodes {
33+
... on UnlabeledEvent {
34+
createdAt
35+
label {
36+
name
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
}
44+
}`;
45+
46+
try {
47+
// Execute query
48+
const result = await github.graphql(query, variables);
49+
50+
// Process query results to find issues where the unlabeling events were
51+
// within the last 5 minutes and were from our deleted label
52+
const recentUnlabels = [];
53+
for (const issue of result.repository.issues.nodes) {
54+
// Each issues returned by query
55+
for (const event of issue.timelineItems.nodes) {
56+
// Each unlabeled event of the issue
57+
const eventTime = new Date(event.createdAt);
58+
if (
59+
eventTime >= fiveMinutesAgo &&
60+
event.label.name === deletedLabelName
61+
) {
62+
// Unlabel event was with the target label and within the last 5 minutes
63+
recentUnlabels.push(issue);
64+
}
65+
}
66+
}
67+
68+
console.log(
69+
`Found ${recentUnlabels.length} recently unlabeled issues with label "${deletedLabelName}"`,
70+
);
71+
console.log("Results:", JSON.stringify(recentUnlabels, null, 2));
72+
73+
return recentUnlabels;
74+
} catch (error) {
75+
console.error("GraphQL query failed:", error);
76+
console.error("Query variables:", variables);
77+
console.error("Deleted label name:", deletedLabelName);
78+
throw error;
79+
}
80+
}
81+
82+
module.exports = getRecentlyUnlabeledIssues;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
const path = require("path");
2+
const populateTemplate = require("../utils/populate-template");
3+
const postComment = require("../utils/post-issue-comment");
4+
const createTemplatedIssue = require("../utils/create-templated-issue");
5+
const formatIssueList = require("./format-issue-list");
6+
const getLATimestamp = require("../utils/get-la-timestamp");
7+
const retrieveLabelDirectory = require("../utils/retrieve-label-directory");
8+
9+
const STATIC_ISSUE_NUMS = require("../utils/_data/static-issue-nums.json");
10+
11+
const readyForPM = retrieveLabelDirectory("readyForPM");
12+
const complexitySmall = retrieveLabelDirectory("complexity2");
13+
const size05pt = retrieveLabelDirectory("size05pt");
14+
const featureAdministrative = retrieveLabelDirectory("featureAdministrative");
15+
const roleBackEndDevOps = retrieveLabelDirectory("roleBackEndDevOps");
16+
17+
/**
18+
* Creates new GitHub issue to notify PM team about label deletion
19+
* and request comment from the label deleter
20+
* @param {Object} github - github object from actions/github-script
21+
* @param {Object} context - context opbject from actions/github-script
22+
* @param {Array} unlabeledIssues - Array of issue numbers affected by label deletion
23+
* @param {Number} notificationIssueNum - Number of the created notification issue
24+
*/
25+
async function postUnlabelNotificationToAgenda({
26+
g: github,
27+
c: context,
28+
unlabeledIssues,
29+
notificationIssueNum,
30+
}) {
31+
// Get agenda comment text prior to comment. We will either
32+
// use it in the agenda comment or in the agenda missing issue
33+
const agendaComment = populateTemplate({
34+
templatePath: path.resolve(
35+
__dirname,
36+
"./templates/agenda-issue-comment.md",
37+
),
38+
templateVars: {
39+
"${label-name}": context.payload.label.name,
40+
"${label-deleter}": context.payload.sender.login,
41+
"${affected-issues}": formatIssueList(unlabeledIssues),
42+
"${notification-issue-num}": notificationIssueNum,
43+
},
44+
});
45+
46+
try {
47+
// Check the status of the agenda issue.
48+
const statusIssue = await github.rest.issues.get({
49+
owner: context.repo.owner,
50+
repo: context.repo.repo,
51+
issue_number: STATIC_ISSUE_NUMS.AGENDA});
52+
53+
// If not open, throw error to create a status closed notification issue
54+
if (statusIssue["data"]["state"] !== "open") {
55+
throw new Error("Status issue has state:" + statusIssue["data"]["state"]);
56+
}
57+
58+
// Post comment to agenda issue
59+
await postComment(STATIC_ISSUE_NUMS.AGENDA, agendaComment, github, context);
60+
61+
} catch (err) {
62+
// There was an issue posting to the agenda -- either the agenda is missing or is closed.
63+
// Create a notification issue about this error
64+
const timestamp = getLATimestamp();
65+
const missingAgendaIssueNum = await createTemplatedIssue({
66+
title: `Review Needed - Error Posting to Agenda Issue #${STATIC_ISSUE_NUMS.AGENDA} for Label \`${context.payload.label.name}\` Deletion`,
67+
templatePath: path.resolve(
68+
__dirname,
69+
"./templates/agenda-error-issue-body.md",
70+
),
71+
templateVars: {
72+
"${agenda-issue-num}": STATIC_ISSUE_NUMS.AGENDA,
73+
"${timestamp}": timestamp,
74+
"${script-name}": path.basename(__filename),
75+
"${agenda-comment}": agendaComment,
76+
},
77+
labels: [complexitySmall, size05pt, featureAdministrative, roleBackEndDevOps, readyForPM],
78+
github: github,
79+
context: context,
80+
});
81+
82+
// Could elevate this to a new Error throw if we want workflow to fail
83+
console.log(
84+
`Agenda issue #${STATIC_ISSUE_NUMS.AGENDA} not open or not found. Notification issue #${missingAgendaIssueNum} created. Original error: ${err.message}`,
85+
);
86+
}
87+
}
88+
89+
module.exports = postUnlabelNotificationToAgenda;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
### Overview
2+
3+
An error occurred while posting a comment to agenda issue #${agenda-issue-num}. The agenda issue may be missing or closed.
4+
5+
### Details
6+
7+
- Detected at: `${timestamp}`
8+
- Detected by: `${script-name}`
9+
10+
### Intended Comment
11+
12+
```md
13+
${agenda-comment}
14+
```
15+
16+
---
17+
18+
_Auto-generated by **Flag Issues With Deleted Labels** workflow._
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
**Review Deleted Label: #${notification-issue-num}**
2+
3+
The actively used label `${label-name}` was deleted.
4+
5+
Deleted by: @${label-deleter}
6+
Affected issues:
7+
${affected-issues}

0 commit comments

Comments
 (0)