├── .eslintignore ├── .eslintrc.yml ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── main.yml │ └── update-project-json.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.mjs ├── README.md ├── __tests__ ├── parse.spec.ts └── projects.spec.ts ├── cspell.json ├── jest.config.js ├── package-lock.json ├── package.json ├── projects.json ├── scripts └── update-projects-json.mjs ├── src ├── artifact.ts ├── configuration.ts ├── execute │ ├── get-pretty-head-commit-hash.ts │ ├── index.ts │ ├── prepare-prettier-ignore-file.ts │ ├── run-prettier.ts │ └── setup-repository.ts ├── get-issue-comment.ts ├── index.ts ├── log-text.ts ├── logger.ts ├── octokit.ts ├── parse.ts ├── projects.ts └── tools │ ├── brew.ts │ ├── gh.ts │ ├── git.ts │ ├── unix.ts │ └── yarn.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /prettier 2 | /repos/** 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es2020: true 3 | node: true 4 | parser: "@typescript-eslint/parser" 5 | extends: 6 | - eslint:recommended 7 | - plugin:@typescript-eslint/recommended 8 | - prettier 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: sunday 8 | time: "01:00" 9 | open-pull-requests-limit: 20 10 | 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: weekly 15 | day: sunday 16 | time: "01:00" 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | - run: npm ci 17 | - run: npm run lint 18 | 19 | test: 20 | name: Test 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | - run: npm ci 26 | - run: npm run test 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | issue_comment: 3 | types: [created] 4 | jobs: 5 | regression_testing_job: 6 | if: ${{ startsWith(github.event.comment.body, 'run') }} 7 | runs-on: ubuntu-latest 8 | name: a job for regression testing 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | submodules: true 14 | - uses: actions/setup-node@v4 15 | - name: configure git 16 | run: | 17 | git config --global user.email "actions@github.com" 18 | git config --global user.name "GitHub Actions" 19 | - run: npm ci 20 | - run: npm run build 21 | - name: main action 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | run: node build/index.js 25 | -------------------------------------------------------------------------------- /.github/workflows/update-project-json.yml: -------------------------------------------------------------------------------- 1 | name: "Update projects.json" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "00 01 1 * *" 7 | 8 | jobs: 9 | update-project-json: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Configure git user 16 | run: | 17 | git config user.name github-actions 18 | git config user.email github-actions@github.com 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Run update-projects-json.mjs 27 | run: node ./scripts/update-projects-json.mjs 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | /errors 4 | /.vscode 5 | .DS_Store 6 | .idea 7 | .yarn/* 8 | !.yarn/releases 9 | !.yarn/plugins 10 | !.yarn/sdks 11 | !.yarn/versions 12 | .pnp.* 13 | /build 14 | log.txt 15 | /repos 16 | /prettier 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /prettier 2 | /repos/** 3 | /build 4 | /cspell.json 5 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prettier-regression-testing 2 | 3 | ## Motivation 4 | 5 | https://github.com/prettier/prettier/issues/9290 6 | 7 | We run Prettier on other projects prior to release to check for regressions. (See [Release Checklist](https://github.com/prettier/prettier/wiki/Release-Checklist)) 8 | 9 | We used to do that manually. 10 | 11 | - https://github.com/sosukesuzuki/eslint-plugin-vue/pull/1 12 | - https://github.com/sosukesuzuki/typescript-eslint/pull/1 13 | 14 | This is a lot of work, so we use GitHub Actions to automate it. 15 | 16 | ## Usage 17 | 18 | Create an issue comment like the one below: 19 | 20 | ``` 21 | run alternativeVersionPrettier vs originalVersionPrettier 22 | ``` 23 | 24 | ### `alternativeVersionPrettier` 25 | 26 | Required. 27 | 28 | There are 3 ways to specify. 29 | 30 | 1. Versions (e.g. `2.0.0`, `1.7.1`) 31 | 2. Repository name + ref (e.g. `sosukesuzuki/prettier#2f3fc241f8cb1867a0a3073ceac9d662e4b25fab`). 32 | 3. Pull Request number on [prettier/prettier](https://github.com/prettier/prettier) repository (e.g. `#110168`). 33 | 34 | ### `originalVersionPrettier` 35 | 36 | Optional. 37 | 38 | In default, use `prettier/prettier#main`. 39 | 40 | Also, you can specify with the logic same as `alternativeVersionPrettier`. 41 | 42 | ## Examples 43 | 44 | ``` 45 | run #110168 46 | ``` 47 | 48 | ``` 49 | run #110168 vs sosukesuzuki/prettier#main 50 | ``` 51 | 52 | ``` 53 | run sosukesuzuki/prettier#main vs 1.8.1 54 | ``` 55 | 56 | ## Add new project 57 | 58 | Add a project info to `projects.json`. 59 | -------------------------------------------------------------------------------- /__tests__/parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { tokenize, parse, parseRepositorySource } from "../src/parse"; 2 | 3 | describe("parse", () => { 4 | describe("parse", () => { 5 | it("returns command obj represents prNumber vs repositoryAndRef", () => { 6 | const command = parse("run #2345 vs sosukesuzuki/prettier#ref"); 7 | expect(command).toEqual({ 8 | alternativePrettier: { 9 | type: "prNumber", 10 | prNumber: "2345", 11 | }, 12 | originalPrettier: { 13 | type: "repositoryAndRef", 14 | ref: "ref", 15 | repositoryName: "sosukesuzuki/prettier", 16 | }, 17 | }); 18 | }); 19 | it("returns command obj represents version vs repositoryAndRef", () => { 20 | const command = parse("run 2.1.2 vs sosukesuzuki/prettier#ref"); 21 | expect(command).toEqual({ 22 | alternativePrettier: { 23 | type: "version", 24 | version: "2.1.2", 25 | }, 26 | originalPrettier: { 27 | type: "repositoryAndRef", 28 | ref: "ref", 29 | repositoryName: "sosukesuzuki/prettier", 30 | }, 31 | }); 32 | }); 33 | it("returns command obj represents version vs default", () => { 34 | const command = parse("run 2.1.2"); 35 | expect(command).toEqual({ 36 | alternativePrettier: { 37 | type: "version", 38 | version: "2.1.2", 39 | }, 40 | originalPrettier: { 41 | type: "repositoryAndRef", 42 | ref: "main", 43 | repositoryName: "prettier/prettier", 44 | }, 45 | }); 46 | }); 47 | it("throws syntax error for non-first run", () => { 48 | expect(() => parse("2.0.0 run")).toThrow( 49 | "A command must start with 'run'.", 50 | ); 51 | }); 52 | it("throws syntax error for 'run' that has no source", () => { 53 | expect(() => parse("run on")).toThrow( 54 | "A prettier repository source must be specified for 'run'.", 55 | ); 56 | }); 57 | it("throws syntax error for 'vs' that has no after source", () => { 58 | expect(() => parse("run 2.0.0 vs on")).toThrow( 59 | "A prettier repository source must be specified for 'vs'.", 60 | ); 61 | }); 62 | it("throw syntax error for unsupported 'on'", () => { 63 | expect(() => parse("run 2.0.0 on")).toThrow( 64 | "We haven't supported 'on' yet.", 65 | ); 66 | }); 67 | }); 68 | 69 | describe("parseRepositorySource", () => { 70 | it("returns an object represents version", () => { 71 | const source = parseRepositorySource({ kind: "source", value: "2.1.2" }); 72 | expect(source).toEqual({ 73 | type: "version", 74 | version: "2.1.2", 75 | }); 76 | }); 77 | it("returns an object represents PR number", () => { 78 | const source = parseRepositorySource({ kind: "source", value: "#2333" }); 79 | expect(source).toEqual({ 80 | type: "prNumber", 81 | prNumber: "2333", 82 | }); 83 | }); 84 | it("returns an object represents repository and ref", () => { 85 | const source = parseRepositorySource({ 86 | kind: "source", 87 | value: "sosukesuzuki/prettier#ref", 88 | }); 89 | expect(source).toEqual({ 90 | type: "repositoryAndRef", 91 | ref: "ref", 92 | repositoryName: "sosukesuzuki/prettier", 93 | }); 94 | }); 95 | it("throws syntax error for invalid source token", () => { 96 | expect(() => 97 | parseRepositorySource({ kind: "source", value: "" }), 98 | ).toThrow("Unexpected source value ''."); 99 | expect(() => 100 | parseRepositorySource({ kind: "source", value: "foobar" }), 101 | ).toThrow("Unexpected source value 'foobar'."); 102 | }); 103 | }); 104 | 105 | describe("tokenize", () => { 106 | it("returns 'run' token", () => { 107 | const tokens = tokenize("run"); 108 | expect(tokens).toEqual([{ kind: "run" }]); 109 | }); 110 | it("returns 'vs' token", () => { 111 | const tokens = tokenize("vs"); 112 | expect(tokens).toEqual([{ kind: "vs" }]); 113 | }); 114 | it("returns 'on' token", () => { 115 | const tokens = tokenize("on"); 116 | expect(tokens).toEqual([{ kind: "on" }]); 117 | }); 118 | it("returns source token", () => { 119 | const tokens = tokenize("source"); 120 | expect(tokens).toEqual([{ kind: "source", value: "source" }]); 121 | }); 122 | it("returns 'run', 'vs', 'on' tokens", () => { 123 | const tokens = tokenize("run vs on"); 124 | expect(tokens).toEqual([{ kind: "run" }, { kind: "vs" }, { kind: "on" }]); 125 | }); 126 | it("returns mixed tokens", () => { 127 | const tokens = tokenize("run foo vs bar on a b c"); 128 | expect(tokens).toEqual([ 129 | { kind: "run" }, 130 | { kind: "source", value: "foo" }, 131 | { kind: "vs" }, 132 | { kind: "source", value: "bar" }, 133 | { kind: "on" }, 134 | { kind: "source", value: "a" }, 135 | { kind: "source", value: "b" }, 136 | { kind: "source", value: "c" }, 137 | ]); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /__tests__/projects.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | import { getProjects, validateProjects } from "../src/projects"; 4 | 5 | describe("getProjects", () => { 6 | it("gets projects from json file", async () => { 7 | const projects = await getProjects(); 8 | const projectsJsonPath = path.join(__dirname, "..", "projects.json"); 9 | const data = JSON.parse(await fs.readFile(projectsJsonPath, "utf-8")); 10 | expect(projects).toEqual(data); 11 | }); 12 | 13 | it("doesn't throw for correct project", () => { 14 | const projects = { 15 | foo: { 16 | commit: "foo", 17 | glob: "foo", 18 | url: "http://example.com", 19 | }, 20 | }; 21 | expect(validateProjects(projects)).toBe(true); 22 | }); 23 | 24 | it("throws error for project that does not have require property", () => { 25 | const projects = { 26 | foo: { 27 | commit: "foo", 28 | }, 29 | }; 30 | expect(validateProjects(projects)).toBe(false); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "words": [ 4 | "codemods", 5 | "eslintignore", 6 | "excalidraw", 7 | "execa", 8 | "octokit", 9 | "pathspec", 10 | "repos", 11 | "sosukesuzuki", 12 | "submodule" 13 | ] 14 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coverageProvider: "v8", 3 | rootDir: "./__tests__", 4 | testEnvironment: "node", 5 | transform: { 6 | "^.+\\.(t|j)sx?$": "@swc/jest", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier-regression-testing", 3 | "version": "1.0.0", 4 | "repository": "ssh://git@github.com/sosukesuzuki/prettier-regression-testing.git", 5 | "license": "MIT", 6 | "author": "sosukesuzuki ", 7 | "main": "index.js", 8 | "scripts": { 9 | "fix": "prettier . -w", 10 | "lint": "run-p lint:*", 11 | "lint:eslint": "eslint src --ext .ts", 12 | "lint:prettier": "prettier . --check", 13 | "lint:tsc": "tsc --noEmit", 14 | "test": "jest", 15 | "build": "tsc" 16 | }, 17 | "dependencies": { 18 | "@actions/artifact": "^1.1.1", 19 | "@actions/core": "^1.2.6", 20 | "@actions/github": "^4.0.0", 21 | "@excalidraw/prettier-config": "^1.0.2", 22 | "ajv": "^8.6.2", 23 | "ci-info": "^3.8.0", 24 | "execa": "^4.0.3", 25 | "semver": "^7.5.4" 26 | }, 27 | "devDependencies": { 28 | "@swc/core": "^1.3.68", 29 | "@swc/jest": "^0.2.26", 30 | "@types/jest": "^29.5.2", 31 | "@types/node": "^18.16.19", 32 | "@types/semver": "^7.5.0", 33 | "@typescript-eslint/eslint-plugin": "^5.61.0", 34 | "@typescript-eslint/parser": "^5.61.0", 35 | "eslint": "^8.44.0", 36 | "eslint-config-prettier": "^8.8.0", 37 | "jest": "^29.6.1", 38 | "npm-run-all": "^4.1.5", 39 | "prettier": "3.0.0", 40 | "typescript": "^5.1.6" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /projects.json: -------------------------------------------------------------------------------- 1 | { 2 | "babel": { 3 | "commit": "71c247a1831fe70e8b847fdb57b5fc10538d9748", 4 | "glob": "./{packages,codemods,eslint}/**/*.{js,ts}", 5 | "ignore": [ 6 | "# TODO: Read from https://github.com/babel/babel/blob/dfa4cc652718e1ce7905eaa2260cafa3392e052f/eslint.config.js#L34 ?", 7 | "/lib", 8 | "/build", 9 | "/.git", 10 | "package.json", 11 | "packages/babel-runtime", 12 | "packages/babel-runtime-corejs2", 13 | "packages/babel-runtime-corejs3", 14 | "packages/*/node_modules", 15 | "packages/*/lib", 16 | "packages/*/test/fixtures", 17 | "packages/*/test/tmp", 18 | "codemods/*/node_modules", 19 | "codemods/*/lib", 20 | "codemods/*/test/fixtures", 21 | "codemods/*/test/tmp", 22 | "packages/babel-compat-data/build", 23 | "packages/babel-preset-env/test/debug-fixtures", 24 | "packages/babel-standalone/babel.js", 25 | "packages/babel-standalone/babel.min.js", 26 | "packages/babel-parser/test/expressions", 27 | "packages/babel-core/src/vendor", 28 | "eslint/*/lib", 29 | "eslint/*/node_modules", 30 | "eslint/*/test/fixtures", 31 | "test/runtime-integration/*/output.js", 32 | "test/runtime-integration/*/output-absolute.js", 33 | "Makefile.js" 34 | ], 35 | "url": "https://github.com/babel/babel.git" 36 | }, 37 | "eslint-plugin-vue": { 38 | "commit": "cfad3eecc506effaedf43cc74f231d19fc780997", 39 | "glob": "./**/*.js", 40 | "url": "https://github.com/vuejs/eslint-plugin-vue.git" 41 | }, 42 | "excalidraw": { 43 | "commit": "275f6fbe2411549fa298de0d6841f71c7967b7d1", 44 | "glob": "./**/*.{css,scss,json,md,html,yml,ts,tsx,js}", 45 | "ignoreFile": ".eslintignore", 46 | "url": "https://github.com/excalidraw/excalidraw.git" 47 | }, 48 | "content": { 49 | "commit": "7e059cd39fa0c468d5966123a473255c1e2e2f90", 50 | "glob": "./**/*.md", 51 | "url": "https://github.com/mdn/content.git" 52 | }, 53 | "prettier": { 54 | "commit": "a32fca07264a6d3c2383d11ffecd0b85afd25985", 55 | "glob": ".", 56 | "url": "https://github.com/prettier/prettier.git" 57 | }, 58 | "react-admin": { 59 | "commit": "83f6ec3fb5f22645c776d155a8e34382845df634", 60 | "glob": [ 61 | "packages/*/src/**/*.{js,json,ts,tsx,css,md}", 62 | "examples/*/src/**/*.{js,ts,json,tsx,css,md}", 63 | "cypress/**/*.{js,ts,json,tsx,css,md}" 64 | ], 65 | "url": "https://github.com/marmelab/react-admin.git" 66 | }, 67 | "typescript-eslint": { 68 | "commit": "7984ef7974f9b86816c66aab08fcd60703667fd2", 69 | "glob": "./**/*.{ts,js,json,md}", 70 | "ignore": ["**/fixtures/**/*"], 71 | "url": "https://github.com/typescript-eslint/typescript-eslint.git" 72 | }, 73 | "vega-lite": { 74 | "commit": "a573af5aef8ce2d90309b5030aa435ca5e926fe9", 75 | "glob": "./**/*.ts", 76 | "ignoreFile": ".eslintignore", 77 | "url": "https://github.com/vega/vega-lite.git" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /scripts/update-projects-json.mjs: -------------------------------------------------------------------------------- 1 | import * as prettier from "prettier"; 2 | import fs from "fs/promises"; 3 | import path from "path"; 4 | import url from "url"; 5 | import execa from "execa"; 6 | import * as github from "@actions/github"; 7 | 8 | const __filename = url.fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | let octokit; 12 | function getOctokit() { 13 | if (!octokit) { 14 | octokit = github.getOctokit(process.env.GITHUB_TOKEN); 15 | } 16 | return octokit; 17 | } 18 | 19 | async function updateProjectsJsonFile() { 20 | const projectsJsonPath = path.join(__dirname, "..", "projects.json"); 21 | const projects = JSON.parse(await fs.readFile(projectsJsonPath, "utf-8")); 22 | const octokit = getOctokit(); 23 | const latestCommits = new Map(); 24 | await Promise.all( 25 | Object.entries(projects).map(async ([projectName, { url }]) => { 26 | const splitted = url.split("/"); 27 | const owner = splitted[3]; 28 | const repo = splitted[4].slice(0, -4); 29 | const repository = await octokit.repos.get({ owner, repo }); 30 | const defaultBranch = repository.data.default_branch; 31 | const latestCommit = await octokit.repos.getCommit({ 32 | owner, 33 | repo, 34 | ref: defaultBranch, 35 | }); 36 | const sha = latestCommit.data.sha; 37 | latestCommits.set(projectName, sha); 38 | }), 39 | ); 40 | const newProjects = { ...projects }; 41 | for (const [projectName, sha] of latestCommits) { 42 | newProjects[projectName].commit = sha; 43 | } 44 | await fs.writeFile( 45 | projectsJsonPath, 46 | await prettier.format(JSON.stringify(newProjects)), 47 | ); 48 | } 49 | 50 | function getFormattedDate() { 51 | const date = new Date(); 52 | const dateStr = 53 | date.getFullYear() + 54 | "-" + 55 | ("0" + (date.getMonth() + 1)).slice(-2) + 56 | "-" + 57 | ("0" + date.getDate()).slice(-2) + 58 | "-" + 59 | ("0" + date.getHours()).slice(-2) + 60 | "-" + 61 | ("0" + date.getMinutes()).slice(-2); 62 | return dateStr; 63 | } 64 | 65 | async function createPullRequest() { 66 | const formattedDate = getFormattedDate(); 67 | const branchName = `update-projects-json-${formattedDate}`; 68 | await execa("git", ["checkout", "-b", branchName]); 69 | await updateProjectsJsonFile(); 70 | 71 | const { stdout: diff } = await execa("git", ["diff", "--name-only"]); 72 | if (diff.includes("projects.json")) { 73 | await execa("git", ["add", "."]); 74 | await execa("git", ["commit", "-m", `"Update projects.json"`]); 75 | await execa("git", ["push", "origin", branchName]); 76 | const octokit = getOctokit(); 77 | await octokit.pulls.create({ 78 | ...github.context.repo, 79 | title: `Update projects.json (${formattedDate})`, 80 | head: branchName, 81 | base: "main", 82 | maintainer_can_modify: true, 83 | }); 84 | } 85 | } 86 | 87 | process.on("unhandledRejection", function (reason) { 88 | throw reason; 89 | }); 90 | 91 | createPullRequest() 92 | .then(() => { 93 | console.log("done"); 94 | }) 95 | .catch((e) => { 96 | throw e; 97 | }); 98 | -------------------------------------------------------------------------------- /src/artifact.ts: -------------------------------------------------------------------------------- 1 | import * as artifact from "@actions/artifact"; 2 | import * as github from "@actions/github"; 3 | import * as path from "path"; 4 | import * as fs from "fs"; 5 | import { getOctokit } from "./octokit"; 6 | 7 | export async function uploadToArtifact( 8 | texts: string[], 9 | ): Promise { 10 | if (texts.length === 0) { 11 | return undefined; 12 | } 13 | const filePaths = texts.map((text) => ({ 14 | filePath: path.join( 15 | process.env.GITHUB_WORKSPACE!, 16 | Date.now().toString() + ".txt", 17 | ), 18 | text, 19 | })); 20 | 21 | for (const { filePath, text } of filePaths) { 22 | fs.writeFileSync(filePath, text, "utf-8"); 23 | } 24 | 25 | const artifactClient = artifact.create(); 26 | const artifactName = "artifact" + Date.now().toString(); 27 | 28 | await artifactClient.uploadArtifact( 29 | artifactName, 30 | filePaths.map(({ filePath }) => filePath), 31 | process.env.GITHUB_WORKSPACE!, 32 | { 33 | continueOnError: true, 34 | }, 35 | ); 36 | 37 | const octokit = getOctokit(); 38 | const { 39 | data: { artifacts }, 40 | } = await octokit.actions.listWorkflowRunArtifacts({ 41 | owner: github.context.repo.owner, 42 | repo: github.context.repo.repo, 43 | run_id: github.context.runId, 44 | }); 45 | 46 | const artifactData = artifacts.find((a) => a.name === artifactName); 47 | 48 | return artifactData?.url; 49 | } 50 | -------------------------------------------------------------------------------- /src/configuration.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import ci from "ci-info"; 3 | 4 | export const cwd = process.cwd(); 5 | export const prettierRepositoryPath = path.join(cwd, "./prettier"); 6 | export const targetRepositoriesPath = path.join(cwd, "./repos"); 7 | export const isCI = ci.isCI; 8 | export const authToken = process.env.NODE_AUTH_TOKEN ?? "nothing"; 9 | -------------------------------------------------------------------------------- /src/execute/get-pretty-head-commit-hash.ts: -------------------------------------------------------------------------------- 1 | import * as git from "../tools/git"; 2 | 3 | /** 4 | * Returns head commit hash and repository name like "sosukesuzuki/prettier@foo" 5 | */ 6 | export async function getPrettyHeadCommitHash( 7 | repositoryPath: string, 8 | ): Promise { 9 | const headCommitHash = await git.revParseHead(repositoryPath); 10 | const remoteUrl = await git.remoteGetUrl(repositoryPath); 11 | // like "sosukesuzuki/prettier" 12 | const prettyRepositoryName = remoteUrl 13 | .replace("https://github.com/", "") 14 | .replace(".git", ""); 15 | return `${prettyRepositoryName}@${headCommitHash}`; 16 | } 17 | -------------------------------------------------------------------------------- /src/execute/index.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises as fs } from "fs"; 2 | import path from "path"; 3 | import { Command } from "../parse"; 4 | import { getPrettyHeadCommitHash } from "./get-pretty-head-commit-hash"; 5 | import { preparePrettierIgnoreFile } from "./prepare-prettier-ignore-file"; 6 | import { runPrettier } from "./run-prettier"; 7 | import { setupPrettierRepository } from "./setup-repository"; 8 | import * as configuration from "../configuration"; 9 | import * as git from "../tools/git"; 10 | import * as logger from "../logger"; 11 | 12 | const getTargetRepositoryPath = (targetRepositoryName: string) => 13 | path.join(configuration.targetRepositoriesPath, targetRepositoryName); 14 | 15 | export interface ExecuteResultEntry { 16 | commitHash: string; 17 | diff: string; 18 | } 19 | 20 | async function clonePrettier() { 21 | if (!existsSync(configuration.prettierRepositoryPath)) { 22 | await logger.log("Cloning Prettier repository..."); 23 | await git.clone( 24 | "https://github.com/prettier/prettier.git", 25 | "./prettier", 26 | configuration.cwd, 27 | ); 28 | } 29 | } 30 | 31 | export async function execute({ 32 | alternativePrettier, 33 | originalPrettier, 34 | }: Command): Promise { 35 | const targetRepositoryNames = await fs.readdir( 36 | configuration.targetRepositoriesPath, 37 | ); 38 | const commitHashes = await Promise.all( 39 | targetRepositoryNames.map(async (targetRepositoryName) => 40 | getPrettyHeadCommitHash(getTargetRepositoryPath(targetRepositoryName)), 41 | ), 42 | ); 43 | 44 | await clonePrettier(); 45 | 46 | // Setup originalVersionPrettier 47 | await logger.log("Setting up originalVersionPrettier..."); 48 | await setupPrettierRepository(originalPrettier); 49 | // Run originalVersionPrettier 50 | await logger.log("Running originalVersionPrettier..."); 51 | await Promise.all( 52 | targetRepositoryNames.map(async (targetRepositoryName) => { 53 | const targetRepositoryPath = 54 | getTargetRepositoryPath(targetRepositoryName); 55 | await preparePrettierIgnoreFile( 56 | targetRepositoryPath, 57 | targetRepositoryName, 58 | ); 59 | await runPrettier( 60 | configuration.prettierRepositoryPath, 61 | targetRepositoryPath, 62 | targetRepositoryName, 63 | ); 64 | await git.add(".", targetRepositoryPath); 65 | await git.commitAllowEmptyNoVerify( 66 | "Fixed by originalVersionPrettier", 67 | targetRepositoryPath, 68 | ); 69 | }), 70 | ); 71 | 72 | // Setup alternativeVersionPrettier 73 | await logger.log("Setting up alternativeVersionPrettier..."); 74 | await setupPrettierRepository(alternativePrettier); 75 | // Run alternativeVersionPrettier 76 | await logger.log("Running alternativeVersionPrettier..."); 77 | await Promise.all( 78 | targetRepositoryNames.map(async (targetRepositoryName) => { 79 | await runPrettier( 80 | configuration.prettierRepositoryPath, 81 | getTargetRepositoryPath(targetRepositoryName), 82 | targetRepositoryName, 83 | ); 84 | }), 85 | ); 86 | 87 | const diffs = await Promise.all( 88 | targetRepositoryNames.map(getTargetRepositoryPath).map(git.diffRepository), 89 | ); 90 | 91 | if (!configuration.isCI) { 92 | await Promise.all( 93 | targetRepositoryNames.map(async (targetRepositoryName) => { 94 | await git.resetHeadHard(getTargetRepositoryPath(targetRepositoryName)); 95 | }), 96 | ); 97 | } 98 | 99 | return targetRepositoryNames.map((_, i) => ({ 100 | commitHash: commitHashes[i], 101 | diff: diffs[i], 102 | })); 103 | } 104 | -------------------------------------------------------------------------------- /src/execute/prepare-prettier-ignore-file.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { getProjects } from "../projects"; 4 | 5 | export async function preparePrettierIgnoreFile( 6 | repositoryPath: string, 7 | repositoryName: string, 8 | ): Promise { 9 | const projects = await getProjects(); 10 | const project = projects[repositoryName]; 11 | if (!project) { 12 | throw new Error(`Repository name '${repositoryName}' is invalid`); 13 | } 14 | 15 | const { ignore, ignoreFile } = project; 16 | 17 | const prettierIgnoreFile = path.join(repositoryPath, "./.prettierignore"); 18 | let prettierIgnoreFileContent = fs.existsSync(prettierIgnoreFile) 19 | ? await fs.promises.readFile(prettierIgnoreFile, "utf8") 20 | : ""; 21 | if (ignoreFile) { 22 | prettierIgnoreFileContent += 23 | "\n" + 24 | (await fs.promises.readFile( 25 | path.join(repositoryPath, ignoreFile), 26 | "utf8", 27 | )); 28 | } 29 | if (ignore) { 30 | prettierIgnoreFileContent += 31 | "\n" + (Array.isArray(ignore) ? ignore.join("\n") : ignore); 32 | } 33 | 34 | await fs.promises.writeFile(prettierIgnoreFile, prettierIgnoreFileContent); 35 | } 36 | -------------------------------------------------------------------------------- /src/execute/run-prettier.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import execa from "execa"; 3 | import { getProjects } from "../projects"; 4 | import * as yarn from "../tools/yarn"; 5 | 6 | export async function runPrettier( 7 | prettierRepositoryPath: string, 8 | repositoryPath: string, 9 | repositoryName: string, 10 | ): Promise { 11 | const projects = await getProjects(); 12 | const project = projects[repositoryName]; 13 | if (!project) { 14 | throw new Error(`Repository name '${repositoryName}' is invalid`); 15 | } 16 | const { glob } = project; 17 | 18 | const prettierRepositoryBinPath = path.join( 19 | prettierRepositoryPath, 20 | "./bin/prettier.js", 21 | ); 22 | 23 | const args = ["--write"]; 24 | if (Array.isArray(glob)) { 25 | args.push(...glob.map((pattern) => JSON.stringify(pattern))); 26 | } else { 27 | args.push(JSON.stringify(glob)); 28 | } 29 | try { 30 | await execa(prettierRepositoryBinPath, args, { 31 | cwd: repositoryPath, 32 | shell: true, 33 | }); 34 | } catch ( 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | error: any 37 | ) { 38 | // if another packages is required to run Prettier 39 | // e.g. excalidraw: https://github.com/excalidraw/excalidraw/blob/a21db08cae608692d9525fff97f109fb24fec20c/package.json#L83 40 | if (error.message.includes("Cannot find module")) { 41 | await yarn.install(repositoryPath); 42 | await execa(prettierRepositoryBinPath, args, { 43 | cwd: repositoryPath, 44 | shell: true, 45 | }); 46 | } else { 47 | throw error; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/execute/setup-repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PrettierRepositorySourceVersion, 3 | PrettierRepositorySourcePrNumber, 4 | PrettierRepositorySourceRepositoryAndRef, 5 | PrettierRepositorySource, 6 | } from "../parse"; 7 | import * as configuration from "../configuration"; 8 | import * as brew from "../tools/brew"; 9 | import * as gh from "../tools/gh"; 10 | import * as git from "../tools/git"; 11 | import * as yarn from "../tools/yarn"; 12 | import * as unix from "../tools/unix"; 13 | 14 | export async function setupPrettierRepository( 15 | prettierRepositorySource: PrettierRepositorySource, 16 | ): Promise { 17 | switch (prettierRepositorySource.type) { 18 | case "prNumber": { 19 | await setupPullRequestNumber( 20 | prettierRepositorySource, 21 | configuration.prettierRepositoryPath, 22 | ); 23 | break; 24 | } 25 | case "repositoryAndRef": { 26 | await setupRepositoryAndRef( 27 | prettierRepositorySource, 28 | configuration.prettierRepositoryPath, 29 | ); 30 | break; 31 | } 32 | case "version": { 33 | await setupVersion( 34 | prettierRepositorySource, 35 | configuration.prettierRepositoryPath, 36 | ); 37 | break; 38 | } 39 | } 40 | } 41 | 42 | async function existsGh() { 43 | return !(await unix.which("gh")).includes("gh not found"); 44 | } 45 | async function setupPullRequestNumber( 46 | repositorySource: PrettierRepositorySourcePrNumber, 47 | cwd: string, 48 | ) { 49 | if (!(await existsGh())) { 50 | await brew.install("gh"); 51 | } 52 | if (configuration.authToken !== "nothing") { 53 | // running locally, `gh` can be already authenticated 54 | await gh.authLoginWithToken(configuration.authToken); 55 | } 56 | await gh.prCheckout(repositorySource.prNumber, cwd); 57 | await yarn.install(cwd); 58 | } 59 | 60 | function getRepositoryUrlWithToken(repositoryName: string) { 61 | return `https://${configuration.authToken}@github.com/${repositoryName}.git`; 62 | } 63 | async function setupRepositoryAndRef( 64 | repositorySource: PrettierRepositorySourceRepositoryAndRef, 65 | cwd: string, 66 | ) { 67 | const { repositoryName, ref } = repositorySource; 68 | const repositoryUrlWithToken = getRepositoryUrlWithToken(repositoryName); 69 | const uniq = "remote" + new Date().getTime(); 70 | await git.remoteAdd(uniq, repositoryUrlWithToken, cwd); 71 | await git.fetch(uniq, cwd); 72 | await git.checkout(ref, cwd); 73 | await yarn.install(cwd); 74 | } 75 | 76 | async function setupVersion( 77 | repositorySource: PrettierRepositorySourceVersion, 78 | cwd: string, 79 | ) { 80 | await git.checkout(`tags/${repositorySource.version}`, cwd); 81 | await yarn.install(cwd); 82 | } 83 | -------------------------------------------------------------------------------- /src/get-issue-comment.ts: -------------------------------------------------------------------------------- 1 | import * as github from "@actions/github"; 2 | import { WebhookPayload } from "@actions/github/lib/interfaces"; 3 | 4 | export function getIssueComment(): Exclude< 5 | WebhookPayload["comment"], 6 | undefined 7 | > { 8 | const { comment } = github.context.payload; 9 | if (!comment) { 10 | throw new Error("'github.context.payload' has no comment."); 11 | } 12 | return comment; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import * as core from "@actions/core"; 3 | import * as configuration from "./configuration"; 4 | import * as logger from "./logger"; 5 | import { execute } from "./execute"; 6 | import { getLogText } from "./log-text"; 7 | import { parse } from "./parse"; 8 | import { getIssueComment } from "./get-issue-comment"; 9 | import { cloneProjects } from "./projects"; 10 | import { uploadToArtifact } from "./artifact"; 11 | 12 | const TOO_LONG_DIFF_THRESHOLD_IN_CHARACTERS = 60000; 13 | 14 | async function exit(error: Error | string) { 15 | if (configuration.isCI) { 16 | core.setFailed(error); 17 | } else { 18 | console.error(error); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | process.on("unhandledRejection", function (reason) { 24 | let errorText: string; 25 | // Handle an error thrown by execa 26 | /* eslint-disable @typescript-eslint/no-explicit-any */ 27 | if ((reason as any).stderr) { 28 | errorText = 29 | "command: " + (reason as any).command + "\n" + (reason as any).stderr; 30 | /* eslint-enable @typescript-eslint/no-explicit-any */ 31 | } else { 32 | errorText = JSON.stringify(reason); 33 | } 34 | logger.error(errorText + "\n").then(() => { 35 | exit(errorText); 36 | }); 37 | }); 38 | 39 | (async () => { 40 | try { 41 | let commandString; 42 | if (configuration.isCI) { 43 | const comment = getIssueComment(); 44 | commandString = comment.body as string; 45 | } else { 46 | await fs.writeFile("log.txt", ""); 47 | commandString = process.argv.splice(2)[0]; 48 | } 49 | if (!commandString) { 50 | throw new Error("Please enter some commands."); 51 | } 52 | await cloneProjects(); 53 | const command = parse(commandString); 54 | const result = await execute(command); 55 | const logText = getLogText(result, command); 56 | if (typeof logText === "string") { 57 | await logger.log(logText); 58 | } else { 59 | const largeTexts: string[] = []; 60 | for (let index = 0; index < logText.length; index++) { 61 | const text = logText[index]; 62 | const shouldSeparate = index > 0; 63 | if ( 64 | text.length >= TOO_LONG_DIFF_THRESHOLD_IN_CHARACTERS && 65 | configuration.isCI 66 | ) { 67 | largeTexts.push(text); 68 | } else { 69 | await logger.log(logText[index], shouldSeparate); 70 | } 71 | } 72 | const artifactUrl = await uploadToArtifact(largeTexts); 73 | if (artifactUrl) { 74 | await logger.log( 75 | "Uploaded too large log.\n" + 76 | `You can download it from ${artifactUrl} .`, 77 | ); 78 | } 79 | } 80 | process.exit(0); 81 | } catch ( 82 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 83 | error: any 84 | ) { 85 | await logger.error(error); 86 | process.exit(1); 87 | } 88 | })().catch((error) => { 89 | logger.error(JSON.stringify(error)).then(() => { 90 | exit(error); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/log-text.ts: -------------------------------------------------------------------------------- 1 | import * as configuration from "./configuration"; 2 | import { ExecuteResultEntry } from "./execute"; 3 | import { Command, PrettierRepositorySource } from "./parse"; 4 | 5 | function getPrettierRepositorySourceText( 6 | prettierRepositorySource: PrettierRepositorySource, 7 | ) { 8 | switch (prettierRepositorySource.type) { 9 | case "prNumber": { 10 | return configuration.isCI 11 | ? `prettier/prettier#${prettierRepositorySource.prNumber}` 12 | : `https://github.com/prettier/prettier/pull/${prettierRepositorySource.prNumber}`; 13 | } 14 | case "repositoryAndRef": { 15 | return configuration.isCI 16 | ? `${prettierRepositorySource.repositoryName}@${prettierRepositorySource.ref}` 17 | : `https://github.com/${prettierRepositorySource.repositoryName}/blob/${prettierRepositorySource.ref}/README.md`; 18 | } 19 | case "version": { 20 | const versionUrl = `https://github.com/prettier/prettier/tree/${prettierRepositorySource.version}`; 21 | return configuration.isCI 22 | ? `[prettier/prettier@${prettierRepositorySource.version}](${versionUrl})` 23 | : versionUrl; 24 | } 25 | } 26 | } 27 | function getLogTitle(command: Command): string { 28 | const alternativePrettierRepositoryText = getPrettierRepositorySourceText( 29 | command.alternativePrettier, 30 | ); 31 | const originalPrettierRepositoryText = getPrettierRepositorySourceText( 32 | command.originalPrettier, 33 | ); 34 | if (configuration.isCI) { 35 | return `**${alternativePrettierRepositoryText} VS ${originalPrettierRepositoryText}**`; 36 | } else { 37 | return `${alternativePrettierRepositoryText} VS ${originalPrettierRepositoryText}`; 38 | } 39 | } 40 | 41 | const LONG_DIFF_THRESHOLD_IN_LINES = 50; 42 | 43 | const TOO_LONG_DIFF_THRESHOLD_IN_CHARACTERS = 60000; 44 | 45 | export function getLogText( 46 | result: ExecuteResultEntry[], 47 | command: Command, 48 | ): string | string[] { 49 | const title = getLogTitle(command); 50 | 51 | const joinedHeader = 52 | title + 53 | "\n\n" + 54 | result.map(({ commitHash }) => `- ${commitHash}`).join("\n") + 55 | "\n\n"; 56 | 57 | const joinedDiff = result.map(({ diff }) => diff).join("\n"); 58 | 59 | if (!configuration.isCI) { 60 | return ( 61 | "\n========= Result =========\n\n" + 62 | joinedHeader + 63 | joinedDiff + 64 | "\n\n========= End of Result =========\n" 65 | ); 66 | } 67 | 68 | if (joinedDiff.length < TOO_LONG_DIFF_THRESHOLD_IN_CHARACTERS) { 69 | return joinedHeader + formatDiff(joinedDiff); 70 | } 71 | 72 | return result.map( 73 | ({ commitHash, diff }) => 74 | `${title} :: ${commitHash}\n\n${formatDiff(diff)}`, 75 | ); 76 | } 77 | 78 | function formatDiff(content: string) { 79 | if (!content.trim()) { 80 | return "**The diff is empty.**"; 81 | } 82 | const lineCount = content.match(/\n/g)?.length ?? 0; 83 | const code = codeBlock(content, "diff"); 84 | return lineCount > LONG_DIFF_THRESHOLD_IN_LINES 85 | ? `
Diff (${lineCount} lines)\n\n${code}\n\n
` 86 | : code; 87 | } 88 | 89 | function codeBlock(content: string, syntax?: string) { 90 | const backtickSequences = content.match(/`+/g) || []; 91 | const longestBacktickSequenceLength = Math.max( 92 | ...backtickSequences.map(({ length }) => length), 93 | ); 94 | const fenceLength = Math.max(3, longestBacktickSequenceLength + 1); 95 | const fence = "`".repeat(fenceLength); 96 | return [fence + (syntax || ""), content, fence].join("\n"); 97 | } 98 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import * as github from "@actions/github"; 3 | import * as configuration from "./configuration"; 4 | import { getOctokit } from "./octokit"; 5 | 6 | let commentId: number | undefined; 7 | async function logToIssueComment(logText: string, separateComment = false) { 8 | const octokit = getOctokit(); 9 | if (commentId === undefined || separateComment) { 10 | const comment = await octokit.issues.createComment({ 11 | ...github.context.repo, 12 | issue_number: github.context.issue.number, 13 | body: logText, 14 | }); 15 | commentId = comment.data.id; 16 | } else { 17 | await octokit.issues.updateComment({ 18 | ...github.context.repo, 19 | comment_id: commentId, 20 | body: logText, 21 | }); 22 | } 23 | } 24 | 25 | export async function log( 26 | logText: string, 27 | separateComment = false, 28 | ): Promise { 29 | if (configuration.isCI) { 30 | console.log(logText); 31 | await logToIssueComment(logText, separateComment); 32 | } else { 33 | const fileData = await fs.readFile("log.txt", "utf-8"); 34 | await fs.writeFile("log.txt", fileData + logText + "\n"); 35 | } 36 | } 37 | 38 | export async function error(logText: string): Promise { 39 | if (configuration.isCI) { 40 | const errorText = "## [Error]\n\n" + "```\n" + logText + "\n```"; 41 | console.log(errorText); 42 | await logToIssueComment(errorText); 43 | } else { 44 | await fs.writeFile("log.txt", "[Error]\n\n" + logText); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/octokit.ts: -------------------------------------------------------------------------------- 1 | import * as github from "@actions/github"; 2 | import * as configuration from "./configuration"; 3 | import type { GitHub } from "@actions/github/lib/utils"; 4 | 5 | type Octokit = InstanceType; 6 | let octokit: Octokit | undefined; 7 | export function getOctokit(): Octokit { 8 | if (octokit === undefined) { 9 | octokit = github.getOctokit(configuration.authToken); 10 | } 11 | return octokit; 12 | } 13 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import semver from "semver"; 2 | 3 | export const sourceTypes = { 4 | version: "version", 5 | repositoryAndRef: "repositoryAndRef", 6 | prNumber: "prNumber", 7 | } as const; 8 | 9 | export interface PrettierRepositorySourceVersion { 10 | type: typeof sourceTypes.version; 11 | version: string; 12 | } 13 | export interface PrettierRepositorySourceRepositoryAndRef { 14 | type: typeof sourceTypes.repositoryAndRef; 15 | // like "sosukesuzuki/prettier" 16 | repositoryName: string; 17 | // like "main" 18 | ref: string; 19 | } 20 | export interface PrettierRepositorySourcePrNumber { 21 | type: typeof sourceTypes.prNumber; 22 | prNumber: string; 23 | } 24 | 25 | export type PrettierRepositorySource = 26 | | PrettierRepositorySourceVersion 27 | | PrettierRepositorySourceRepositoryAndRef 28 | | PrettierRepositorySourcePrNumber; 29 | 30 | export type Project = { 31 | repositoryUrl: string; 32 | }; 33 | 34 | export interface Command { 35 | alternativePrettier: PrettierRepositorySource; 36 | originalPrettier: PrettierRepositorySource; 37 | } 38 | 39 | const defaultPrettierRepositorySource: PrettierRepositorySource = { 40 | type: sourceTypes.repositoryAndRef, 41 | repositoryName: "prettier/prettier", 42 | ref: "main", 43 | }; 44 | export function parse(source: string): Command { 45 | const tokens = tokenize(source); 46 | 47 | let alternativePrettier: PrettierRepositorySource | undefined = undefined; 48 | let originalPrettier: PrettierRepositorySource = 49 | defaultPrettierRepositorySource; 50 | 51 | for (const [index, token] of tokenize(source).entries()) { 52 | const lookahead = (): Token => { 53 | return tokens[index + 1]; 54 | }; 55 | const lookbehind = (): Token => { 56 | return tokens[index - 1]; 57 | }; 58 | const match = (kind: Token["kind"]) => { 59 | return token.kind === kind; 60 | }; 61 | 62 | if (index === 0 && token.kind !== "run") { 63 | throw new SyntaxError("A command must start with 'run'."); 64 | } 65 | 66 | if (match("run")) { 67 | if (lookahead().kind !== "source") { 68 | throw new SyntaxError( 69 | "A prettier repository source must be specified for 'run'.", 70 | ); 71 | } 72 | continue; 73 | } 74 | 75 | if (match("vs")) { 76 | if (lookahead().kind !== "source") { 77 | throw new SyntaxError( 78 | "A prettier repository source must be specified for 'vs'.", 79 | ); 80 | } 81 | continue; 82 | } 83 | 84 | if (match("on")) { 85 | throw new SyntaxError("We haven't supported 'on' yet."); 86 | } 87 | 88 | if (match("source")) { 89 | if (lookbehind().kind === "run") { 90 | alternativePrettier = parseRepositorySource(token); 91 | } else if (lookbehind().kind === "vs") { 92 | originalPrettier = parseRepositorySource(token); 93 | } else { 94 | throw new SyntaxError( 95 | `Unexpected token '${token.kind}', expect 'run' or 'vs'.`, 96 | ); 97 | } 98 | } 99 | } 100 | 101 | return { alternativePrettier, originalPrettier } as Command; 102 | } 103 | 104 | export function parseRepositorySource(token: Token): PrettierRepositorySource { 105 | if (token.kind !== "source") { 106 | throw new Error(`Unexpected token '${token.kind}', expect 'source'.`); 107 | } 108 | 109 | const { value } = token; 110 | 111 | // like "2.3.4" 112 | if (semver.valid(value)) { 113 | return { 114 | type: sourceTypes.version, 115 | version: value, 116 | }; 117 | } 118 | 119 | // like "sosukesuzuki/prettier#ref" 120 | const split1 = value.split("/").filter(Boolean); 121 | if (value.split("/").length === 2) { 122 | const split2 = split1[1].split(/#|@/).filter(Boolean); 123 | if (split2.length === 2) { 124 | const remoteName = split1[0]; 125 | const repositoryName = `${remoteName}/${split2[0]}`; 126 | const ref = split2[1]; 127 | return { 128 | type: sourceTypes.repositoryAndRef, 129 | repositoryName, 130 | ref, 131 | }; 132 | } 133 | } 134 | 135 | // like "#3465" 136 | const matched = value.match(/\b\d{1,5}\b/g); 137 | if (value.startsWith("#") && matched) { 138 | return { 139 | type: sourceTypes.prNumber, 140 | prNumber: matched[0], 141 | }; 142 | } 143 | 144 | throw new SyntaxError(`Unexpected source value '${value}'.`); 145 | } 146 | 147 | type Token = 148 | | { 149 | kind: "run"; 150 | } 151 | | { 152 | kind: "vs"; 153 | } 154 | | { 155 | kind: "on"; 156 | } 157 | | { 158 | kind: "source"; 159 | value: string; 160 | }; 161 | export function tokenize(source: string): Token[] { 162 | const tokens: Token[] = []; 163 | for (const word of source.trim().split(" ").filter(Boolean)) { 164 | switch (word.toLowerCase()) { 165 | case "run": 166 | tokens.push({ kind: "run" }); 167 | break; 168 | case "vs": 169 | tokens.push({ kind: "vs" }); 170 | break; 171 | case "on": 172 | tokens.push({ kind: "on" }); 173 | break; 174 | default: 175 | tokens.push({ kind: "source", value: word }); 176 | } 177 | } 178 | return tokens; 179 | } 180 | -------------------------------------------------------------------------------- /src/projects.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { existsSync, promises as fs } from "fs"; 3 | import Ajv, { DefinedError } from "ajv"; 4 | import * as logger from "./logger"; 5 | import * as configuration from "./configuration"; 6 | import * as git from "./tools/git"; 7 | 8 | export interface Project { 9 | url: string; 10 | glob: string | readonly string[]; 11 | ignoreFile?: string; 12 | ignore?: string | readonly string[]; 13 | commit: string; 14 | } 15 | 16 | const projectSchema = { 17 | type: "object", 18 | properties: { 19 | commit: { type: "string" }, 20 | glob: { 21 | oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], 22 | }, 23 | ignoreFile: { type: "string" }, 24 | ignore: { 25 | oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], 26 | }, 27 | url: { type: "string" }, 28 | }, 29 | required: ["commit", "glob", "url"], 30 | additionalProperties: false, 31 | }; 32 | 33 | const schema = { 34 | type: "object", 35 | patternProperties: { 36 | ".+": projectSchema, 37 | }, 38 | additionalProperties: false, 39 | } as const; 40 | 41 | const ajv = new Ajv(); 42 | 43 | export const validateProjects = ajv.compile(schema); 44 | 45 | let data: { [project: string]: Project }; 46 | 47 | export async function getProjects(): Promise<{ [project: string]: Project }> { 48 | if (data) { 49 | return data; 50 | } 51 | const projectJsonPath = path.join(__dirname, "..", "projects.json"); 52 | data = JSON.parse(await fs.readFile(projectJsonPath, "utf-8")); 53 | if (validateProjects(data)) { 54 | return data as { [project: string]: Project }; 55 | } 56 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 57 | throw validateProjects.errors![0] as DefinedError; 58 | } 59 | 60 | export async function cloneProjects(): Promise { 61 | await logger.log("Cloning repositories..."); 62 | if (!existsSync(configuration.targetRepositoriesPath)) { 63 | await fs.mkdir(configuration.targetRepositoriesPath); 64 | } 65 | const projects = await getProjects(); 66 | await Promise.all( 67 | Object.entries(projects).map(async ([name, project]) => { 68 | const repo = path.join(configuration.targetRepositoriesPath, name); 69 | if (!existsSync(repo)) { 70 | await git.shallowClone(project.url, project.commit, repo); 71 | } 72 | }), 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/tools/brew.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | 3 | export async function install(packageName: string): Promise { 4 | await execa("brew", ["install", packageName]); 5 | } 6 | -------------------------------------------------------------------------------- /src/tools/gh.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | 3 | export async function prCheckout(prNumber: string, cwd: string): Promise { 4 | await execa("gh", ["pr", "checkout", prNumber], { cwd }); 5 | } 6 | 7 | export async function authLoginWithToken(token: string): Promise { 8 | await execa("gh", ["auth", "login", "--with-token"], { input: token }); 9 | } 10 | -------------------------------------------------------------------------------- /src/tools/git.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | import path from "path"; 3 | import fs from "fs/promises"; 4 | 5 | export async function remoteAdd( 6 | remoteName: string, 7 | repositoryUrl: string, 8 | cwd: string, 9 | ): Promise { 10 | await execa("git", ["remote", "add", remoteName, repositoryUrl], { cwd }); 11 | await execa("git", ["config", "checkout.defaultRemote", remoteName], { cwd }); 12 | } 13 | 14 | export async function fetch(remoteName: string, cwd: string): Promise { 15 | await execa("git", ["fetch", remoteName], { cwd }); 16 | } 17 | 18 | export async function fetchDepth1( 19 | remoteName: string, 20 | commitHash: string, 21 | cwd: string, 22 | ): Promise { 23 | await execa("git", ["fetch", "--depth", "1", remoteName, commitHash], { 24 | cwd, 25 | }); 26 | } 27 | 28 | export async function checkout(ref: string, cwd: string): Promise { 29 | await execa("git", ["checkout", ref], { cwd }); 30 | } 31 | 32 | export async function revParseHead(cwd: string): Promise { 33 | const headCommitHash = await execa("git", ["rev-parse", "HEAD"], { 34 | cwd, 35 | }).then(({ stdout }) => stdout); 36 | return headCommitHash; 37 | } 38 | 39 | export async function remoteGetUrl(cwd: string): Promise { 40 | const remoteUrl = await execa( 41 | "git", 42 | ["remote", "get-url", "--all", "origin"], 43 | { cwd }, 44 | ).then(({ stdout }) => stdout); 45 | return remoteUrl; 46 | } 47 | 48 | export async function diffRepository(directoryPath: string): Promise { 49 | const diffString = await execa( 50 | "git", 51 | [ 52 | "diff", 53 | `--src-prefix=ORI/${path.basename(directoryPath)}/`, 54 | `--dst-prefix=ALT/${path.basename(directoryPath)}/`, 55 | ], 56 | { cwd: directoryPath }, 57 | ).then(({ stdout }) => stdout); 58 | return diffString; 59 | } 60 | 61 | export async function add(pathspec: string, cwd: string): Promise { 62 | await execa("git", ["add", pathspec], { cwd }); 63 | } 64 | 65 | export async function commitAllowEmptyNoVerify( 66 | message: string, 67 | cwd: string, 68 | ): Promise { 69 | await execa( 70 | "git", 71 | ["commit", "--allow-empty", "--no-verify", "-m", JSON.stringify(message)], 72 | { cwd }, 73 | ); 74 | } 75 | 76 | export async function resetHeadHard(cwd: string): Promise { 77 | await execa("git", ["reset", "HEAD^", "--hard"], { cwd }); 78 | } 79 | 80 | export async function clone( 81 | url: string, 82 | dirname: string, 83 | cwd: string, 84 | ): Promise { 85 | await execa("git", ["clone", url, dirname], { cwd }); 86 | } 87 | 88 | export async function init(cwd: string): Promise { 89 | await execa("git", ["init"], { cwd }); 90 | } 91 | 92 | export async function shallowClone( 93 | url: string, 94 | commit: string, 95 | cwd: string, 96 | ): Promise { 97 | await fs.mkdir(cwd); 98 | await init(cwd); 99 | await remoteAdd("origin", url, cwd); 100 | await fetchDepth1("origin", commit, cwd); 101 | await checkout("FETCH_HEAD", cwd); 102 | } 103 | -------------------------------------------------------------------------------- /src/tools/unix.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | 3 | export async function which(cmd: string): Promise { 4 | const res = await execa("which", [cmd]).then(({ stdout }) => stdout); 5 | return res; 6 | } 7 | -------------------------------------------------------------------------------- /src/tools/yarn.ts: -------------------------------------------------------------------------------- 1 | import execa from "execa"; 2 | 3 | export async function install(cwd: string): Promise { 4 | await execa("yarn", [], { cwd }); 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "outDir": "./build", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["./src"] 11 | } 12 | --------------------------------------------------------------------------------