From adc2f327678495d348d80f162b46a19e271d2e31 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 13 May 2024 14:11:35 -0700 Subject: [PATCH] Add script to sync labels (#3323) Add script to sync labels to enforce the labels designed in #3265 image --- .github/actions/setup/action.yml | 2 +- .github/workflows/sync-labels.yml | 33 +++ .github/workflows/verify-labels.yml | 26 ++ CONTRIBUTING.md | 299 +++++++++++------------ eng/common/labels.yaml | 114 +++++++++ eng/common/scripts/sync-labels.ts | 354 ++++++++++++++++++++++++++++ package.json | 11 +- pnpm-lock.yaml | 98 +++++++- 8 files changed, 767 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/sync-labels.yml create mode 100644 .github/workflows/verify-labels.yml create mode 100644 eng/common/labels.yaml create mode 100644 eng/common/scripts/sync-labels.ts diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 403daa27bb..914d8b9cc3 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -11,7 +11,7 @@ runs: steps: - name: Install pnpm - uses: pnpm/action-setup@v2 + uses: pnpm/action-setup@v3 - name: Set node version to ${{ inputs.node-version }} uses: actions/setup-node@v4 diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 0000000000..4b6ad0ac28 --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,33 @@ +name: Sync labels + +on: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + paths: + - "package.json" + - "eng/common/labels.yaml" + - "eng/common/scripts/sync-labels.ts" + - ".github/workflows/sync-labels.yml" + + workflow_dispatch: {} + +permissions: + issues: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + + - run: pnpm install + name: Install dependencies + + - run: pnpm sync-labels --github + name: Sync labels + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/verify-labels.yml b/.github/workflows/verify-labels.yml new file mode 100644 index 0000000000..b87f91c10e --- /dev/null +++ b/.github/workflows/verify-labels.yml @@ -0,0 +1,26 @@ +name: Verify labels +on: + pull_request: + branches: + - "main" + paths: + - "package.json" + - "eng/common/labels.yaml" + - "eng/common/scripts/sync-labels.ts" + - ".github/workflows/sync-labels.yml" + - "CONTRIBUTING.md" + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + + - run: pnpm install + name: Install dependencies + + - run: pnpm sync-labels --github --check + name: Verify labels + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc7aca7d51..ced01d22fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,8 @@ -# Prerequisites +# Developper guide + +This section goes over the setup of the repo for development. + +## Repo setup - Install [Node.js](https://nodejs.org/) 20 LTS - Install [pnpm](https://pnpm.io/) @@ -7,163 +11,69 @@ npm install -g pnpm ``` -# Installing NPM dependencies +- Install dependencies ```bash pnpm install ``` -This will install all of the npm dependencies of all projects in the -repo. Do this whenever you `git pull` or your workspace is freshly -cleaned/cloned. - -Note that `pnpm install` must be done before building in VS Code or -using the command line. - -# Install playwright browsers for UI testing - -```bash -npx playwright install -``` - -# Using command line - -**If you are not at the root of the repo you have to use `-w` option to specify you want to run the command for the workspace. `pnpm -w `.** - -## Rebuild the whole repo +- Build the dependencies ```bash pnpm build ``` -This will build all projects in the correct dependency order. - -## Build the whole repo incrementally - -```bash -pnpm build -``` - -This will build all projects that have changed since the last `pnpm build` in -dependency order. - -## Build an individual package on the command line - -```bash -cd packages/ -pnpm build -``` - -## Run all tests for the whole repo - -```bash -pnpm test -``` - -## Start compile on save - -Starting this command will rebuild the typescript files on save. +- (Optional) Install [Playwright](https://playwright.dev/) browsers for UI testing ```bash -pnpm watch +npx playwright install ``` -## Cleanup - -Sometimes there are ghost files left in the dist folder (common when renaming or deleting a TypeScript file), running this will get a clean state. +- Start the build in watch mode to automatically rebuild on save ```bash -pnpm clean +pnpm run watch ``` -## Run tests for an individual package +## Using command line -```bash -cd packages/ -pnpm test -``` - -## Verbose test logging +**If you are not at the root of the repo you have to use `-w` option to specify you want to run the command for the workspace. `pnpm -w `.** +Those commands can be run on the workspace or in a specific package(`cd ./packages/`). + +| Command | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm build` | Build | +| `pnpm test` | Test | +| `pnpm test:watch` | Run test in watch mode(only when inside a package) | +| `pnpm watch` | Build in watch mode, Starting this command will rebuild the typescript files on save. | +| `pnpm clean` | Clean, sometimes there are ghost files left in the dist folder (common when renaming or deleting a TypeScript file), running this will get a clean state. | +| `pnpm format` | Format | +| `pnpm format:check` | Validate files are formatted | +| `pnpm gen-extern-signature` | Regenerate TypeScript signature for decorators(except compiler) | +| `pnpm change add` | Add a change description | +| `pnpm lint` | Run linters | +| `pnpm lint:fix` | Fix autofixable issues | +| `pnpm regen-samples` | Regen the samples(when the samples test fail) | +| `pnpm regen-docs` | Regen the reference docs | + +### Verbose test logging Tests sometimes log extra info using `logVerboseTestOutput` To see this output on the command line, set environment variable TYPESPEC_VERBOSE_TEST_OUTPUT=true. -## Reformat source code - -```bash -pnpm format -``` - -PR validation enforces code formatting style rules for the repo. This -command will reformat code automatically so that it passes. - -You can also check if your code is formatted correctly without -reformatting anything using `pnpm check-format`. - -See also below for having this happen automatically in VS Code -whenever you save. - -## Generate TypeScript signature for decorators - -For all packages except the compiler this will be done automatically as part of each package `build` script. - -If you want to regenerate decorator signatures without a full build you can run: - -``` -pnpm gen-extern-signature -``` - **For the compiler you will need to run it manually or run the whole workspace build. This is because for the tool to run it needs the compiler to build first.** -## Generate changelogs +## Using VS Code -```bash -pnpm change -``` +### Recommended extensions -## Linting +1. [Vitest Test Explorer](https://marketplace.visualstudio.com/items?itemName=vitest.explorer): Run tests from the IDE. +2. [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode): Automatically keep code formatted correctly on save. +3. [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint): Show eslint errors in warnings in UI. +4. [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker): Show spell check errors in document. -```bash -pnpm lint -``` - -PR validation enforces linting rules for the repo. This -command will run the linter on all packages. - -## Regenerate Samples - -```bash -pnpm regen-samples -``` - -PR validation runs OpenAPI emitters on samples and compares them to known, -reviewed, checked-in versions. If your PR would change the generated output, -run this command to regenerate any samples and check those files in with -your PR. Carefully review whether the changes are intentional. - -## Regenerate Reference Docs - -```bash -pnpm regen-docs -``` - -PR validation will ensure that reference docs are up to date. - -# Using VS Code - -## Recommended extensions - -1. [Vitest Test Explorer](https://marketplace.visualstudio.com/items?itemName=ZixuanChen.vitest-explorer): - Run tests from the IDE. (Version `0.2.43` is bugged on OSX, use `0.2.42` instead) -2. [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode): - Automatically keep code formatted correctly on save. -3. [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint): - Show eslint errors in warnings in UI. -4. [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker): - Show spell check errors in document. - -## Opening the repo as workspace +### Opening the repo as workspace Always open the root of the repo as the workspace. Things are setup to allow easy development across packages rather than opening one package @@ -173,7 +83,7 @@ at a time in the IDE. cloned - Or run `code /path/to/repo/root` on the command line -## Building +### Building - Terminal -> Run Build Task (`Ctrl+Shift+B`) @@ -189,17 +99,17 @@ Terminal pane will have three parallel watch tasks running: - `watch-tmlanguage`: process that regenerates typespec.tmlanguage when tmlanguage.ts changes -## Testing +### Testing ```bash # Run all the tests pnpm test # Run in a specific package tests in watch mode -npm run test:watch +pnpm test:watch ``` -## Debugging +### Debugging There are several "Run and Debug" tasks set up. Click on the Run and Debug icon on the sidebar, pick one from its down, and press F5 to @@ -226,16 +136,16 @@ debug the last one you chose. typespec.tmlanguage file that provides syntax highlighting of TypeSpec in VS and VS Code. Select this to debug its build process. -# Developing the Visual Studio Extension +## Developing the Visual Studio Extension -## Prerequisites +### Prerequisites Install [Visual Studio](https://visualstudio.microsoft.com/vs/) 17.0 or later. It is not currently possible to build the VS extension without it, and of course you'll need Visual Studio to run and debug the Visual Studio extension. -## Build VS extension on the command line +### Build VS extension on the command line See the command line build steps above. If you have VS installed, the VS extension will be included in your command line full repo @@ -245,7 +155,7 @@ If you do not have VS installed the command line build steps above will simply skip building the VS extension and only build the VS Code extension. -## Build VS extension in VS +### Build VS extension in VS - Open packages/typespec-vs/Microsoft.TypeSpec.VisualStudio.sln in Visual Studio - Build -> Build solution (`Ctrl+Shift+B`) @@ -254,7 +164,7 @@ Unlike TypeScript in VS Code above, this is not a watching build, but it is relatively fast to run. Press Ctrl+Shift+B again to build any changes after you make them. -## Debug VS extension +### Debug VS extension - Click on the play icon in the toolbar or press `F5` @@ -266,9 +176,9 @@ The VS debugger will attach only to the VS client process. Use "Attach to Language Server" described above to debug the language server in VS Code. -# Installing your build +### Installing your build -``` +```bash pnpm dogfood ``` @@ -291,6 +201,16 @@ configuration in Visual Studio, then you can install it by double-clicking on packages/typespec-vs/Microsoft.TypeSpec.VisualStudio.vsix that gets produced. +## TypeSpec website + +### Run locally + +Go to `packages/website` and run the command: + +```bash +pnpm start +``` + # Pull request ## Trigger TypeSpec Playground Try It build @@ -298,36 +218,99 @@ that gets produced. For contributors of the repo the build will trigger automatically but for other's forks it will need a manual trigger from a contributor. As a contributor you can run the following command to trigger the build and create a TypeSpec playground link for this PR. -``` -/azp run TypeSpec Pull Request Try It +```bash +/azp run typespec - pr tools ``` -## Run formatter +# Issue and Pr processes -Trigger a workflow that will format the code, commit and push. +## Labels -``` -/typespeceng format -``` +TypeSpec repo use labels to help categorize and manage issues and PRs. The following is a list of labels and their descriptions. -# TypeSpec website + + -## Run locally +### Labels reference -Go to `packages/website` and run the command: +#### issue_kinds -``` -npm start -``` +Issue kinds + +| Name | Color | Description | +| --------- | ------- | ------------------------------------------ | +| `bug` | #d93f0b | Something isn't working | +| `feature` | #cccccc | New feature or request | +| `docs` | #cccccc | Improvements or additions to documentation | +| `epic` | #cccccc | | + +#### area + +Area of the codebase + +| Name | Color | Description | +| ---------------------------- | ------- | ----------------------------------- | +| `compiler:core` | #453261 | Issues for @typespec/compiler | +| `compiler:emitter-framework` | #453261 | Issues for the emitter framework | +| `ide` | #846da1 | Issues for VS, VSCode, Monaco, etc. | +| `lib:http` | #c7aee6 | | +| `lib:openapi` | #c7aee6 | | +| `lib:rest` | #c7aee6 | | +| `lib:versioning` | #c7aee6 | | +| `meta:blog` | #007dc8 | Blog updates | +| `meta:website` | #007dc8 | TypeSpec.io updates | +| `tspd` | #004185 | Issues for the tspd tool | +| `emitter:client:csharp` | #e1b300 | | +| `emitter:json-schema` | #957300 | | +| `emitter:protobuf` | #957300 | The protobuf emitter | +| `emitter:service:csharp` | #967200 | | +| `emitter:service:js` | #967200 | | +| `eng` | #65bfff | | + +#### breaking-change + +Labels around annotating issues and PR if they contain breaking change or deprecation + +| Name | Color | Description | +| ----------------- | ------- | ---------------------------------------------------------------------------------- | +| `breaking-change` | #B60205 | A change that might cause specs or code to break | +| `deprecation` | #760205 | A previously supported feature will now report a warning and eventually be removed | + +#### design-issues + +Design issue management + +| Name | Color | Description | +| ----------------- | ------- | ------------------------------------------------------ | +| `design:accepted` | #1a4421 | Proposal for design has been discussed and accepted. | +| `design:needed` | #96c499 | A design request has been raised that needs a proposal | +| `design:proposed` | #56815a | Proposal has been added and ready for discussion | + +#### process + +Process labels + +| Name | Color | Description | +| -------------- | ------- | --------------------------------------------------------------------------------- | +| `needs-triage` | #ffffff | | +| `needs-info` | #ffffff | Mark an issue that needs reply from the author or it will be closed automatically | +| `triaged:core` | #5319e7 | | + +#### misc + +Misc labels -## Publish website to github.io +| Name | Color | Description | +| -------------------------- | ------- | ------------------ | +| `Client Emitter Migration` | #FD92F0 | | +| `good first issue` | #7057ff | Good for newcomers | -The website on github.io should be published when releasing new packages. + -To release: +### Updating labels -- Go to https://github.com/microsoft/typespec/actions/workflows/website-gh-pages.yml -- Click the `Run workflow` dropdown and select the `main` branch. +Labels are configured in `eng/common/labels.yaml`. To update labels, edit this file and run `pnpm sync-labels`. +**If you create a new label in github UI without updating the `labels.yaml` file, it WILL be automatically removed** # TypeSpec Emitters diff --git a/eng/common/labels.yaml b/eng/common/labels.yaml new file mode 100644 index 0000000000..57bb6af121 --- /dev/null +++ b/eng/common/labels.yaml @@ -0,0 +1,114 @@ +# cspell:ignore bfff + +issue_kinds: + description: "Issue kinds" + labels: + bug: + color: d93f0b + description: Something isn't working + feature: + color: cccccc + description: New feature or request + docs: + color: cccccc + description: Improvements or additions to documentation + epic: + color: cccccc + description: "" +area: + description: "Area of the codebase" + labels: + compiler:core: + color: "453261" + description: Issues for @typespec/compiler + compiler:emitter-framework: + color: "453261" + description: Issues for the emitter framework + ide: + color: 846da1 + description: Issues for VS, VSCode, Monaco, etc. + lib:http: + color: c7aee6 + description: "" + lib:openapi: + color: c7aee6 + description: "" + lib:rest: + color: c7aee6 + description: "" + lib:versioning: + color: c7aee6 + description: "" + meta:blog: + color: 007dc8 + description: Blog updates + meta:website: + color: 007dc8 + description: TypeSpec.io updates + tspd: + color: "004185" + description: Issues for the tspd tool + emitter:client:csharp: + color: e1b300 + description: "" + emitter:json-schema: + color: "957300" + description: "" + emitter:protobuf: + color: "957300" + description: The protobuf emitter + emitter:service:csharp: + color: "967200" + description: "" + emitter:service:js: + color: "967200" + description: "" + eng: + color: 65bfff + description: "" + +breaking-change: + description: "Labels around annotating issues and PR if they contain breaking change or deprecation" + labels: + breaking-change: + color: B60205 + description: A change that might cause specs or code to break + deprecation: + color: "760205" + description: A previously supported feature will now report a warning and eventually be removed + +design-issues: + description: "Design issue management" + labels: + design:accepted: + color: 1a4421 + description: Proposal for design has been discussed and accepted. + design:needed: + color: 96c499 + description: A design request has been raised that needs a proposal + design:proposed: + color: 56815a + description: Proposal has been added and ready for discussion + +process: + description: "Process labels" + labels: + needs-triage: + color: ffffff + description: "" + needs-info: + color: ffffff + description: Mark an issue that needs reply from the author or it will be closed automatically + triaged:core: + color: "5319e7" + description: "" + +misc: + description: "Misc labels" + labels: + Client Emitter Migration: + color: FD92F0 + description: "" + good first issue: + color: 7057ff + description: Good for newcomers diff --git a/eng/common/scripts/sync-labels.ts b/eng/common/scripts/sync-labels.ts new file mode 100644 index 0000000000..368098b4af --- /dev/null +++ b/eng/common/scripts/sync-labels.ts @@ -0,0 +1,354 @@ +import { Octokit as OctokitCore } from "@octokit/core"; +import { paginateGraphQL } from "@octokit/plugin-paginate-graphql"; +import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; +import { readFile, writeFile } from "fs/promises"; +import { dirname, resolve } from "path"; +import pc from "picocolors"; +import { format, resolveConfig } from "prettier"; +import { fileURLToPath } from "url"; +import { inspect, parseArgs } from "util"; +import { parse } from "yaml"; + +const Octokit = OctokitCore.plugin(paginateGraphQL).plugin(restEndpointMethods); +type Octokit = InstanceType; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const labelFileRelative = "eng/common/labels.yaml"; +const labelFile = resolve(repoRoot, labelFileRelative); +const contributingFile = resolve(repoRoot, "CONTRIBUTING.md"); +const magicComment = { + start: "", + end: "", +} as const; + +const repo = { + owner: "microsoft", + repo: "typespec", +}; +await main(); + +interface LabelsConfig { + readonly categories: LabelCategory[]; + readonly labels: Label[]; +} + +interface LabelCategory { + readonly name: string; + readonly description: string; + readonly labels: Label[]; +} + +interface Label { + readonly name: string; + readonly color: string; + readonly description: string; +} + +interface ActionOptions { + readonly dryRun?: boolean; + readonly check?: boolean; +} + +async function main() { + const options = parseArgs({ + args: process.argv.slice(2), + options: { + "dry-run": { + type: "boolean", + description: "Do not make any changes, log what action would be taken.", + }, + check: { + type: "boolean", + description: "Check if labels are in sync, return non zero exit code if not.", + }, + github: { type: "boolean", description: "Include github labels" }, + }, + }); + const content = await readFile(labelFile, "utf8"); + const labels = loadLabels(content); + logLabelConfig(labels); + + if (options.values["github"]) { + await syncGithubLabels(labels.labels, { + dryRun: options.values["dry-run"], + check: options.values.check, + }); + } + + updateContributingFile(labels, { + dryRun: options.values["dry-run"], + check: options.values.check, + }); +} + +function loadLabels(yamlContent: string): LabelsConfig { + const data: Record< + string, + { description: string; labels: Record } + > = parse(yamlContent); + const labels = []; + const categories: LabelCategory[] = []; + for (const [categoryName, { description, labels: labelMap }] of Object.entries(data)) { + const categoryLabels = Object.entries(labelMap).map(([name, data]) => ({ name, ...data })); + const category = { name: categoryName, description, labels: categoryLabels }; + categories.push(category); + for (const label of categoryLabels) { + validateLabel(label); + labels.push(label); + } + } + return { labels, categories }; +} + +function logLabelConfig(config: LabelsConfig) { + console.log("Label config:"); + const max = config.labels.reduce((max, label) => Math.max(max, label.name.length), 0); + for (const category of config.categories) { + console.log(` ${pc.green(category.name)} ${pc.gray(category.description)}`); + for (const label of category.labels) { + console.log(` ${prettyLabel(label, max)}`); + } + console.log(""); + } + console.log(""); +} + +function logLabels(message: string, labels: Label[]) { + if (labels.length === 0) { + console.log(message, pc.cyan("none")); + return; + } + console.log(message); + const max = labels.reduce((max, label) => Math.max(max, label.name.length), 0); + for (const label of labels) { + console.log(` ${prettyLabel(label, max)}`); + } + console.log(""); +} + +function prettyLabel(label: Label, padEnd: number = 0) { + return `${pc.cyan(label.name.padEnd(padEnd))} ${pc.blue(`#${label.color}`)} ${pc.gray(label.description)}`; +} + +async function syncGithubLabels(labels: Label[], options: ActionOptions = {}) { + if (!options.dryRun && !process.env.GITHUB_TOKEN && !options.check) { + throw new Error( + "GITHUB_TOKEN environment variable is required when not running in dry-run mode or check mode." + ); + } + const octokit = new Octokit( + process.env.GITHUB_TOKEN ? { auth: `token ${process.env.GITHUB_TOKEN}` } : {} + ); + + const existingLabels = await fetchAllLabels(octokit); + logLabels("Existing github labels", existingLabels as any); + const labelToUpdate: Label[] = []; + const labelsToCreate: Label[] = []; + const exitingLabelMap = new Map(existingLabels.map((label) => [label.name, label])); + for (const label of labels) { + const existingLabel = exitingLabelMap.get(label.name); + if (existingLabel) { + if (existingLabel.color !== label.color || existingLabel.description !== label.description) { + labelToUpdate.push(label); + } + } else { + labelsToCreate.push(label); + } + exitingLabelMap.delete(label.name); + } + const labelsToDelete = Array.from(exitingLabelMap.values()); + logLabels("Labels to update", labelToUpdate); + logLabels("Labels to create", labelsToCreate); + logLabels("Labels to delete", labelsToDelete); + console.log(""); + + if (options.check) { + if (labelsToDelete.length > 0) { + checkLabelsToDelete(labelsToDelete); + } + } else { + logAction("Applying changes", options); + await updateLabels(octokit, labelToUpdate, options); + await createLabels(octokit, labelsToCreate, options); + await deleteLabels(octokit, labelsToDelete, options); + logAction("Done applying changes", options); + } +} + +async function checkLabelsToDelete(labels: GithubLabel[]) { + console.log("Checking labels that will be deleted don't have any issues assigned."); + let hasError = false; + for (const label of labels) { + if (label.issues.totalCount > 0) { + console.error( + pc.red( + `Label ${label.name} has ${label.issues.totalCount} issues assigned to it, make sure to rename the label manually first to not lose assignment.` + ) + ); + hasError = true; + } + } + if (hasError) { + process.exit(1); + } else { + console.error(pc.green(`Labels looks good to delete.`)); + } +} + +interface GithubLabel { + readonly name: string; + readonly color: string; + readonly description: string; + readonly issues: { readonly totalCount: number }; +} +async function fetchAllLabels(octokit: Octokit): Promise { + const { repository } = await octokit.graphql.paginate( + `query paginate($cursor: String) { + repository(owner: "Microsoft", name: "typespec") { + labels(first: 100, after: $cursor) { + nodes { + color + name + description + issues(filterBy: {states: OPEN}) { + totalCount + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }` + ); + + return repository.labels.nodes; +} + +function logAction(message: string, options: ActionOptions) { + const prefix = options.dryRun ? `${pc.gray("[dry-run]")} ` : ""; + console.log(prefix + message); +} + +async function doAction(action: () => Promise, label: string, options: ActionOptions) { + if (!options.dryRun) { + await action(); + } + logAction(label, options); +} +async function createLabels(octokit: Octokit, labels: Label[], options: ActionOptions) { + for (const label of labels) { + await doAction( + () => octokit.rest.issues.createLabel({ ...repo, ...label }), + `Created label ${label.name}, color: ${label.color}, description: ${label.description}`, + options + ); + } +} +async function updateLabels(octokit: Octokit, labels: Label[], options: ActionOptions) { + for (const label of labels) { + await doAction( + () => octokit.rest.issues.updateLabel({ ...repo, ...label }), + `Updated label ${label.name}, color: ${label.color}, description: ${label.description}`, + options + ); + } +} +async function deleteLabels(octokit: Octokit, labels: GithubLabel[], options: ActionOptions) { + checkLabelsToDelete(labels); + + for (const label of labels) { + await doAction( + () => octokit.rest.issues.deleteLabel({ ...repo, name: label.name }), + `Deleted label ${label.name}`, + options + ); + console.log(`Deleted label ${label.name}`); + } +} + +function validateLabel(label: Label) { + if (label.name === undefined) { + throw new Error(`Label missing name: ${inspect(label)}`); + } + if (label.color === undefined) { + throw new Error(`Label missing color: ${inspect(label)}`); + } + if (label.description === undefined) { + throw new Error(`Label missing description: ${inspect(label)}`); + } +} + +async function updateContributingFile(labels: LabelsConfig, options: ActionOptions) { + console.log("Updating contributing file", contributingFile); + const content = await readFile(contributingFile, "utf8"); + const startIndex = content.indexOf(magicComment.start); + const endIndex = content.indexOf(magicComment.end); + if (startIndex === -1) { + throw new Error(`Could not find start comment "${magicComment.start}" in ${contributingFile}`); + } + const start = content.slice(0, startIndex + magicComment.start.length); + const end = + endIndex === -1 + ? magicComment.end + "\n" + content.slice(startIndex + magicComment.start.length) + : content.slice(endIndex); + + const warning = ``; + const newContent = `${start}\n${warning}\n${generateLabelsDoc(labels)}\n${end}`; + const { plugins, ...prettierOptions } = (await resolveConfig(contributingFile)) ?? {}; + const formatted = await format(newContent, { ...prettierOptions, filepath: contributingFile }); + if (options.check) { + if (formatted === content) { + console.log(pc.green("CONTRIBUTING.md is up to date.")); + } else { + console.error( + pc.red( + "CONTRIBUTING.md file label section is not up to date, run pnpm sync-labels to update it" + ) + ); + process.exit(1); + } + } else { + await doAction( + () => writeFile(contributingFile, formatted), + "Updated contributing file", + options + ); + } +} + +function generateLabelsDoc(labels: LabelsConfig) { + return [ + "### Labels reference", + ...labels.categories.map((category) => { + return `#### ${category.name}\n\n${category.description}\n\n${table([ + ["Name", "Color", "Description"], + ...category.labels.map((label) => [ + inlinecode(label.name), + `#${label.color}`, + label.description, + ]), + ])}`; + }), + ].join("\n"); +} + +// #region markdown helpers +export function inlinecode(code: string) { + return "`" + code + "`"; +} +function escapeMarkdownTable(text: string) { + return text.replace(/([^\\])(\|)/g, "$1\\$2").replace(/\n/g, "
"); +} + +function table([header, ...rows]: string[][]) { + const renderRow = (row: string[]): string => `| ${row.map(escapeMarkdownTable).join(" | ")} |`; + + return [ + renderRow(header), + "|" + header.map((x) => "-".repeat(x.length + 2)).join("|") + "|", + ...rows.map(renderRow), + ].join("\n"); +} +// #endregion diff --git a/package.json b/package.json index efc43f266a..8ee92c87e3 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,16 @@ "test:e2e": "pnpm -r run test:e2e", "test": "pnpm -r --aggregate-output --reporter=append-only run test", "update-latest-docs": "pnpm -r run update-latest-docs", - "watch": "tsc --build ./tsconfig.ws.json --watch" + "watch": "tsc --build ./tsconfig.ws.json --watch", + "sync-labels": "tsx ./eng/common/scripts/sync-labels.ts" }, "devDependencies": { "@chronus/chronus": "^0.10.1", "@chronus/github": "^0.3.2", "@eslint/js": "^8.57.0", + "@octokit/core": "^6.1.2", + "@octokit/plugin-paginate-graphql": "^5.2.2", + "@octokit/plugin-rest-endpoint-methods": "^13.2.1", "@pnpm/find-workspace-packages": "^6.0.9", "@types/node": "~18.11.19", "@typescript-eslint/parser": "^7.6.0", @@ -48,13 +52,16 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-unicorn": "^52.0.0", "eslint-plugin-vitest": "^0.5.3", + "picocolors": "~1.0.0", "prettier": "~3.2.5", "prettier-plugin-organize-imports": "~3.2.4", "rimraf": "~5.0.5", "syncpack": "^12.3.0", + "tsx": "^4.9.4", "typescript": "~5.4.5", "typescript-eslint": "^7.6.0", - "vitest": "^1.5.0" + "vitest": "^1.5.0", + "yaml": "~2.4.1" }, "syncpack": { "dependencyTypes": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4c32c6ff8..9a5514be73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,15 @@ importers: '@eslint/js': specifier: ^8.57.0 version: 8.57.0 + '@octokit/core': + specifier: ^6.1.2 + version: 6.1.2 + '@octokit/plugin-paginate-graphql': + specifier: ^5.2.2 + version: 5.2.2(@octokit/core@6.1.2) + '@octokit/plugin-rest-endpoint-methods': + specifier: ^13.2.1 + version: 13.2.1(@octokit/core@6.1.2) '@pnpm/find-workspace-packages': specifier: ^6.0.9 version: 6.0.9(@pnpm/logger@5.0.0) @@ -53,6 +62,9 @@ importers: eslint-plugin-vitest: specifier: ^0.5.3 version: 0.5.3(eslint@8.57.0)(typescript@5.4.5)(vitest@1.5.0) + picocolors: + specifier: ~1.0.0 + version: 1.0.0 prettier: specifier: ~3.2.5 version: 3.2.5 @@ -65,6 +77,9 @@ importers: syncpack: specifier: ^12.3.0 version: 12.3.0(typescript@5.4.5) + tsx: + specifier: ^4.9.4 + version: 4.9.4 typescript: specifier: ~5.4.5 version: 5.4.5 @@ -74,6 +89,9 @@ importers: vitest: specifier: ^1.5.0 version: 1.5.0(@types/node@18.11.19)(@vitest/ui@1.5.0)(happy-dom@14.7.1) + yaml: + specifier: ~2.4.1 + version: 2.4.1 e2e: {} @@ -3169,7 +3187,7 @@ packages: dependencies: '@actions/github': 6.0.0 '@chronus/chronus': 0.10.0 - '@octokit/graphql': 8.1.0 + '@octokit/graphql': 8.1.1 cross-spawn: 7.0.3 octokit: 3.2.0 picocolors: 1.0.0 @@ -6385,6 +6403,11 @@ packages: engines: {node: '>= 18'} dev: true + /@octokit/auth-token@5.1.1: + resolution: {integrity: sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==} + engines: {node: '>= 18'} + dev: true + /@octokit/auth-unauthenticated@5.0.1: resolution: {integrity: sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==} engines: {node: '>= 18'} @@ -6406,6 +6429,19 @@ packages: universal-user-agent: 6.0.1 dev: true + /@octokit/core@6.1.2: + resolution: {integrity: sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-token': 5.1.1 + '@octokit/graphql': 8.1.1 + '@octokit/request': 9.0.1 + '@octokit/request-error': 6.0.2 + '@octokit/types': 13.5.0 + before-after-hook: 3.0.2 + universal-user-agent: 7.0.2 + dev: true + /@octokit/endpoint@10.0.0: resolution: {integrity: sha512-emBcNDxBdC1y3+knJonS5zhUB/CG6TihubxM2U1/pG/Z1y3a4oV0Gzz3lmkCvWWQI6h3tqBAX9MgCBFp+M68Jw==} engines: {node: '>= 18'} @@ -6431,12 +6467,12 @@ packages: universal-user-agent: 6.0.1 dev: true - /@octokit/graphql@8.1.0: - resolution: {integrity: sha512-XDvj6GcUnQYgbCLXElt3vZDzNIPGvGiwxQO2XzsvfVUjebGh0E5eCD/1My9zUGSNKaGVZitVuO8LMziGmoFryg==} + /@octokit/graphql@8.1.1: + resolution: {integrity: sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==} engines: {node: '>= 18'} dependencies: '@octokit/request': 9.0.1 - '@octokit/types': 13.4.0 + '@octokit/types': 13.5.0 universal-user-agent: 7.0.2 dev: true @@ -6474,8 +6510,8 @@ packages: resolution: {integrity: sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==} dev: true - /@octokit/openapi-types@22.0.1: - resolution: {integrity: sha512-1yN5m1IMNXthoBDUXFF97N1gHop04B3H8ws7wtOr8GgRyDO1gKALjwMHARNBoMBiB/2vEe/vxstrApcJZzQbnQ==} + /@octokit/openapi-types@22.2.0: + resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} dev: true /@octokit/plugin-paginate-graphql@4.0.1(@octokit/core@5.1.0): @@ -6487,6 +6523,15 @@ packages: '@octokit/core': 5.1.0 dev: true + /@octokit/plugin-paginate-graphql@5.2.2(@octokit/core@6.1.2): + resolution: {integrity: sha512-7znSVvlNAOJisCqAnjN1FtEziweOHSjPGAuc5W58NeGNAr/ZB57yCsjQbXDlWsVryA7hHQaEQPcBbJYFawlkyg==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + dependencies: + '@octokit/core': 6.1.2 + dev: true + /@octokit/plugin-paginate-rest@9.2.1(@octokit/core@5.1.0): resolution: {integrity: sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==} engines: {node: '>= 18'} @@ -6507,6 +6552,16 @@ packages: '@octokit/types': 12.6.0 dev: true + /@octokit/plugin-rest-endpoint-methods@13.2.1(@octokit/core@6.1.2): + resolution: {integrity: sha512-YMWBw6Exh1ZBs5cCE0AnzYxSQDIJS00VlBqISTgNYmu5MBdeM07K/MAJjy/VkNaH5jpJmD/5HFUvIZ+LDB5jSQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + dependencies: + '@octokit/core': 6.1.2 + '@octokit/types': 13.5.0 + dev: true + /@octokit/plugin-retry@6.0.1(@octokit/core@5.1.0): resolution: {integrity: sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==} engines: {node: '>= 18'} @@ -6572,10 +6627,10 @@ packages: '@octokit/openapi-types': 20.0.0 dev: true - /@octokit/types@13.4.0: - resolution: {integrity: sha512-WlMegy3lPXYWASe3k9Jslc5a0anrYAYMWtsFrxBTdQjS70hvLH6C+PGvHbOsgy3RA3LouGJoU/vAt4KarecQLQ==} + /@octokit/types@13.5.0: + resolution: {integrity: sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==} dependencies: - '@octokit/openapi-types': 22.0.1 + '@octokit/openapi-types': 22.2.0 dev: true /@octokit/webhooks-methods@4.1.0: @@ -8919,6 +8974,10 @@ packages: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} dev: true + /before-after-hook@3.0.2: + resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + dev: true + /better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -11873,6 +11932,12 @@ packages: get-intrinsic: 1.2.4 dev: true + /get-tsconfig@4.7.5: + resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + /github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} requiresBuild: true @@ -16644,6 +16709,10 @@ packages: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} dev: false + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -17890,6 +17959,17 @@ packages: typescript: 5.4.5 dev: true + /tsx@4.9.4: + resolution: {integrity: sha512-TlSJTVn2taGGDgdV3jAqCj7WQ/CafCB5p4SbG7W2Bl/0AJWH1ShJlBbc0y2lOFTjQEVAAULSTlmehw/Mwv3S/Q==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.20.2 + get-tsconfig: 4.7.5 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /tuf-js@2.2.0: resolution: {integrity: sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg==} engines: {node: ^16.14.0 || >=18.0.0}