Skip to content

Commit 4b1d399

Browse files
committed
Create workflow to check linked PR
1 parent b2af171 commit 4b1d399

4 files changed

Lines changed: 371 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Check Closed Issue for Linked PR
2+
3+
on:
4+
issues:
5+
types: [closed]
6+
7+
jobs:
8+
check-for-linked-issue:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Check Out Repository
12+
uses: actions/checkout@v4
13+
14+
- name: Check Issue Labels And Linked PRs
15+
uses: actions/github-script@v8
16+
id: check-issue-labels-and-linked-prs
17+
with:
18+
script: |
19+
const script = require(
20+
'./github-actions'
21+
+ '/check-closed-issue-for-linked-pr'
22+
+ '/check-for-linked-issue'
23+
+ '/check-issue-labels-and-linked-prs.js'
24+
);
25+
const isValidClose = await script({github, context});
26+
console.log(
27+
`Issue is ${isValidClose ? '' : 'not '}allowed to be closed.`
28+
);
29+
core.setOutput('isValidClose', isValidClose);
30+
31+
- name: Reopen Issue
32+
if: steps.check-issue-labels-and-linked-prs.outputs.isValidClose == 'false'
33+
uses: actions/github-script@v8
34+
id: reopen-issue
35+
with:
36+
github-token: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }}
37+
script: |
38+
const script = require(
39+
'./github-actions'
40+
+ '/check-closed-issue-for-linked-pr'
41+
+ '/check-for-linked-issue'
42+
+ '/reopen-issue.js'
43+
);
44+
await script({github, context});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Checks whether a closed issue has a linked PR or one of the labels to excuse
3+
* this GitHub Actions workflow.
4+
*
5+
* @param {{github: object, context: object}} actionsGithubScriptArgs - GitHub
6+
* objects from actions/github-script
7+
* @returns {boolean} False if the issue does not have a linked PR, a "non-PR
8+
* contribution" label, or an "Ignore..." label.
9+
*/
10+
async function hasLinkedPrOrExcusableLabel({ github, context }) {
11+
const repoOwner = context.repo.owner;
12+
const repoName = context.repo.repo;
13+
const issueNumber = context.payload.issue.number;
14+
15+
const labels = context.payload.issue.labels.map((label) => label.name);
16+
17+
// --------------------------------------------------
18+
19+
// Check if the issue has the labels that will avoid re-opening it.
20+
if (
21+
labels.some(
22+
(label) => label === 'non-PR contribution' || label.includes('Ignore')
23+
)
24+
)
25+
return true;
26+
console.info(
27+
`Issue #${issueNumber} does not have ` +
28+
`the necessary labels to excuse reopening it.`
29+
);
30+
31+
// Use GitHub's GraphQL's closedByPullRequestsReferences to more reliably
32+
// determine if there is a linked PR.
33+
const query = `query($owner: String!, $repo: String!, $issue: Int!) {
34+
repository(owner: $owner, name: $repo) {
35+
issue(number: $issue) {
36+
closedByPullRequestsReferences(includeClosedPrs: true, first: 1) {
37+
totalCount
38+
}
39+
}
40+
}
41+
}`;
42+
43+
const variables = {
44+
owner: repoOwner,
45+
repo: repoName,
46+
issue: issueNumber,
47+
};
48+
49+
try {
50+
const response = await github.graphql(query, variables);
51+
52+
const numLinkedPrs =
53+
response.repository.issue.closedByPullRequestsReferences.totalCount;
54+
55+
console.debug(`Number of linked PRs found: ${numLinkedPrs}.`);
56+
57+
if (numLinkedPrs > 0) return true;
58+
} catch (err) {
59+
throw new Error(
60+
`Can not find issue #${issueNumber} or its PR count; error = ${err}`
61+
);
62+
}
63+
console.info(`Issue #${issueNumber} does not have a linked PR.`);
64+
65+
// If the issue does not have a linked PR or any of the excusable labels.
66+
return false;
67+
}
68+
69+
// ==================================================
70+
71+
module.exports = hasLinkedPrOrExcusableLabel;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
'use strict';
2+
3+
const hasLinkedPrOrExcusableLabel = require('./check-issue-labels-and-linked-prs');
4+
5+
// ==================================================
6+
7+
// Create the github and context mocks. Freezing Objects to prevent accidental
8+
// changes.
9+
const github = Object.freeze({ graphql: jest.fn() });
10+
const context = deepFreeze({
11+
repo: {
12+
owner: 'owner1',
13+
repo: 'repo1',
14+
},
15+
payload: {
16+
issue: {
17+
number: 1,
18+
labels: [],
19+
},
20+
},
21+
});
22+
23+
describe('hasLinkedPrOrExcusableLabel', () => {
24+
let contextCopy;
25+
26+
beforeEach(() => {
27+
contextCopy = structuredClone(context);
28+
jest.resetAllMocks();
29+
});
30+
31+
test.each([
32+
[[{ name: 'non-PR contribution' }]],
33+
[
34+
[
35+
{ name: 'non-PR contribution' },
36+
{ name: 'good first issue' },
37+
{ name: 'size: 1pt' },
38+
],
39+
],
40+
])(
41+
'If the issue has the "non-PR contribution" label, then return true. ' +
42+
'Labels: %j.',
43+
async (labelsList) => {
44+
// Arrange
45+
contextCopy.payload.issue.labels = labelsList;
46+
47+
// Act
48+
const result = await hasLinkedPrOrExcusableLabel({
49+
github,
50+
context: contextCopy,
51+
});
52+
53+
// Assert
54+
expect(result).toBe(true);
55+
expect(github.graphql).not.toHaveBeenCalled();
56+
}
57+
);
58+
59+
test.each([
60+
[[{ name: 'Ignore: Test' }]],
61+
[
62+
[
63+
{ name: 'Ignore: Test' },
64+
{ name: 'good first issue' },
65+
{ name: 'size: 1pt' },
66+
],
67+
],
68+
])(
69+
'If the issue has a label that includes "Ignore", then return true. ' +
70+
'Labels: %j',
71+
async (labelsList) => {
72+
// Arrange
73+
contextCopy.payload.issue.labels = labelsList;
74+
75+
// Act
76+
const result = await hasLinkedPrOrExcusableLabel({
77+
github,
78+
context: contextCopy,
79+
});
80+
81+
// Assert
82+
expect(result).toBe(true);
83+
expect(github.graphql).not.toHaveBeenCalled();
84+
}
85+
);
86+
87+
test('If the issue has a linked PR, then return true.', async () => {
88+
// Arrange
89+
github.graphql.mockResolvedValue({
90+
repository: {
91+
issue: {
92+
closedByPullRequestsReferences: {
93+
totalCount: 1,
94+
},
95+
},
96+
},
97+
});
98+
99+
// Act
100+
const result = await hasLinkedPrOrExcusableLabel({
101+
github,
102+
context: contextCopy,
103+
});
104+
105+
// Assert
106+
expect(result).toBe(true);
107+
expect(github.graphql).toHaveBeenCalledWith(
108+
expect.stringContaining('query'),
109+
{
110+
owner: context.repo.owner,
111+
repo: context.repo.repo,
112+
issue: context.payload.issue.number,
113+
}
114+
);
115+
});
116+
117+
test(
118+
'If there is no linked PR nor any of the excusable labels, ' +
119+
'then return false.',
120+
async () => {
121+
// Arrange
122+
github.graphql.mockResolvedValue({
123+
repository: {
124+
issue: {
125+
closedByPullRequestsReferences: {
126+
totalCount: 0,
127+
},
128+
},
129+
},
130+
});
131+
132+
// Act
133+
const result = await hasLinkedPrOrExcusableLabel({
134+
github,
135+
context: contextCopy,
136+
});
137+
138+
// Assert
139+
expect(result).toBe(false);
140+
expect(github.graphql).toHaveBeenCalledWith(
141+
expect.stringContaining('query'),
142+
{
143+
owner: context.repo.owner,
144+
repo: context.repo.repo,
145+
issue: context.payload.issue.number,
146+
}
147+
);
148+
}
149+
);
150+
});
151+
152+
// ==================================================
153+
154+
/**
155+
* Helper function taken from MDN. Freezes nested Objects.
156+
*
157+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze#deep_freezing
158+
*
159+
* @param {*} object - Any JavaScript Object.
160+
* @returns Passed-in Object.
161+
*/
162+
function deepFreeze(object) {
163+
// Retrieve the property names defined on object
164+
const propNames = Reflect.ownKeys(object);
165+
166+
// Freeze properties before freezing self
167+
for (const name of propNames) {
168+
const value = object[name];
169+
170+
if ((value && typeof value === 'object') || typeof value === 'function') {
171+
deepFreeze(value);
172+
}
173+
}
174+
175+
return Object.freeze(object);
176+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const queryIssueInfo = require('../../utils/query-issue-info');
2+
const mutateIssueStatus = require('../../utils/mutate-issue-status');
3+
const postComment = require('../../utils/post-issue-comment');
4+
5+
const statusFieldIds = require('../../utils/_data/status-field-ids');
6+
const labelDirectory = require('../../utils/_data/label-directory.json');
7+
8+
// ==================================================
9+
10+
/**
11+
* Reopens an issue that does not have a linked PR or excusable labels. Adds a
12+
* "ready for product" label, sets the project status to Questions / In Review",
13+
* and posts a comment to the issue.
14+
*
15+
* @param {{github: object, context: object}} actionsGithubScriptArgs -
16+
* GitHub objects from actions/github-script
17+
*/
18+
async function reopenIssue({ github, context }) {
19+
const repoOwner = context.repo.owner;
20+
const repoName = context.repo.repo;
21+
const issueNumber = context.payload.issue.number;
22+
23+
const labelsToAdd = [labelDirectory.readyForPM[0]];
24+
25+
const newStatusFieldId = statusFieldIds('Questions_In_Review');
26+
27+
const comment =
28+
'This issue was reopened because ' +
29+
`it did not have any of the following:
30+
- A linked PR,
31+
- An \`Ignore\` label
32+
- A \`non-PR contribution\` label`;
33+
34+
// --------------------------------------------------
35+
36+
// Add the "ready for product" label.
37+
try {
38+
await github.rest.issues.addLabels({
39+
owner: repoOwner,
40+
repo: repoName,
41+
issue_number: issueNumber,
42+
labels: labelsToAdd,
43+
});
44+
} catch (err) {
45+
throw new Error(
46+
`Unable to add "ready for product" label to issue #${issueNumber}; ` +
47+
`error = ${err}`
48+
);
49+
}
50+
console.info(`Added "ready for product" label to issue #${issueNumber}.`);
51+
52+
// Change the project status of the issue to "Questions / In Review".
53+
const issueInfo = await queryIssueInfo(github, context, issueNumber);
54+
await mutateIssueStatus(github, context, issueInfo.id, newStatusFieldId);
55+
console.info(
56+
`Changed project status to ` +
57+
`"Questions / In Review" in issue #${issueNumber}.`
58+
);
59+
60+
// Post comment to the issue.
61+
await postComment(issueNumber, comment, github, context);
62+
console.info(`Posted comment to issue #${issueNumber}.`);
63+
64+
// Re-opening the issue.
65+
try {
66+
await github.rest.issues.update({
67+
owner: repoOwner,
68+
repo: repoName,
69+
issue_number: issueNumber,
70+
state: 'open',
71+
});
72+
} catch (err) {
73+
throw new Error(`Unable to reopen issue #${issueNumber}; error = ${err}`);
74+
}
75+
console.info(`Reopened issue #${issueNumber}.`);
76+
}
77+
78+
// ==================================================
79+
80+
module.exports = reopenIssue;

0 commit comments

Comments
 (0)