Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions locales/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,12 @@ e_issue_assigned =
— <a href="{ $issueUrl }">{ $issueUrl }</a>

{ $repoHashtag } #assigned

e_project_item_status_changed =
🔄 { $itemTypeIcon } { $itemType } status changed in { $repoName } / <b>{ $projectName }</b>.

👤 User: <a href="{ $userUrl }">{ $user }</a> { $telegramStatus }
📚 Item: <a href="{ $itemUrl }">{ $itemTitle }</a>
👉 status: <b>{ $status }</b>

{ $repoHashtag } #project
202 changes: 202 additions & 0 deletions src/lib/github/webhooks/handlers/projects-item-edited.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<ItemDetails> {
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<GraphQLResponse["node"] | null> {
const result = await octokit.graphql<GraphQLResponse>(
`
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";
}
2 changes: 2 additions & 0 deletions src/lib/github/webhooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 }));
36 changes: 29 additions & 7 deletions src/lib/github/webhooks/withGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TEvent extends EmitterWebhookEventName>(handler: HandlerFunction<TEvent, unknown>) {
return async (event: EmitterWebhookEvent<TEvent>) => {
if (!("repository" in event.payload)) return;
interface GuardOptions {
skipRepositoryCheck?: boolean;
}

export function withGuards<TEvent extends EmitterWebhookEventName>(
handler: HandlerFunction<TEvent, unknown>,
options: GuardOptions = {},
) {
return async (event: EmitterWebhookEvent<TEvent>) => {
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
Expand Down
Loading