From 425972d1ba602ac279461f7fefd05c1eba30ce1c Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Mon, 8 Jun 2026 04:31:11 +0200 Subject: [PATCH] Handle repo label rename/delete --- .../das/src/webhook/handlers/label.handler.ts | 252 +++++++++++++++++- packages/das/src/webhook/webhook.service.ts | 2 +- 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/packages/das/src/webhook/handlers/label.handler.ts b/packages/das/src/webhook/handlers/label.handler.ts index ab813bb..18f09e2 100644 --- a/packages/das/src/webhook/handlers/label.handler.ts +++ b/packages/das/src/webhook/handlers/label.handler.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { DataSource, Repository } from "typeorm"; import { LabelEvent, Issue, PullRequest } from "../../entities"; @Injectable() @@ -13,6 +13,7 @@ export class LabelHandler { private readonly issueRepo: Repository, @InjectRepository(PullRequest) private readonly prRepo: Repository, + private readonly dataSource: DataSource, ) {} /** @@ -74,4 +75,253 @@ export class LabelHandler { ); } } + + /** + * Called for repository-level label.deleted and label.edited events. + * GitHub applies these transitions repo-wide, so mirror the current + * target-level label state with per-target events for the scoring views. + */ + async handleRepoLabel(payload: Record): Promise { + const action = payload.action; + if (action !== "deleted" && action !== "edited") return; + + const repoFullName: string | undefined = payload.repository?.full_name; + const labelName: string | undefined = payload.label?.name; + if (!repoFullName || !labelName) return; + + const sender = payload.sender; + const actorGithubId = sender?.id != null ? String(sender.id) : null; + const actorLogin = sender?.login ?? null; + const timestamp = new Date().toISOString(); + + if (action === "deleted") { + await this.removeRepoLabel( + repoFullName, + labelName, + actorGithubId, + actorLogin, + timestamp, + ); + return; + } + + const oldName: string | undefined = payload.changes?.name?.from; + if (!oldName || oldName === labelName) return; + + await this.renameRepoLabel( + repoFullName, + oldName, + labelName, + actorGithubId, + actorLogin, + timestamp, + ); + } + + private async removeRepoLabel( + repoFullName: string, + labelName: string, + actorGithubId: string | null, + actorLogin: string | null, + timestamp: string, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await manager.query( + ` + WITH latest_events AS ( + SELECT DISTINCT ON ( + le.repo_full_name, + le.target_number, + le.target_type, + le.label_name + ) + le.repo_full_name, + le.target_number, + le.target_type, + le.action + FROM label_events le + WHERE le.repo_full_name = $1 + AND le.label_name = $2 + AND le.target_type IN ('pr', 'issue') + ORDER BY + le.repo_full_name, + le.target_number, + le.target_type, + le.label_name, + le.timestamp DESC + ), + current_targets AS ( + SELECT repo_full_name, target_number, target_type + FROM latest_events + WHERE action = 'labeled' + ) + INSERT INTO label_events ( + repo_full_name, + target_number, + target_type, + label_name, + action, + actor_github_id, + actor_login, + timestamp + ) + SELECT + repo_full_name, + target_number, + target_type, + $2::varchar, + 'unlabeled', + $3::varchar, + $4::varchar, + $5::timestamptz + FROM current_targets + ON CONFLICT DO NOTHING + `, + [repoFullName, labelName, actorGithubId, actorLogin, timestamp], + ); + + await manager.query( + ` + UPDATE pull_requests + SET labels = array_remove(labels, $2) + WHERE repo_full_name = $1 + AND $2 = ANY(labels) + `, + [repoFullName, labelName], + ); + + await manager.query( + ` + UPDATE issues + SET labels = array_remove(labels, $2) + WHERE repo_full_name = $1 + AND $2 = ANY(labels) + `, + [repoFullName, labelName], + ); + }); + } + + private async renameRepoLabel( + repoFullName: string, + oldName: string, + newName: string, + actorGithubId: string | null, + actorLogin: string | null, + timestamp: string, + ): Promise { + await this.dataSource.transaction(async (manager) => { + await manager.query( + ` + WITH latest_events AS ( + SELECT DISTINCT ON ( + le.repo_full_name, + le.target_number, + le.target_type, + le.label_name + ) + le.repo_full_name, + le.target_number, + le.target_type, + le.action, + le.actor_github_id, + le.actor_login + FROM label_events le + WHERE le.repo_full_name = $1 + AND le.label_name = $2 + AND le.target_type IN ('pr', 'issue') + ORDER BY + le.repo_full_name, + le.target_number, + le.target_type, + le.label_name, + le.timestamp DESC + ), + current_targets AS ( + SELECT + repo_full_name, + target_number, + target_type, + actor_github_id, + actor_login + FROM latest_events + WHERE action = 'labeled' + ), + transition_rows AS ( + SELECT + repo_full_name, + target_number, + target_type, + $2::varchar AS label_name, + 'unlabeled'::varchar AS action, + $4::varchar AS actor_github_id, + $5::varchar AS actor_login, + $6::timestamptz AS timestamp + FROM current_targets + + UNION ALL + + SELECT + repo_full_name, + target_number, + target_type, + $3::varchar AS label_name, + 'labeled'::varchar AS action, + actor_github_id, + actor_login, + $6::timestamptz AS timestamp + FROM current_targets + ) + INSERT INTO label_events ( + repo_full_name, + target_number, + target_type, + label_name, + action, + actor_github_id, + actor_login, + timestamp + ) + SELECT + repo_full_name, + target_number, + target_type, + label_name, + action, + actor_github_id, + actor_login, + timestamp + FROM transition_rows + ON CONFLICT DO NOTHING + `, + [repoFullName, oldName, newName, actorGithubId, actorLogin, timestamp], + ); + + await manager.query( + ` + UPDATE pull_requests + SET labels = CASE + WHEN $3 = ANY(labels) THEN array_remove(labels, $2) + ELSE array_replace(labels, $2, $3) + END + WHERE repo_full_name = $1 + AND $2 = ANY(labels) + `, + [repoFullName, oldName, newName], + ); + + await manager.query( + ` + UPDATE issues + SET labels = CASE + WHEN $3 = ANY(labels) THEN array_remove(labels, $2) + ELSE array_replace(labels, $2, $3) + END + WHERE repo_full_name = $1 + AND $2 = ANY(labels) + `, + [repoFullName, oldName, newName], + ); + }); + } } diff --git a/packages/das/src/webhook/webhook.service.ts b/packages/das/src/webhook/webhook.service.ts index aafa29d..bf24ee1 100644 --- a/packages/das/src/webhook/webhook.service.ts +++ b/packages/das/src/webhook/webhook.service.ts @@ -116,7 +116,7 @@ export class WebhookService { await this.reviewCommentHandler.handle(payload); break; case "label": - // Repo-level label CRUD — not used for scoring, skip + await this.labelHandler.handleRepoLabel(payload); break; default: this.logger.debug(`Unhandled event type: ${event}`);