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