Skip to content

Commit b8faef6

Browse files
authored
ci: add stale theme pull request closer action push (anuraghazra#2067)
This commit adds the `stale-theme-pr-closer` closer action. This action is used to close theme pull requests with an `invalid` label that has been stale for a given number of `STALE_DAYS`.
1 parent 986070a commit b8faef6

10 files changed

Lines changed: 242 additions & 43 deletions

.github/workflows/e2e-test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ on:
33
deployment_status:
44

55
jobs:
6-
preview:
6+
e2eTests:
77
if:
88
github.event_name == 'deployment_status' &&
99
github.event.deployment_status.state == 'success'
10+
name: Perform 2e2 tests
1011
runs-on: ubuntu-latest
1112
strategy:
1213
matrix:

.github/workflows/empty-issues-closer.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313
steps:
1414
- uses: actions/checkout@v3 # NOTE: Retrieve issue templates.
15+
1516
- name: Run empty issues closer action
1617
uses: rickstaa/empty-issues-closer-action@v1
1718
env:

.github/workflows/generate-theme-doc.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
name: Generate Theme Readme
2-
32
on:
43
push:
54
branches:
@@ -8,8 +7,9 @@ on:
87
- "themes/index.js"
98

109
jobs:
11-
build:
10+
generateThemeDoc:
1211
runs-on: ubuntu-latest
12+
name: Generate theme doc
1313
strategy:
1414
matrix:
1515
node-version: [16.x]

.github/workflows/preview-theme.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
name: Theme preview
2-
32
on:
43
pull_request_target:
54
types: [opened, edited, reopened, synchronize]
@@ -10,11 +9,11 @@ on:
109

1110
jobs:
1211
previewTheme:
12+
name: Install & Preview
1313
runs-on: ubuntu-latest
1414
strategy:
1515
matrix:
1616
node-version: [16.x]
17-
name: Install & Preview
1817

1918
steps:
2019
- uses: actions/checkout@v3
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Close stale theme pull requests that have the 'invalid' label.
2+
on:
3+
schedule:
4+
- cron: "0 0 */7 * *"
5+
6+
jobs:
7+
closeOldThemePrs:
8+
name: Close stale 'invalid' theme PRs
9+
runs-on: ubuntu-latest
10+
strategy:
11+
matrix:
12+
node-version: [16.x]
13+
14+
steps:
15+
- uses: actions/checkout@v3
16+
17+
- name: Setup Node
18+
uses: actions/setup-node@v3
19+
with:
20+
node-version: ${{ matrix.node-version }}
21+
cache: npm
22+
23+
- uses: bahmutov/npm-install@v1
24+
with:
25+
useLockFile: false
26+
27+
- run: npm run close-stale-theme-prs
28+
env:
29+
STALE_DAYS: 15
30+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
name: Test
2-
32
on:
43
push:
54
branches:
@@ -10,6 +9,7 @@ on:
109

1110
jobs:
1211
build:
12+
name: Perform tests
1313
runs-on: ubuntu-latest
1414
strategy:
1515
matrix:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js",
1212
"theme-readme-gen": "node scripts/generate-theme-doc",
1313
"preview-theme": "node scripts/preview-theme",
14+
"close-stale-theme-prs": "node scripts/close-stale-theme-prs",
1415
"generate-langs-json": "node scripts/generate-langs-json",
1516
"format": "./node_modules/.bin/prettier --write .",
1617
"format:check": "./node_modules/.bin/prettier --check ."

scripts/close-stale-theme-prs.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* @file Script that can be used to close stale theme PRs that have a `invalid` label.
3+
*/
4+
import * as dotenv from "dotenv";
5+
dotenv.config();
6+
7+
import { debug, setFailed } from "@actions/core";
8+
import github from "@actions/github";
9+
import { RequestError } from "@octokit/request-error";
10+
import { getGithubToken, getRepoInfo } from "./helpers.js";
11+
12+
// Script parameters
13+
const CLOSING_COMMENT = `
14+
\rThis PR has been automatically closed due to inactivity. Please feel free to reopen it if you need to continue working on it.\
15+
\rThank you for your contributions.
16+
`;
17+
18+
/**
19+
* Fetch open PRs from a given repository.
20+
* @param user The user name of the repository owner.
21+
* @param repo The name of the repository.
22+
* @returns The open PRs.
23+
*/
24+
export const fetchOpenPRs = async (octokit, user, repo) => {
25+
const openPRs = [];
26+
let hasNextPage = true;
27+
let endCursor;
28+
while (hasNextPage) {
29+
try {
30+
const { repository } = await octokit.graphql(
31+
`
32+
{
33+
repository(owner: "${user}", name: "${repo}") {
34+
open_prs: pullRequests(${
35+
endCursor ? `after: "${endCursor}", ` : ""
36+
}
37+
first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) {
38+
nodes {
39+
number
40+
commits(last:1){
41+
nodes{
42+
commit{
43+
pushedDate
44+
}
45+
}
46+
}
47+
labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) {
48+
nodes {
49+
name
50+
}
51+
}
52+
reviews(first: 1, states: CHANGES_REQUESTED, author: "github-actions[bot]") {
53+
nodes {
54+
updatedAt
55+
}
56+
}
57+
}
58+
pageInfo {
59+
endCursor
60+
hasNextPage
61+
}
62+
}
63+
}
64+
}
65+
`,
66+
);
67+
openPRs.push(...repository.open_prs.nodes);
68+
hasNextPage = repository.open_prs.pageInfo.hasNextPage;
69+
endCursor = repository.open_prs.pageInfo.endCursor;
70+
} catch (error) {
71+
if (error instanceof RequestError) {
72+
setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`);
73+
}
74+
throw error;
75+
}
76+
}
77+
return openPRs;
78+
};
79+
80+
/**
81+
* Retrieve pull requests that have a given label.
82+
* @param pull The pull requests to check.
83+
* @param label The label to check for.
84+
*/
85+
export const pullsWithLabel = (pulls, label) => {
86+
return pulls.filter((pr) => {
87+
return pr.labels.nodes.some((lab) => lab.name === label);
88+
});
89+
};
90+
91+
/**
92+
* Check if PR is stale. Meaning that it hasn't been updated in a given time.
93+
* @param {Object} pullRequest request object.
94+
* @param {number} days number of days.
95+
* @returns Boolean indicating if PR is stale.
96+
*/
97+
const isStale = (pullRequest, staleDays) => {
98+
const lastCommitDate = new Date(
99+
pullRequest.commits.nodes[0].commit.pushedDate,
100+
);
101+
if (pullRequest.reviews.nodes[0]) {
102+
const lastReviewDate = new Date(pullRequest.reviews.nodes[0].updatedAt);
103+
const lastUpdateDate =
104+
lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate;
105+
const now = new Date();
106+
return now - lastUpdateDate > 1000 * 60 * 60 * 24 * staleDays;
107+
} else {
108+
return false;
109+
}
110+
};
111+
112+
/**
113+
* Main function.
114+
*/
115+
const run = async () => {
116+
try {
117+
// Create octokit client.
118+
const dryRun = process.env.DRY_RUN === "true" || false;
119+
const staleDays = process.env.STALE_DAYS || 15;
120+
debug("Creating octokit client...");
121+
const octokit = github.getOctokit(getGithubToken());
122+
const { owner, repo } = getRepoInfo(github.context);
123+
124+
// Retrieve all theme pull requests.
125+
debug("Retrieving all theme pull requests...");
126+
const prs = await fetchOpenPRs(octokit, owner, repo);
127+
const themePRs = pullsWithLabel(prs, "themes");
128+
const invalidThemePRs = pullsWithLabel(themePRs, "invalid");
129+
debug("Retrieving stale themePRs...");
130+
const staleThemePRs = invalidThemePRs.filter((pr) =>
131+
isStale(pr, staleDays),
132+
);
133+
const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number);
134+
debug(`Found ${staleThemePRs.length} stale theme PRs`);
135+
136+
// Loop through all stale invalid theme pull requests and close them.
137+
for (const prNumber of staleThemePRsNumbers) {
138+
debug(`Closing #${prNumber} because it is stale...`);
139+
if (!dryRun) {
140+
await octokit.issues.createComment({
141+
owner,
142+
repo,
143+
issue_number: prNumber,
144+
body: CLOSING_COMMENT,
145+
});
146+
await octokit.pulls.update({
147+
owner,
148+
repo,
149+
pull_number: prNumber,
150+
state: "closed",
151+
});
152+
} else {
153+
debug("Dry run enabled, skipping...");
154+
}
155+
}
156+
} catch (error) {
157+
setFailed(error.message);
158+
}
159+
};
160+
161+
run();

scripts/helpers.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @file Contains helper functions used in the scripts.
3+
*/
4+
5+
// Script variables.
6+
const OWNER = "anuraghazra";
7+
const REPO = "github-readme-stats";
8+
9+
/**
10+
* Retrieve information about the repository that ran the action.
11+
*
12+
* @param {Object} context Action context.
13+
* @returns {Object} Repository information.
14+
*/
15+
export const getRepoInfo = (ctx) => {
16+
try {
17+
return {
18+
owner: ctx.repo.owner,
19+
repo: ctx.repo.repo,
20+
};
21+
} catch (error) {
22+
return {
23+
owner: OWNER,
24+
repo: REPO,
25+
};
26+
}
27+
};
28+
29+
/**
30+
* Retrieve github token and throw error if it is not found.
31+
*
32+
* @returns {string} Github token.
33+
*/
34+
export const getGithubToken = () => {
35+
const token = core.getInput("github_token") || process.env.GITHUB_TOKEN;
36+
if (!token) {
37+
throw Error("Could not find github token");
38+
}
39+
return token;
40+
};

scripts/preview-theme.js

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import * as dotenv from "dotenv";
55
dotenv.config();
66

7-
import core, { debug, setFailed } from "@actions/core";
7+
import { debug, setFailed } from "@actions/core";
88
import github from "@actions/github";
99
import ColorContrastChecker from "color-contrast-checker";
1010
import { info } from "console";
@@ -14,10 +14,9 @@ import parse from "parse-diff";
1414
import { inspect } from "util";
1515
import { isValidHexColor } from "../src/common/utils.js";
1616
import { themes } from "../themes/index.js";
17+
import { getGithubToken, getRepoInfo } from "./helpers.js";
1718

18-
// Script variables
19-
const OWNER = "anuraghazra";
20-
const REPO = "github-readme-stats";
19+
// Script variables.
2120
const COMMENTER = "github-actions[bot]";
2221

2322
const COMMENT_TITLE = "Automated Theme Preview";
@@ -43,26 +42,6 @@ const REQUIRED_COLOR_PROPS = [
4342
const INVALID_REVIEW_COMMENT = (commentUrl) =>
4443
`Some themes are invalid. See the [Automated Theme Preview](${commentUrl}) comment above for more information.`;
4544

46-
/**
47-
* Retrieve information about the repository that ran the action.
48-
*
49-
* @param {Object} context Action context.
50-
* @returns {Object} Repository information.
51-
*/
52-
export const getRepoInfo = (ctx) => {
53-
try {
54-
return {
55-
owner: ctx.repo.owner,
56-
repo: ctx.repo.repo,
57-
};
58-
} catch (error) {
59-
return {
60-
owner: OWNER,
61-
repo: REPO,
62-
};
63-
}
64-
};
65-
6645
/**
6746
* Retrieve PR number from the event payload.
6847
*
@@ -86,19 +65,6 @@ const getCommenter = () => {
8665
return process.env.COMMENTER ? process.env.COMMENTER : COMMENTER;
8766
};
8867

89-
/**
90-
* Retrieve github token and throw error if it is not found.
91-
*
92-
* @returns {string} Github token.
93-
*/
94-
const getGithubToken = () => {
95-
const token = core.getInput("github_token") || process.env.GITHUB_TOKEN;
96-
if (!token) {
97-
throw Error("Could not find github token");
98-
}
99-
return token;
100-
};
101-
10268
/**
10369
* Returns whether the comment is a preview comment.
10470
*

0 commit comments

Comments
 (0)