diff --git a/.github/workflows/cleaner.js b/.github/workflows/cleaner.js new file mode 100644 index 0000000000..8fc6438fad --- /dev/null +++ b/.github/workflows/cleaner.js @@ -0,0 +1,160 @@ +'use strict'; + +// eslint-disable-next-line node/no-extraneous-require +const yaml = require('js-yaml'); +const fs = require('fs'); +const { join, basename } = require('path'); + +function load(type) { + const base = `source/_data/${type}`; + const items = fs.readdirSync(base); + return items.map(item => { + const file = `${base}/${item}`; + const content = yaml.load(fs.readFileSync(file)); + return { + file, + content + }; + }); +} + +// Set your GitHub Personal Access Token +const headers = { + Authorization: 'Bearer ' + process.env.GITHUB_TOKEN +}; + +// Define the GraphQL query template +const queryTemplate = ` +query { + {repos} +} +`; + +// Function to get a sanitized key supported by GraphQL +function getKey(owner, repo) { + return (owner + repo).replace(/[^a-zA-Z0-9]/g, '').replace(/^\d+/, ''); +} + +// Function to build a query string for a repository +function buildRepoQuery(owner, repo) { + return ` + ${getKey(owner, repo)}: repository(owner: "${owner}", name: "${repo}") { + name + stargazers { + totalCount + } + owner { + login + } + url + isArchived + defaultBranchRef { + target { + ... on Commit { + history(first: 1) { + edges { + node { + committedDate + } + } + } + } + } + } + } + `; +} + +// Function to read GitHub repositories list and make the GraphQL query +async function queryRepos(reposList, batchSize = 100) { + let result = {}; + try { + for (let i = 0; i < reposList.length; i += batchSize) { + // Construct the query for a batch of repositories + const reposQuery = reposList.slice(i, i + batchSize).map(({ owner, repo }) => buildRepoQuery(owner, repo)).join(' '); + const query = queryTemplate.replace('{repos}', reposQuery); + + // Send the GraphQL query + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': headers.Authorization + }, + body: JSON.stringify({ query: query }) + }); + + if (!response.ok) { + throw new Error(`Query failed with status code ${response.status}: ${response.statusText}`); + } + + const json = await response.json(); + const { data, errors } = json; + if (errors) { + console.log(`Query failed with error: ${JSON.stringify(errors)}`); + } + result = { ...result, ...data }; + } + } catch (error) { + console.error('Error:', error); + } + return result; +} + +async function validate(type) { + const list = load(type); + const repos = []; + list.forEach(({ file, content }) => { + const { link } = content; + if (!link.startsWith('https://github.com/')) { + console.log('Skip', link); + } else { + // Extract owner and repo from github url + const parts = link.split('/'); + repos.push({ + owner: parts[3], + repo: parts[4].replace(/\.git$/, '') + }); + } + }); + const result = await queryRepos(repos); + list.forEach(({ file, content }) => { + const { link } = content; + if (!link.startsWith('https://github.com/')) { + console.log('Skip', link); + } else { + // Extract owner and repo from github url + const parts = link.split('/'); + const owner = parts[3]; + const repo = parts[4].replace(/\.git$/, ''); + const repoKey = getKey(owner, repo); + if (result[repoKey]) { + const entry = result[repoKey]; + const stars = entry.stargazers.totalCount; + const isArchived = entry.isArchived; + const lastCommitDate = entry.defaultBranchRef.target.history.edges[0].node.committedDate; + console.log(`Repo: ${owner}/${repo}, Stars: ${stars}, Archived: ${isArchived}, Last Commit Date: ${lastCommitDate}`); + const newOwner = entry.owner.login; + const newRepo = entry.name; + if (owner !== newOwner || repo !== newRepo) { + console.log(`Repo: ${owner}/${repo} has been renamed to ${newOwner}/${newRepo}`); + content.link = `https://github.com/${newOwner}/${newRepo}`; + fs.writeFileSync(file, yaml.dump(content)); + } + } else { + console.log(`Repo: ${owner}/${repo} does not exist or is private.`); + console.log(`Remove: ${file}`); + fs.unlinkSync(file); + if (type === 'themes') { + const screenshotsPath = 'source/themes/screenshots'; + const screenshot = join(screenshotsPath, basename(file).replace('yml', 'png')); + console.log(`Remove: ${screenshot}`); + fs.unlinkSync(screenshot); + } + } + } + }); +} + +validate('themes'); +validate('plugins'); diff --git a/.github/workflows/cleaner.yml b/.github/workflows/cleaner.yml new file mode 100644 index 0000000000..e846a6d523 --- /dev/null +++ b/.github/workflows/cleaner.yml @@ -0,0 +1,63 @@ +name: Cleaner + +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true +permissions: + contents: write + pull-requests: write +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + cache: "npm" + cache-dependency-path: "package.json" + - name: Install Dependencies + run: npm install + - name: Cleanup + run: node .github/workflows/cleaner.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Config + run: | + git config --global user.name 'Hexo' + git config --global user.email 'hexojs@users.noreply.github.com' + - name: Commit + run: | + git checkout -b cleanup + git add . + git commit -m 'chore: clean up themes & plugins' + - name: Push + run: | + git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} + git push origin cleanup -f + - name: Check for existing PR + id: check_pr + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const headBranch = `${owner}:cleanup`; + const { data: pullRequests } = await github.rest.pulls.list({ + owner: owner, + repo: context.repo.repo, + state: 'open', + head: headBranch, + base: 'master' + }); + return pullRequests.length > 0; + result-encoding: string + - name: Open PR + if: steps.check_pr.outputs.result == 'false' + run: | + gh pr create -B master -H cleanup --title 'chore: clean up themes & plugins' --body 'Created by Github Actions' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}