diff --git a/locales/en.ftl b/locales/en.ftl index 648d79d..9c810de 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -223,3 +223,12 @@ e_issue_assigned = — { $issueUrl } { $repoHashtag } #assigned + +e_project_item_status_changed = + 🔄 { $itemTypeIcon } { $itemType } status changed in { $repoName } / { $projectName }. + + 👤 User: { $user } { $telegramStatus } + 📚 Item: { $itemTitle } + 👉 status: { $status } + + { $repoHashtag } #project diff --git a/src/lib/github/webhooks/handlers/projects-item-edited.ts b/src/lib/github/webhooks/handlers/projects-item-edited.ts new file mode 100644 index 0000000..e3862e4 --- /dev/null +++ b/src/lib/github/webhooks/handlers/projects-item-edited.ts @@ -0,0 +1,202 @@ +import type { HandlerFunction } from "@octokit/webhooks/types"; + +import { bot } from "#bot"; + +import { escapeHtml } from "../../../escape-html.ts"; +import { octokit } from "../../github.ts"; +import { botText, getRepoHashtag, getUser } from "./_utils.ts"; + +interface ProjectItemPayload { + sender: { + login: string; + html_url: string; + }; + + changes: { + field_value?: { + field_name: string; + field_type: string; + field_node_id: string; + }; + }; + + projects_v2_item: { + node_id: string; + content_type: string; + content_node_id: string; + }; + + organization?: { + login: string; + }; + repository?: { + name: string; + html_url: string; + }; +} + +interface ItemDetails { + title: string; + type: string; + url?: string; + status: string; + projectTitle: string; + repositoryName?: string; + repositoryUrl?: string; +} + +export const projectItemEditedCallback: HandlerFunction<"projects_v2_item.edited", unknown> = async (event) => { + const payload = event.payload as unknown as ProjectItemPayload; + + const isStatusChanged = payload.changes.field_value?.field_name === "Status"; + if (!isStatusChanged) return; + + const user = await getUser(payload.sender); + const details = await fetchItemDetails(payload.projects_v2_item.node_id); + + const userUrl = payload.sender.html_url; + const itemUrl = details.url ?? ""; + const repoName = details.repositoryName ?? details.projectTitle; + const repoHashtag = details.repositoryName && details.repositoryUrl ? getRepoHashtag(details.repositoryName) : ""; + + const typeIcons: Record = { + Issue: "🔘", + PullRequest: "🌴", + DraftIssue: "📝", + }; + + await bot.announce( + botText("e_project_item_status_changed", { + user: escapeHtml(user.ghDisplayname), + userUrl: escapeHtml(userUrl), + telegramStatus: user.telegramStatus, + projectName: escapeHtml(details.projectTitle), + repoName: escapeHtml(repoName), + status: escapeHtml(details.status), + itemType: escapeHtml(details.type), + itemTitle: escapeHtml(details.title), + itemUrl: escapeHtml(itemUrl), + itemTypeIcon: typeIcons[details.type], + repoHashtag, + }), + { + link_preview_options: { + prefer_small_media: true, + url: itemUrl, + }, + }, + ); +}; + +async function fetchItemDetails(nodeId: string): Promise { + const node = await fetchNodeData(nodeId); + + if (!node) { + return { + title: "N/A", + type: "Item", + status: "N/A", + projectTitle: "N/A", + }; + } + + const status = extractStatus(node); + const content = node.content; + const repository = content?.repository; + + return { + title: content?.title ?? "N/A", + type: content?.__typename ?? "Item", + url: content?.url, + repositoryName: repository?.name, + repositoryUrl: repository?.url, + status, + projectTitle: node.project?.title ?? "N/A", + }; +} + +interface GraphQLResponse { + node: { + content: { + title: string; + url?: string; + __typename: "DraftIssue" | "Issue" | "PullRequest"; + repository?: { + name: string; + url: string; + }; + }; + project: { + title: string; + }; + fieldValues: { + nodes: { + name?: string; + field?: { + name: string; + }; + }[]; + }; + }; +} + +async function fetchNodeData(nodeId: string): Promise { + const result = await octokit.graphql( + ` + query($id: ID!) { + node(id: $id) { + ... on ProjectV2Item { + content { + ... on Issue { + title + url + __typename + repository { + name + url + } + } + ... on PullRequest { + title + url + __typename + repository { + name + url + } + } + ... on DraftIssue { + title + __typename + } + } + project { + title + } + fieldValues(first: 50) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + } + } + } + } + } + `, + { + id: nodeId, + }, + ); + return result.node; +} + +function extractStatus(node: GraphQLResponse["node"]): string { + const statusNode = node.fieldValues.nodes.find((field) => field.field?.name === "Status"); + return statusNode?.name ?? "N/A"; +} diff --git a/src/lib/github/webhooks/index.ts b/src/lib/github/webhooks/index.ts index fe8d566..b464b70 100644 --- a/src/lib/github/webhooks/index.ts +++ b/src/lib/github/webhooks/index.ts @@ -3,6 +3,7 @@ import { config } from "#config"; import { issuesAssignedCallback } from "./handlers/issues-assigned.ts"; import { issuesOpenedCallback } from "./handlers/issues-opened.ts"; +import { projectItemEditedCallback } from "./handlers/projects-item-edited.ts"; import { pullRequestClosedCallback } from "./handlers/pull-request-closed.ts"; import { pullRequestOpenedCallback } from "./handlers/pull-request-opened.ts"; import { releaseCreatedCallback } from "./handlers/release-created.ts"; @@ -19,3 +20,4 @@ webhooks.on("pull_request.opened", withGuards(pullRequestOpenedCallback)); webhooks.on("release.created", withGuards(releaseCreatedCallback)); webhooks.on("repository.created", withGuards(repositoryCreatedCallback)); webhooks.on("star.created", withGuards(starCreatedCallback)); +webhooks.on("projects_v2_item.edited", withGuards(projectItemEditedCallback, { skipRepositoryCheck: true })); diff --git a/src/lib/github/webhooks/withGuards.ts b/src/lib/github/webhooks/withGuards.ts index 8483514..247f6df 100644 --- a/src/lib/github/webhooks/withGuards.ts +++ b/src/lib/github/webhooks/withGuards.ts @@ -8,24 +8,46 @@ import { isRepositoryAccepted, isUserMuted } from "./handlers/_utils.ts"; * This ensures that only events which meet certain conditions reach the handler: * - The event comes from an accepted repository (`isRepositoryAccepted` returns true). * - The user who triggered the event is not muted (`isUserMuted` returns false). - + * + * Guards can be configured through the `options` parameter. + * * @template TEvent - The GitHub webhook event name (e.g., "pull_request.opened"). * @param handler - The original handler function to wrap. - * @returns A new handler function that applies the guards before calling the original handler. + * @param options - Optional configuration for enabling or disabling specific guards. + * @returns A new handler function that applies the configured guards before calling the original handler. * * @example * webhooks.on("pull_request.opened", withGuards(pullRequestOpenedCallback)); + * + * @example + * webhooks.on( + * "projects_v2_item.edited", + * withGuards(projectItemEditedCallback, { + * skipRepositoryCheck: true, + * }), + * ); */ -export function withGuards(handler: HandlerFunction) { - return async (event: EmitterWebhookEvent) => { - if (!("repository" in event.payload)) return; +interface GuardOptions { + skipRepositoryCheck?: boolean; +} +export function withGuards( + handler: HandlerFunction, + options: GuardOptions = {}, +) { + return async (event: EmitterWebhookEvent) => { const username = event.payload.sender?.login; - const repo = event.payload.repository?.full_name; - if (repo && !(await isRepositoryAccepted(repo))) return; + if (!options.skipRepositoryCheck) { + if (!("repository" in event.payload)) return; + + const repo = event.payload.repository?.full_name; + if (repo && !(await isRepositoryAccepted(repo))) { + return; + } + } if (username && (await isUserMuted(username))) return; // eslint-disable-next-line @typescript-eslint/no-unsafe-return