Make the sync label logic reusable for typespec-azure repo (#3425)

To allow this https://github.com/Azure/typespec-azure/pull/903
This commit is contained in:
Timothee Guerin 2024-05-23 10:01:14 -07:00 коммит произвёл GitHub
Родитель 410705935e
Коммит 5698be15fe
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
12 изменённых файлов: 267 добавлений и 194 удалений

2
.github/policies/issues.needs-info.yml поставляемый
Просмотреть файл

@ -15,8 +15,6 @@ configuration:
filters:
- isIssue
- isOpen
- hasLabel:
label: No-Recent-Activity
- hasLabel:
label: needs-info
- noActivitySince:

Просмотреть файл

@ -6,8 +6,6 @@
**/obj/
**/.vs/
**/.docusaurus/
common/temp/
common/scripts/
.chronus/changes/
# Pnpm lock file

Просмотреть файл

@ -297,17 +297,6 @@ TypeSpec repo use labels to help categorize and manage issues and PRs. The follo
### Labels reference
#### issue_kinds
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
@ -332,6 +321,17 @@ Area of the codebase
| `emitter:service:js` | #967200 | |
| `eng` | #65bfff | |
#### issue_kinds
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 | |
#### breaking-change
Labels around annotating issues and PR if they contain breaking change or deprecation

Просмотреть файл

@ -1,24 +0,0 @@
import { type AreaLabels } from "./labels.js";
/**
* Set the paths that each area applies to.
*/
export const AreaPaths: Record<keyof typeof AreaLabels, string[]> = {
"compiler:core": ["packages/compiler/"],
"compiler:emitter-framework": [],
ide: ["packages/typespec-vscode/", "packages/typespec-vs/"],
"lib:http": ["packages/http/"],
"lib:openapi": ["packages/openapi/"],
"lib:rest": ["packages/rest/"],
"lib:versioning": ["packages/versioning/"],
"meta:blog": ["blog/"],
"meta:website": ["website/"],
tspd: ["packages/tspd/"],
"emitter:client:csharp": ["packages/http-client-csharp/"],
"emitter:json-schema": ["packages/json-schema/"],
"emitter:protobuf": ["packages/protobuf/"],
"emitter:openapi3": ["packages/openapi3/"],
"emitter:service:csharp": [],
"emitter:service:js": [],
eng: ["eng/", ".github/"],
};

Просмотреть файл

@ -1,4 +1,6 @@
// cspell:ignore bfff
import { repo } from "../scripts/common.js";
import { defineConfig, defineLabels } from "../scripts/labels/config.js";
/**
* Labels that are used to categorize issue for which area they belong to.
@ -74,10 +76,10 @@ export const AreaLabels = defineLabels({
},
});
export default {
export const CommonLabels = {
issue_kinds: {
description: "Issue kinds",
labels: {
labels: defineLabels({
bug: {
color: "d93f0b",
description: "Something isn't working",
@ -94,11 +96,7 @@ export default {
color: "cccccc",
description: "",
},
},
},
area: {
description: "Area of the codebase",
labels: AreaLabels,
}),
},
"breaking-change": {
description:
@ -150,23 +148,52 @@ export default {
},
},
},
misc: {
description: "Misc labels",
labels: {
"Client Emitter Migration": {
color: "FD92F0",
description: "",
},
"good first issue": {
color: "7057ff",
description: "Good for newcomers",
};
/**
* Set the paths that each area applies to.
*/
export const AreaPaths: Record<keyof typeof AreaLabels, string[]> = {
"compiler:core": ["packages/compiler/"],
"compiler:emitter-framework": [],
ide: ["packages/typespec-vscode/", "packages/typespec-vs/"],
"lib:http": ["packages/http/"],
"lib:openapi": ["packages/openapi/"],
"lib:rest": ["packages/rest/"],
"lib:versioning": ["packages/versioning/"],
"meta:blog": ["blog/"],
"meta:website": ["website/"],
tspd: ["packages/tspd/"],
"emitter:client:csharp": ["packages/http-client-csharp/"],
"emitter:json-schema": ["packages/json-schema/"],
"emitter:protobuf": ["packages/protobuf/"],
"emitter:openapi3": ["packages/openapi3/"],
"emitter:service:csharp": [],
"emitter:service:js": [],
eng: ["eng/", ".github/"],
};
export default defineConfig({
repo,
labels: {
area: {
description: "Area of the codebase",
labels: AreaLabels,
},
...CommonLabels,
misc: {
description: "Misc labels",
labels: {
"Client Emitter Migration": {
color: "FD92F0",
description: "",
},
"good first issue": {
color: "7057ff",
description: "Good for newcomers",
},
},
},
},
} as const;
function defineLabels<const T extends string>(
labels: Record<T, { color: string; description: string }>
) {
return labels;
}
areaPaths: AreaPaths,
});

Просмотреть файл

@ -1,8 +1,6 @@
import { resolve } from "path";
import { stringify } from "yaml";
import { AreaPaths } from "../../config/areas.js";
import { AreaLabels } from "../../config/labels.js";
import { CheckOptions, repoRoot, syncFile } from "../common.js";
import { CheckOptions, syncFile } from "../common.js";
import {
PolicyServiceConfig,
and,
@ -16,14 +14,15 @@ import {
or,
payloadType,
} from "./policy.js";
import type { RepoConfig } from "./types.js";
const policyFolder = resolve(repoRoot, ".github", "policies");
const policyFolder = resolve(process.cwd(), ".github", "policies");
export interface SyncLabelAutomationOptions extends CheckOptions {}
export async function syncLabelAutomation(options: SyncLabelAutomationOptions) {
await syncPolicyFile(issueTriageConfig, options);
await syncPolicyFile(prTriageConfig, options);
export async function syncLabelAutomation(config: RepoConfig, options: SyncLabelAutomationOptions) {
await syncPolicyFile(createIssueTriageConfig(config), options);
await syncPolicyFile(createPrTriageConfig(config), options);
}
async function syncPolicyFile(policy: PolicyServiceConfig, options: CheckOptions) {
@ -33,96 +32,100 @@ async function syncPolicyFile(policy: PolicyServiceConfig, options: CheckOptions
await syncFile(filename, content, options);
}
const issueTriageConfig: PolicyServiceConfig = {
id: "issues.triage",
name: "New Issue Assign labels",
description: "Assign labels to new issues",
resource: "repository",
disabled: false,
configuration: {
resourceManagementConfiguration: {
eventResponderTasks: [
eventResponderTask({
description: "Adds `needs-area` label for new unassigned issues",
if: [
payloadType("Issues"),
isAction("Opened"),
not(and(["isAssignedToSomeone"])),
not(or(Object.keys(AreaLabels).map((area) => hasLabel(area)))),
],
then: [
{
addLabel: {
label: "needs-area",
function createIssueTriageConfig(config: RepoConfig): PolicyServiceConfig {
const areaLabels = config.labels.area.labels;
return {
id: "issues.triage",
name: "New Issue Assign labels",
description: "Assign labels to new issues",
resource: "repository",
disabled: false,
configuration: {
resourceManagementConfiguration: {
eventResponderTasks: [
eventResponderTask({
description: "Adds `needs-area` label for new unassigned issues",
if: [
payloadType("Issues"),
isAction("Opened"),
not(and(["isAssignedToSomeone"])),
not(or(Object.keys(areaLabels).map((area) => hasLabel(area)))),
],
then: [
{
addLabel: {
label: "needs-area",
},
},
},
],
}),
eventResponderTask({
description: "Remove `needs-area` label when an area label is added",
if: [
payloadType("Issues"),
hasLabel("needs-area"),
"isOpen",
or(Object.keys(AreaLabels).map((area) => labelAdded(area))),
],
then: [
{
removeLabel: {
label: "needs-area",
},
},
],
}),
eventResponderTask({
description: "Add `needs-area` back when all area labels are removed",
if: [
payloadType("Issues"),
not(hasLabel("needs-area")),
"isOpen",
or(Object.keys(AreaLabels).map((area) => labelRemoved(area))),
not(or(Object.keys(AreaLabels).map((area) => hasLabel(area)))),
],
then: [
{
addLabel: {
label: "needs-area",
},
},
],
}),
],
},
},
};
const prTriageConfig: PolicyServiceConfig = {
id: "prs.triage",
name: "Assign area labels to PRs",
description: "Assign area labels to PR depending on path modified.",
resource: "repository",
disabled: false,
configuration: {
resourceManagementConfiguration: {
eventResponderTasks: [
eventResponderTask({
if: [payloadType("Pull_Request")],
then: Object.entries(AreaPaths).flatMap(([label, files]) => {
return files.map((file) => {
return {
if: [filesMatchPattern(`${file}.*`)],
then: [
{
addLabel: {
label,
},
},
],
};
});
],
}),
}),
],
eventResponderTask({
description: "Remove `needs-area` label when an area label is added",
if: [
payloadType("Issues"),
hasLabel("needs-area"),
"isOpen",
or(Object.keys(areaLabels).map((area) => labelAdded(area))),
],
then: [
{
removeLabel: {
label: "needs-area",
},
},
],
}),
eventResponderTask({
description: "Add `needs-area` back when all area labels are removed",
if: [
payloadType("Issues"),
not(hasLabel("needs-area")),
"isOpen",
or(Object.keys(areaLabels).map((area) => labelRemoved(area))),
not(or(Object.keys(areaLabels).map((area) => hasLabel(area)))),
],
then: [
{
addLabel: {
label: "needs-area",
},
},
],
}),
],
},
},
},
};
};
}
function createPrTriageConfig(config: RepoConfig): PolicyServiceConfig {
return {
id: "prs.triage",
name: "Assign area labels to PRs",
description: "Assign area labels to PR depending on path modified.",
resource: "repository",
disabled: false,
configuration: {
resourceManagementConfiguration: {
eventResponderTasks: [
eventResponderTask({
if: [payloadType("Pull_Request")],
then: Object.entries(config.areaPaths).flatMap(([label, files]) => {
return files.map((file) => {
return {
if: [filesMatchPattern(`${file}.*`)],
then: [
{
addLabel: {
label,
},
},
],
};
});
}),
}),
],
},
},
};
}

Просмотреть файл

@ -0,0 +1,11 @@
import { RepoConfig } from "./types.js";
export function defineConfig(config: RepoConfig) {
return config;
}
export function defineLabels<const T extends string>(
labels: Record<T, { color: string; description: string }>
) {
return labels;
}

Просмотреть файл

@ -6,14 +6,13 @@ import { resolve } from "path";
import pc from "picocolors";
import { format, resolveConfig } from "prettier";
import { inspect } from "util";
import rawLabels from "../../config/labels.js";
import { repo, repoRoot } from "../common.js";
import { RepoConfig } from "./types.js";
const Octokit = OctokitCore.plugin(paginateGraphQL).plugin(restEndpointMethods);
type Octokit = InstanceType<typeof Octokit>;
const labelFileRelative = "eng/common/config/labels.ts";
const contributingFile = resolve(repoRoot, "CONTRIBUTING.md");
const contributingFile = resolve(process.cwd(), "CONTRIBUTING.md");
const magicComment = {
start: "<!-- LABEL GENERATED REF START -->",
end: "<!-- LABEL GENERATED REF END -->",
@ -25,12 +24,12 @@ export interface SyncLabelsOptions {
readonly dryRun?: boolean;
}
export async function syncLabelsDefinitions(options: SyncLabelsOptions = {}) {
const labels = loadLabels();
export async function syncLabelsDefinitions(config: RepoConfig, options: SyncLabelsOptions = {}) {
const labels = loadLabels(config);
logLabelConfig(labels);
if (options.github) {
await syncGithubLabels(labels.labels, {
await syncGithubLabels(config, labels.labels, {
dryRun: options.dryRun,
check: options.check,
});
@ -42,7 +41,7 @@ export async function syncLabelsDefinitions(options: SyncLabelsOptions = {}) {
});
}
interface LabelsConfig {
interface LabelsResolvedConfig {
readonly categories: LabelCategory[];
readonly labels: Label[];
}
@ -64,8 +63,8 @@ interface ActionOptions {
readonly check?: boolean;
}
function loadLabels(): LabelsConfig {
const data = rawLabels;
function loadLabels(config: RepoConfig): LabelsResolvedConfig {
const data = config.labels;
const labels = [];
const categories: LabelCategory[] = [];
for (const [categoryName, { description, labels: labelMap }] of Object.entries(data)) {
@ -80,7 +79,7 @@ function loadLabels(): LabelsConfig {
return { labels, categories };
}
function logLabelConfig(config: LabelsConfig) {
function logLabelConfig(config: LabelsResolvedConfig) {
console.log("Label config:");
const max = config.labels.reduce((max, label) => Math.max(max, label.name.length), 0);
for (const category of config.categories) {
@ -110,7 +109,7 @@ 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 = {}) {
async function syncGithubLabels(config: RepoConfig, 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."
@ -120,8 +119,8 @@ async function syncGithubLabels(labels: Label[], options: ActionOptions = {}) {
process.env.GITHUB_TOKEN ? { auth: `token ${process.env.GITHUB_TOKEN}` } : {}
);
const existingLabels = await fetchAllLabels(octokit);
logLabels("Existing github labels", existingLabels as any);
const existingLabels = await fetchAllLabels(octokit, config.repo);
logLabels("Existing github labels", existingLabels);
const labelToUpdate: Label[] = [];
const labelsToCreate: Label[] = [];
const exitingLabelMap = new Map(existingLabels.map((label) => [label.name, label]));
@ -148,9 +147,9 @@ async function syncGithubLabels(labels: Label[], options: ActionOptions = {}) {
}
} else {
logAction("Applying changes", options);
await updateLabels(octokit, labelToUpdate, options);
await createLabels(octokit, labelsToCreate, options);
await deleteLabels(octokit, labelsToDelete, options);
await updateLabels(config, octokit, labelToUpdate, options);
await createLabels(config, octokit, labelsToCreate, options);
await deleteLabels(config, octokit, labelsToDelete, options);
logAction("Done applying changes", options);
}
}
@ -160,7 +159,7 @@ async function checkLabelsToDelete(labels: GithubLabel[]) {
let hasError = false;
for (const label of labels) {
if (label.issues.totalCount > 0) {
console.error(
console.log(
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.`
)
@ -171,7 +170,7 @@ async function checkLabelsToDelete(labels: GithubLabel[]) {
if (hasError) {
process.exit(1);
} else {
console.error(pc.green(`Labels looks good to delete.`));
console.log(pc.green(`Labels looks good to delete.`));
}
}
@ -181,10 +180,10 @@ interface GithubLabel {
readonly description: string;
readonly issues: { readonly totalCount: number };
}
async function fetchAllLabels(octokit: Octokit): Promise<GithubLabel[]> {
async function fetchAllLabels(octokit: Octokit, repo: RepoConfig["repo"]): Promise<GithubLabel[]> {
const { repository } = await octokit.graphql.paginate(
`query paginate($cursor: String) {
repository(owner: "Microsoft", name: "typespec") {
`query paginate($cursor: String, $owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
labels(first: 100, after: $cursor) {
nodes {
color
@ -200,7 +199,11 @@ async function fetchAllLabels(octokit: Octokit): Promise<GithubLabel[]> {
}
}
}
}`
}`,
{
owner: repo.owner,
repo: repo.repo,
}
);
return repository.labels.nodes;
@ -217,30 +220,45 @@ async function doAction(action: () => Promise<unknown>, label: string, options:
}
logAction(label, options);
}
async function createLabels(octokit: Octokit, labels: Label[], options: ActionOptions) {
async function createLabels(
config: RepoConfig,
octokit: Octokit,
labels: Label[],
options: ActionOptions
) {
for (const label of labels) {
await doAction(
() => octokit.rest.issues.createLabel({ ...repo, ...label }),
() => octokit.rest.issues.createLabel({ ...config.repo, ...label }),
`Created label ${label.name}, color: ${label.color}, description: ${label.description}`,
options
);
}
}
async function updateLabels(octokit: Octokit, labels: Label[], options: ActionOptions) {
async function updateLabels(
config: RepoConfig,
octokit: Octokit,
labels: Label[],
options: ActionOptions
) {
for (const label of labels) {
await doAction(
() => octokit.rest.issues.updateLabel({ ...repo, ...label }),
() => octokit.rest.issues.updateLabel({ ...config.repo, ...label }),
`Updated label ${label.name}, color: ${label.color}, description: ${label.description}`,
options
);
}
}
async function deleteLabels(octokit: Octokit, labels: GithubLabel[], options: ActionOptions) {
async function deleteLabels(
config: RepoConfig,
octokit: Octokit,
labels: GithubLabel[],
options: ActionOptions
) {
checkLabelsToDelete(labels);
for (const label of labels) {
await doAction(
() => octokit.rest.issues.deleteLabel({ ...repo, name: label.name }),
() => octokit.rest.issues.deleteLabel({ ...config.repo, name: label.name }),
`Deleted label ${label.name}`,
options
);
@ -260,7 +278,7 @@ function validateLabel(label: Label) {
}
}
async function updateContributingFile(labels: LabelsConfig, options: ActionOptions) {
async function updateContributingFile(labels: LabelsResolvedConfig, options: ActionOptions) {
console.log("Updating contributing file", contributingFile);
const content = await readFile(contributingFile, "utf8");
const startIndex = content.indexOf(magicComment.start);
@ -282,7 +300,7 @@ async function updateContributingFile(labels: LabelsConfig, options: ActionOptio
if (formatted === content) {
console.log(pc.green("CONTRIBUTING.md is up to date."));
} else {
console.error(
console.log(
pc.red(
"CONTRIBUTING.md file label section is not up to date, run pnpm sync-labels to update it"
)
@ -298,7 +316,7 @@ async function updateContributingFile(labels: LabelsConfig, options: ActionOptio
}
}
function generateLabelsDoc(labels: LabelsConfig) {
function generateLabelsDoc(labels: LabelsResolvedConfig) {
return [
"### Labels reference",
...labels.categories.map((category) => {

Просмотреть файл

@ -1,10 +1,16 @@
import { resolve } from "path";
import { parseArgs } from "util";
import { syncLabelAutomation } from "./automation.js";
import { syncLabelsDefinitions } from "./definitions.js";
import { RepoConfig } from "./types.js";
const options = parseArgs({
args: process.argv.slice(2),
options: {
config: {
type: "string",
description: "The directory where the labels configuration is stored.",
},
"dry-run": {
type: "boolean",
description: "Do not make any changes, log what action would be taken.",
@ -17,12 +23,23 @@ const options = parseArgs({
},
});
await syncLabelsDefinitions({
if (!options.values["config"]) {
throw new Error("--config is required");
}
const config = await loadConfig(options.values["config"]);
await syncLabelsDefinitions(config, {
check: options.values["check"],
dryRun: options.values["dry-run"],
github: options.values["github"],
});
await syncLabelAutomation({
await syncLabelAutomation(config, {
check: options.values["check"],
});
async function loadConfig(configFile: string): Promise<RepoConfig> {
const module = await import(resolve(process.cwd(), configFile));
return module.default;
}

Просмотреть файл

@ -0,0 +1,18 @@
export interface RepoConfig {
repo: {
owner: string;
repo: string;
};
labels: LabelsConfig;
areaPaths: Record<string, string[]>;
}
export interface LabelsConfig {
area: LabelCategory;
[key: string]: LabelCategory;
}
export interface LabelCategory {
description: string;
labels: Record<string, { color: string; description: string }>;
}

7
eng/tsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["**/*"]
}

Просмотреть файл

@ -33,7 +33,7 @@
"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",
"sync-labels": "tsx ./eng/common/scripts/labels/sync-labels.ts"
"sync-labels": "tsx ./eng/common/scripts/labels/sync-labels.ts --config ./eng/common/config/labels.ts"
},
"devDependencies": {
"@chronus/chronus": "^0.10.2",