From d1203408b57367ce9db31b158c78cf780af9b5ed Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:13:35 -0400 Subject: [PATCH] Add GitHub Project V2 unfurl support (#11753) * Initial plan * Add GitHub Project V2 unfurl support to the GitHub plugin Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * Various fixes --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Co-authored-by: Tom Moor --- .../HoverPreview/HoverPreviewProject.tsx | 6 +- app/utils/mention.ts | 4 +- plugins/github/server/github.ts | 182 ++++++++++++++++-- plugins/github/shared/GitHubUtils.ts | 1 + .../IssueStatusIcon/GitHubIssueStatusIcon.tsx | 1 + shared/editor/components/Mentions.tsx | 6 +- shared/types.ts | 2 +- 7 files changed, 187 insertions(+), 15 deletions(-) diff --git a/app/components/HoverPreview/HoverPreviewProject.tsx b/app/components/HoverPreview/HoverPreviewProject.tsx index fa80c10118..9cfb43a83a 100644 --- a/app/components/HoverPreview/HoverPreviewProject.tsx +++ b/app/components/HoverPreview/HoverPreviewProject.tsx @@ -62,7 +62,11 @@ const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_( )} - + {state.name} diff --git a/app/utils/mention.ts b/app/utils/mention.ts index f064161411..0d2a76f88c 100644 --- a/app/utils/mention.ts +++ b/app/utils/mention.ts @@ -65,7 +65,9 @@ export const determineMentionType = ({ ? MentionType.PullRequest : type === "issues" ? MentionType.Issue - : undefined; + : type === "projects" + ? MentionType.Project + : undefined; } case IntegrationService.Linear: { diff --git a/plugins/github/server/github.ts b/plugins/github/server/github.ts index 52ae87c368..f25aba9e51 100644 --- a/plugins/github/server/github.ts +++ b/plugins/github/server/github.ts @@ -13,7 +13,11 @@ import { IntegrationService, UnfurlResourceType } from "@shared/types"; import Logger from "@server/logging/Logger"; import type { User } from "@server/models"; import { Integration } from "@server/models"; -import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types"; +import type { + UnfurlIssueOrPR, + UnfurlProject, + UnfurlSignature, +} from "@server/types"; import { GitHubUtils } from "../shared/GitHubUtils"; import env from "./env"; @@ -24,6 +28,33 @@ type Issue = type Installation = Endpoints["GET /app/installations/{installation_id}"]["response"]["data"]; +type ParsedIssueOrPR = { + owner: string; + repo: string; + type: UnfurlResourceType.Issue | UnfurlResourceType.PR; + id: number; + url: string; +}; + +type ParsedProject = { + owner: string; + ownerType: "orgs" | "users"; + type: UnfurlResourceType.Project; + projectNumber: number; + url: string; +}; + +type GitHubResource = ParsedIssueOrPR | ParsedProject; + +type GitHubProject = { + number: number; + title: string; + description: string | null; + url: string; + createdAt: string; + closed: boolean; +}; + const requestPlugin = (octokit: Octokit) => ({ requestRepos: () => octokit.paginate.iterator( @@ -36,7 +67,7 @@ const requestPlugin = (octokit: Octokit) => ({ } ), - requestPR: async (params: NonNullable>) => + requestPR: async (params: ParsedIssueOrPR) => octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, { owner: params.owner, repo: params.repo, @@ -47,9 +78,7 @@ const requestPlugin = (octokit: Octokit) => ({ }, }), - requestIssue: async ( - params: NonNullable> - ) => + requestIssue: async (params: ParsedIssueOrPR) => octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, { owner: params.owner, repo: params.repo, @@ -60,6 +89,61 @@ const requestPlugin = (octokit: Octokit) => ({ }, }), + /** + * Fetches details of a GitHub ProjectV2 using the GraphQL API. + * + * @param params Parsed project URL identifiers. + * @returns Project data or undefined if not found. + */ + requestProject: async ( + params: ParsedProject + ): Promise => { + const ownerField = params.ownerType === "orgs" ? "organization" : "user"; + + const query = `query($login: String!, $number: Int!) { + ${ownerField}(login: $login) { + projectV2(number: $number) { + number + title + shortDescription + url + createdAt + closed + } + } + }`; + + const result = await octokit.graphql< + Record< + string, + { + projectV2: { + number: number; + title: string; + shortDescription: string | null; + url: string; + createdAt: string; + closed: boolean; + } | null; + } + > + >(query, { login: params.owner, number: params.projectNumber }); + + const project = result[ownerField]?.projectV2; + if (!project) { + return undefined; + } + + return { + number: project.number, + title: project.title, + description: project.shortDescription, + url: project.url, + createdAt: project.createdAt, + closed: project.closed, + }; + }, + /** * Fetches app installations accessible to the user * @@ -75,7 +159,7 @@ const requestPlugin = (octokit: Octokit) => ({ * @returns Response containing resource details */ requestResource: async function requestResource( - resource: ReturnType + resource: GitHubResource | undefined ): Promise | undefined> { switch (resource?.type) { case UnfurlResourceType.PR: @@ -139,7 +223,7 @@ export class GitHub { * @param url URL to parse * @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`. */ - public static parseUrl(url: string) { + public static parseUrl(url: string): GitHubResource | undefined { try { const { hostname, pathname } = new URL(url); if (hostname !== "github.com") { @@ -147,6 +231,29 @@ export class GitHub { } const parts = pathname.split("/"); + + // Handle project URLs: /orgs/{org}/projects/{number} or /users/{user}/projects/{number} + if ( + (parts[1] === "orgs" || parts[1] === "users") && + parts[3] === "projects" + ) { + const ownerType = parts[1] as "orgs" | "users"; + const owner = parts[2]; + const projectNumber = Number(parts[4]); + + if (!owner || isNaN(projectNumber)) { + return; + } + + return { + owner, + ownerType, + type: UnfurlResourceType.Project, + projectNumber, + url, + }; + } + const owner = parts[1]; const repo = parts[2]; const type = parts[3] @@ -154,11 +261,17 @@ export class GitHub { : undefined; const id = Number(parts[4]); - if (!type || !GitHub.supportedResources.includes(type)) { + if (!type || !GitHub.supportedResources.includes(type) || isNaN(id)) { return; } - return { owner, repo, type, id, url }; + return { + owner, + repo, + type: type as UnfurlResourceType.Issue | UnfurlResourceType.PR, + id, + url, + }; } catch (_err) { // Invalid URL format return; @@ -261,6 +374,10 @@ export class GitHub { integration.settings.github!.installation.id ); + if (resource.type === UnfurlResourceType.Project) { + return GitHub.unfurlProject(client, resource); + } + const res = await client.requestResource(resource); if (!res) { return { error: "Resource not found" }; @@ -273,9 +390,52 @@ export class GitHub { } }; + private static async unfurlProject( + client: InstanceType, + resource: ParsedProject + ) { + let project: GitHubProject | undefined; + try { + project = await client.requestProject(resource); + } catch (err) { + Logger.warn("Failed to fetch project from GitHub", err); + return { error: "Resource not found" }; + } + + if (!project) { + return { error: "Resource not found" }; + } + + const state = project.closed ? "completed" : "open"; + + return { + type: UnfurlResourceType.Project, + url: project.url, + id: `#${project.number}`, + name: project.title, + color: GitHubUtils.getColorForStatus(state), + description: project.description, + lead: null, + state: { + type: state, + name: state, + color: GitHubUtils.getColorForStatus(state), + }, + labels: [], + createdAt: project.createdAt, + targetDate: null, + } satisfies UnfurlProject; + } + private static transformData(data: Issue | PR, type: UnfurlResourceType) { if (type === UnfurlResourceType.Issue) { const issue = data as Issue; + const issueState = + issue.state === "closed" + ? issue.state_reason === "completed" + ? "completed" + : "canceled" + : issue.state; return { type: UnfurlResourceType.Issue, url: issue.html_url, @@ -291,8 +451,8 @@ export class GitHub { color: `#${label.color}`, })), state: { - name: issue.state, - color: GitHubUtils.getColorForStatus(issue.state), + name: issueState, + color: GitHubUtils.getColorForStatus(issueState), }, createdAt: issue.created_at, } satisfies UnfurlIssueOrPR; diff --git a/plugins/github/shared/GitHubUtils.ts b/plugins/github/shared/GitHubUtils.ts index 585b8efcf5..e38fde944d 100644 --- a/plugins/github/shared/GitHubUtils.ts +++ b/plugins/github/shared/GitHubUtils.ts @@ -53,6 +53,7 @@ export class GitHubUtils { return "#a371f7"; case "closed": return "#f85149"; + case "completed": case "merged": return "#8250df"; case "canceled": diff --git a/shared/components/IssueStatusIcon/GitHubIssueStatusIcon.tsx b/shared/components/IssueStatusIcon/GitHubIssueStatusIcon.tsx index 7b7be3ee9c..8c5c0785f0 100644 --- a/shared/components/IssueStatusIcon/GitHubIssueStatusIcon.tsx +++ b/shared/components/IssueStatusIcon/GitHubIssueStatusIcon.tsx @@ -18,6 +18,7 @@ export function GitHubIssueStatusIcon(props: BaseIconProps) { ); + case "completed": case "closed": return ( { - {Math.round(project.progress * 100)}% + + {project.progress !== undefined + ? `${Math.round(project.progress * 100)}%` + : project.id} + diff --git a/shared/types.ts b/shared/types.ts index 123d09212d..186ed6ab6d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -637,7 +637,7 @@ export type UnfurlResponse = { /** Project labels */ labels: Array<{ name: string; color: string }>; /** Project progress (0-1) */ - progress: number; + progress?: number; /** Project creation time */ createdAt: string; /** Project target date */