diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 804a200..b9d275f 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -3,10 +3,11 @@ on: deployment_status: jobs: - preview: + e2eTests: if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' + name: Perform 2e2 tests runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/empty-issues-closer.yaml b/.github/workflows/empty-issues-closer.yaml index 5f050fe..b20eef3 100644 --- a/.github/workflows/empty-issues-closer.yaml +++ b/.github/workflows/empty-issues-closer.yaml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 # NOTE: Retrieve issue templates. + - name: Run empty issues closer action uses: rickstaa/empty-issues-closer-action@v1 env: diff --git a/.github/workflows/generate-theme-doc.yml b/.github/workflows/generate-theme-doc.yml index ef076f3..d5fac06 100644 --- a/.github/workflows/generate-theme-doc.yml +++ b/.github/workflows/generate-theme-doc.yml @@ -1,5 +1,4 @@ name: Generate Theme Readme - on: push: branches: @@ -8,8 +7,9 @@ on: - "themes/index.js" jobs: - build: + generateThemeDoc: runs-on: ubuntu-latest + name: Generate theme doc strategy: matrix: node-version: [16.x] diff --git a/.github/workflows/preview-theme.yml b/.github/workflows/preview-theme.yml index 5c5cf9a..132d4eb 100644 --- a/.github/workflows/preview-theme.yml +++ b/.github/workflows/preview-theme.yml @@ -1,5 +1,4 @@ name: Theme preview - on: pull_request_target: types: [opened, edited, reopened, synchronize] @@ -10,11 +9,11 @@ on: jobs: previewTheme: + name: Install & Preview runs-on: ubuntu-latest strategy: matrix: node-version: [16.x] - name: Install & Preview steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/stale-theme-pr-closer.yaml b/.github/workflows/stale-theme-pr-closer.yaml new file mode 100644 index 0000000..8b1c78b --- /dev/null +++ b/.github/workflows/stale-theme-pr-closer.yaml @@ -0,0 +1,30 @@ +name: Close stale theme pull requests that have the 'invalid' label. +on: + schedule: + - cron: "0 0 */7 * *" + +jobs: + closeOldThemePrs: + name: Close stale 'invalid' theme PRs + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.x] + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - run: npm run close-stale-theme-prs + env: + STALE_DAYS: 15 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c051f96..fe34668 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,4 @@ name: Test - on: push: branches: @@ -10,6 +9,7 @@ on: jobs: build: + name: Perform tests runs-on: ubuntu-latest strategy: matrix: diff --git a/package.json b/package.json index b1bce12..ffadefd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.e2e.config.js", "theme-readme-gen": "node scripts/generate-theme-doc", "preview-theme": "node scripts/preview-theme", + "close-stale-theme-prs": "node scripts/close-stale-theme-prs", "generate-langs-json": "node scripts/generate-langs-json", "format": "./node_modules/.bin/prettier --write .", "format:check": "./node_modules/.bin/prettier --check ." diff --git a/scripts/close-stale-theme-prs.js b/scripts/close-stale-theme-prs.js new file mode 100644 index 0000000..69e5e19 --- /dev/null +++ b/scripts/close-stale-theme-prs.js @@ -0,0 +1,161 @@ +/** + * @file Script that can be used to close stale theme PRs that have a `invalid` label. + */ +import * as dotenv from "dotenv"; +dotenv.config(); + +import { debug, setFailed } from "@actions/core"; +import github from "@actions/github"; +import { RequestError } from "@octokit/request-error"; +import { getGithubToken, getRepoInfo } from "./helpers.js"; + +// Script parameters +const CLOSING_COMMENT = ` + \rThis PR has been automatically closed due to inactivity. Please feel free to reopen it if you need to continue working on it.\ + \rThank you for your contributions. +`; + +/** + * Fetch open PRs from a given repository. + * @param user The user name of the repository owner. + * @param repo The name of the repository. + * @returns The open PRs. + */ +export const fetchOpenPRs = async (octokit, user, repo) => { + const openPRs = []; + let hasNextPage = true; + let endCursor; + while (hasNextPage) { + try { + const { repository } = await octokit.graphql( + ` + { + repository(owner: "${user}", name: "${repo}") { + open_prs: pullRequests(${ + endCursor ? `after: "${endCursor}", ` : "" + } + first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + number + commits(last:1){ + nodes{ + commit{ + pushedDate + } + } + } + labels(first: 100, orderBy:{field: CREATED_AT, direction: DESC}) { + nodes { + name + } + } + reviews(first: 1, states: CHANGES_REQUESTED, author: "github-actions[bot]") { + nodes { + updatedAt + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + `, + ); + openPRs.push(...repository.open_prs.nodes); + hasNextPage = repository.open_prs.pageInfo.hasNextPage; + endCursor = repository.open_prs.pageInfo.endCursor; + } catch (error) { + if (error instanceof RequestError) { + setFailed(`Could not retrieve top PRs using GraphQl: ${error.message}`); + } + throw error; + } + } + return openPRs; +}; + +/** + * Retrieve pull requests that have a given label. + * @param pull The pull requests to check. + * @param label The label to check for. + */ +export const pullsWithLabel = (pulls, label) => { + return pulls.filter((pr) => { + return pr.labels.nodes.some((lab) => lab.name === label); + }); +}; + +/** + * Check if PR is stale. Meaning that it hasn't been updated in a given time. + * @param {Object} pullRequest request object. + * @param {number} days number of days. + * @returns Boolean indicating if PR is stale. + */ +const isStale = (pullRequest, staleDays) => { + const lastCommitDate = new Date( + pullRequest.commits.nodes[0].commit.pushedDate, + ); + if (pullRequest.reviews.nodes[0]) { + const lastReviewDate = new Date(pullRequest.reviews.nodes[0].updatedAt); + const lastUpdateDate = + lastCommitDate >= lastReviewDate ? lastCommitDate : lastReviewDate; + const now = new Date(); + return now - lastUpdateDate > 1000 * 60 * 60 * 24 * staleDays; + } else { + return false; + } +}; + +/** + * Main function. + */ +const run = async () => { + try { + // Create octokit client. + const dryRun = process.env.DRY_RUN === "true" || false; + const staleDays = process.env.STALE_DAYS || 15; + debug("Creating octokit client..."); + const octokit = github.getOctokit(getGithubToken()); + const { owner, repo } = getRepoInfo(github.context); + + // Retrieve all theme pull requests. + debug("Retrieving all theme pull requests..."); + const prs = await fetchOpenPRs(octokit, owner, repo); + const themePRs = pullsWithLabel(prs, "themes"); + const invalidThemePRs = pullsWithLabel(themePRs, "invalid"); + debug("Retrieving stale themePRs..."); + const staleThemePRs = invalidThemePRs.filter((pr) => + isStale(pr, staleDays), + ); + const staleThemePRsNumbers = staleThemePRs.map((pr) => pr.number); + debug(`Found ${staleThemePRs.length} stale theme PRs`); + + // Loop through all stale invalid theme pull requests and close them. + for (const prNumber of staleThemePRsNumbers) { + debug(`Closing #${prNumber} because it is stale...`); + if (!dryRun) { + await octokit.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: CLOSING_COMMENT, + }); + await octokit.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: "closed", + }); + } else { + debug("Dry run enabled, skipping..."); + } + } + } catch (error) { + setFailed(error.message); + } +}; + +run(); diff --git a/scripts/helpers.js b/scripts/helpers.js new file mode 100644 index 0000000..fabf300 --- /dev/null +++ b/scripts/helpers.js @@ -0,0 +1,40 @@ +/** + * @file Contains helper functions used in the scripts. + */ + +// Script variables. +const OWNER = "anuraghazra"; +const REPO = "github-readme-stats"; + +/** + * Retrieve information about the repository that ran the action. + * + * @param {Object} context Action context. + * @returns {Object} Repository information. + */ +export const getRepoInfo = (ctx) => { + try { + return { + owner: ctx.repo.owner, + repo: ctx.repo.repo, + }; + } catch (error) { + return { + owner: OWNER, + repo: REPO, + }; + } +}; + +/** + * Retrieve github token and throw error if it is not found. + * + * @returns {string} Github token. + */ +export const getGithubToken = () => { + const token = core.getInput("github_token") || process.env.GITHUB_TOKEN; + if (!token) { + throw Error("Could not find github token"); + } + return token; +}; diff --git a/scripts/preview-theme.js b/scripts/preview-theme.js index c6312b4..94145fb 100644 --- a/scripts/preview-theme.js +++ b/scripts/preview-theme.js @@ -4,7 +4,7 @@ import * as dotenv from "dotenv"; dotenv.config(); -import core, { debug, setFailed } from "@actions/core"; +import { debug, setFailed } from "@actions/core"; import github from "@actions/github"; import ColorContrastChecker from "color-contrast-checker"; import { info } from "console"; @@ -14,10 +14,9 @@ import parse from "parse-diff"; import { inspect } from "util"; import { isValidHexColor } from "../src/common/utils.js"; import { themes } from "../themes/index.js"; +import { getGithubToken, getRepoInfo } from "./helpers.js"; -// Script variables -const OWNER = "anuraghazra"; -const REPO = "github-readme-stats"; +// Script variables. const COMMENTER = "github-actions[bot]"; const COMMENT_TITLE = "Automated Theme Preview"; @@ -43,26 +42,6 @@ const REQUIRED_COLOR_PROPS = [ const INVALID_REVIEW_COMMENT = (commentUrl) => `Some themes are invalid. See the [Automated Theme Preview](${commentUrl}) comment above for more information.`; -/** - * Retrieve information about the repository that ran the action. - * - * @param {Object} context Action context. - * @returns {Object} Repository information. - */ -export const getRepoInfo = (ctx) => { - try { - return { - owner: ctx.repo.owner, - repo: ctx.repo.repo, - }; - } catch (error) { - return { - owner: OWNER, - repo: REPO, - }; - } -}; - /** * Retrieve PR number from the event payload. * @@ -86,19 +65,6 @@ const getCommenter = () => { return process.env.COMMENTER ? process.env.COMMENTER : COMMENTER; }; -/** - * Retrieve github token and throw error if it is not found. - * - * @returns {string} Github token. - */ -const getGithubToken = () => { - const token = core.getInput("github_token") || process.env.GITHUB_TOKEN; - if (!token) { - throw Error("Could not find github token"); - } - return token; -}; - /** * Returns whether the comment is a preview comment. *