From 5fb0508236856e570e6b847c911bc8f67e95432b Mon Sep 17 00:00:00 2001 From: Daan Klarenbeek Date: Sun, 25 Jun 2023 12:27:11 +0200 Subject: [PATCH] feat: add label syncing --- apps/github-bot/package.json | 1 + .../src/events/LabelSync/LabelSyncUtils.ts | 50 ++++++++++++++ .../src/events/LabelSync/PullRequestEvent.ts | 40 +++++++++++ .../src/events/LabelSync/PushEvent.ts | 67 +++++++++++++++++++ yarn.lock | 3 +- 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 apps/github-bot/src/events/LabelSync/LabelSyncUtils.ts create mode 100644 apps/github-bot/src/events/LabelSync/PullRequestEvent.ts create mode 100644 apps/github-bot/src/events/LabelSync/PushEvent.ts diff --git a/apps/github-bot/package.json b/apps/github-bot/package.json index 940a3d4d..5de7f001 100644 --- a/apps/github-bot/package.json +++ b/apps/github-bot/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@ijsblokje/octocat": "*", + "@ijsblokje/release": "*", "@ijsblokje/server": "*", "@ijsblokje/utils": "*", "dotenv": "16.3.1" diff --git a/apps/github-bot/src/events/LabelSync/LabelSyncUtils.ts b/apps/github-bot/src/events/LabelSync/LabelSyncUtils.ts new file mode 100644 index 00000000..ed030430 --- /dev/null +++ b/apps/github-bot/src/events/LabelSync/LabelSyncUtils.ts @@ -0,0 +1,50 @@ +import type { Octokit } from "@ijsblokje/octokit"; + +/** + * Deletes a label and handles rejections if any + * @param octokit The authenticated octokit instance + * @param name The name of the label + * @param owner The owner of the repository + * @param repo The name of the repository + */ +export async function DeleteLabel(octokit: Octokit, name: string, owner: string, repo: string) { + try { + await octokit.request("DELETE /repos/{owner}/{repo}/labels/{name}", { name, owner, repo }); + } catch (error) { + octokit.logger.warn(`[DeleteLabel]: Unable to delete label with name ${name} (repository: ${owner}/${repo}) `, error); + } +} + +/** + * Creates a new label and handles rejections if any + * @param octokit The authenticated octokit instance + * @param name The label name + * @param color The label color + * @param description The label description + * @param owner The owner of the repository + * @param repo The repository name + */ +export async function CreateLabel(octokit: Octokit, name: string, color: string, description: string, owner: string, repo: string) { + try { + await octokit.request("POST /repos/{owner}/{repo}/labels", { name, owner, repo, color: color.replace("#", ""), description }); + } catch (error) { + octokit.logger.warn(`[DeleteLabel]: Unable to create label with name ${name} (repository: ${owner}/${repo}) `, error); + } +} + +/** + * Updates a label and handles rejections if any + * @param octokit The authenticated octokit instance + * @param name The label name + * @param color The label color + * @param description The label description + * @param owner The owner of the repository + * @param repo The repository name + */ +export async function UpdateLabel(octokit: Octokit, name: string, color: string, description: string, owner: string, repo: string) { + try { + await octokit.request("POST /repos/{owner}/{repo}/labels", { name, owner, repo, color: color.replace("#", ""), description }); + } catch (error) { + octokit.logger.warn(`[DeleteLabel]: Unable to update label with name ${name} (repository: ${owner}/${repo}) `, error); + } +} diff --git a/apps/github-bot/src/events/LabelSync/PullRequestEvent.ts b/apps/github-bot/src/events/LabelSync/PullRequestEvent.ts new file mode 100644 index 00000000..84208e67 --- /dev/null +++ b/apps/github-bot/src/events/LabelSync/PullRequestEvent.ts @@ -0,0 +1,40 @@ +import { ApplyOptions, GitHubEvent, GitHubInstallation } from "@ijsblokje/octocat"; +import type { Octokit } from "@ijsblokje/octokit"; +import type { EmitterWebhookEvent } from "@ijsblokje/server"; +import { Commit } from "@ijsblokje/release"; + +@ApplyOptions({ event: "pull_request" }) +export default class extends GitHubEvent { + public override async run( + event: EmitterWebhookEvent<"pull_request.opened" | "pull_request.edited">, + octokit: Octokit, + installation?: GitHubInstallation + ) { + const parsedTitle = new Commit({ commit: { message: event.payload.pull_request.title } } as any).parse(); + if (!parsedTitle || !installation) return; + + const defaultLabels = installation.defaultLabels.filter((label) => label.name.toLowerCase().includes(parsedTitle.type)); + const repoLabels = (installation.labels.get(event.payload.repository.name) ?? []).filter((label) => + label.name.toLowerCase().includes(parsedTitle.type) + ); + const currentLabels = (event.payload.pull_request.labels ?? []).filter((label) => label.name.toLowerCase().includes("merge")); + + if (event.payload.sender.login === "renovate[bot]") { + const dependencyLabel = installation.defaultLabels.find((label) => label.name.toLowerCase().includes("dependencies")); + if (dependencyLabel) defaultLabels.push(dependencyLabel); + } + + await octokit + .request("PUT /repos/{owner}/{repo}/issues/{issue_number}/labels", { + issue_number: event.payload.pull_request.number, + owner: installation.name, + repo: event.payload.repository.name, + labels: [ + ...defaultLabels.map((label) => label.name), + ...repoLabels.map((label) => label.name), + ...currentLabels.map((label) => label.name) + ] + }) + .catch(() => void 0); + } +} diff --git a/apps/github-bot/src/events/LabelSync/PushEvent.ts b/apps/github-bot/src/events/LabelSync/PushEvent.ts new file mode 100644 index 00000000..f929efba --- /dev/null +++ b/apps/github-bot/src/events/LabelSync/PushEvent.ts @@ -0,0 +1,67 @@ +import { ApplyOptions, GitHubEvent, GitHubInstallation } from "@ijsblokje/octocat"; +import type { Octokit } from "@ijsblokje/octokit"; +import type { EmitterWebhookEvent } from "@ijsblokje/server"; +import { LABEL_CONFIG_LOCATION } from "@ijsblokje/utils/constants.js"; +import type { Label } from "@ijsblokje/utils/types.js"; +import _ from "lodash"; +import { CreateLabel, DeleteLabel, UpdateLabel } from "./LabelSyncUtils.js"; + +@ApplyOptions({ event: "push" }) +export default class extends GitHubEvent { + public override async run(event: EmitterWebhookEvent<"push">, octokit: Octokit, installation?: GitHubInstallation) { + if (!installation || !installation.defaultLabels) return; + if (event.payload.repository.name !== (installation.isUser ? installation!.name : ".github")) return; + + if (event.payload.commits.some((commit) => commit.removed.includes(LABEL_CONFIG_LOCATION))) { + installation!.defaultLabels = []; + installation!.labels.clear(); + + return; + } + + if (!event.payload.commits.some((commit) => [...commit.added, ...commit.modified].includes(LABEL_CONFIG_LOCATION))) return; + + const labelConfig = await this.getLabelConfig(octokit, installation.name, event.payload.repository.name); + if (!labelConfig) return; + + const owner = installation.name; + installation.updateLabels(Buffer.from(labelConfig, "base64").toString()); + + for (const repo of installation.configs.keys()) { + const labels = [...installation.defaultLabels, ...(installation.labels.get(repo) ?? [])]; + const existingLbs = await octokit.request("GET /repos/{owner}/{repo}/labels", { owner, repo }).catch(() => ({ data: [] })); + const existingLabels: Label[] = existingLbs.data.map((label) => ({ + name: label.name, + description: label.description ?? "", + color: `#${label.color}` + })); + + const addLabels = labels.filter((label) => !existingLabels.find((l) => l.name === label.name)); // Labels that have to be created + const removeLabels = existingLabels.filter((label) => !labels.find((l) => l.name === label.name)); // Labels that have to be deleted + const commonLabels = labels.filter((label) => { + const cLabel = existingLabels.find((l) => l.name === label.name); + if (!cLabel) return false; + return !_.isEqual(cLabel, label); + }); // Labels that have to be updated + + await Promise.allSettled(removeLabels.map((label) => DeleteLabel(octokit, label.name, owner, repo))); + await Promise.allSettled(addLabels.map((label) => CreateLabel(octokit, label.name, label.color, label.description, owner, repo))); + await Promise.allSettled(commonLabels.map((label) => UpdateLabel(octokit, label.name, label.color, label.description, owner, repo))); + } + } + + private async getLabelConfig(octokit: Octokit, owner: string, repo: string) { + try { + const response = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { + owner, + repo, + path: LABEL_CONFIG_LOCATION + }); + + return "content" in response.data ? response.data.content : null; + } catch (error) { + octokit.logger.error(`[LabelSync(PushEvent)]: Failed to fetch label config content from ${owner}/${repo}`, error); + return null; + } + } +} diff --git a/yarn.lock b/yarn.lock index d4b47417..56ffcbb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -549,7 +549,7 @@ __metadata: languageName: unknown linkType: soft -"@ijsblokje/release@workspace:packages/release": +"@ijsblokje/release@*, @ijsblokje/release@workspace:packages/release": version: 0.0.0-use.local resolution: "@ijsblokje/release@workspace:packages/release" dependencies: @@ -3228,6 +3228,7 @@ __metadata: resolution: "github-bot@workspace:apps/github-bot" dependencies: "@ijsblokje/octocat": "*" + "@ijsblokje/release": "*" "@ijsblokje/server": "*" "@ijsblokje/utils": "*" "@types/node": ^18.16.18