Files
Copilot d1203408b5 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 <tom@getoutline.com>
2026-03-14 17:13:35 -04:00

482 lines
13 KiB
TypeScript

import {
createOAuthUserAuth,
createAppAuth,
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Sequelize } from "sequelize";
import type { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
import type { IntegrationType } from "@shared/types";
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,
UnfurlProject,
UnfurlSignature,
} from "@server/types";
import { GitHubUtils } from "../shared/GitHubUtils";
import env from "./env";
type PR =
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
type Issue =
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
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(
octokit.rest.apps.listReposAccessibleToInstallation,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
),
requestPR: async (params: ParsedIssueOrPR) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
owner: params.owner,
repo: params.repo,
pull_number: params.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
requestIssue: async (params: ParsedIssueOrPR) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
owner: params.owner,
repo: params.repo,
issue_number: params.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
/**
* 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<GitHubProject | undefined> => {
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
*
* @returns {Array} Containing details of all app installations done by user
*/
requestAppInstallations: async () =>
octokit.paginate("GET /user/installations"),
/**
* Fetches details of a GitHub resource, e.g, a pull request or an issue
*
* @param resource Contains identifiers which are used to construct resource endpoint, e.g, `/repos/{params.owner}/{params.repo}/pulls/{params.id}`
* @returns Response containing resource details
*/
requestResource: async function requestResource(
resource: GitHubResource | undefined
): Promise<OctokitResponse<Issue | PR> | undefined> {
switch (resource?.type) {
case UnfurlResourceType.PR:
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
case UnfurlResourceType.Issue:
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
default:
return;
}
},
/**
* Fetches details of a specific GitHub app installation
*
* @param installationId Id of the installation to fetch
* @returns Response containing installation details
*/
requestAppInstallation: async (
installationId: number
): Promise<OctokitResponse<Installation>> =>
octokit.request("GET /app/installations/{installation_id}", {
installation_id: installationId,
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
/**
* Uninstalls the GitHub app from a given target
*
* @param installationId Id of the target from where to uninstall
*/
requestAppUninstall: async (installationId: number) =>
octokit.request("DELETE /app/installations/{id}", {
id: installationId,
}),
});
const CustomOctokit = Octokit.plugin(requestPlugin);
export class GitHub {
private static appId = env.GITHUB_APP_ID;
private static appKey = env.GITHUB_APP_PRIVATE_KEY
? Buffer.from(env.GITHUB_APP_PRIVATE_KEY, "base64").toString("ascii")
: undefined;
private static clientId = env.GITHUB_CLIENT_ID;
private static clientSecret = env.GITHUB_CLIENT_SECRET;
private static appOctokit: Octokit;
private static supportedResources = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
/**
* Parses a given URL and returns resource identifiers for GitHub specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`.
*/
public static parseUrl(url: string): GitHubResource | undefined {
try {
const { hostname, pathname } = new URL(url);
if (hostname !== "github.com") {
return;
}
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]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = Number(parts[4]);
if (!type || !GitHub.supportedResources.includes(type) || isNaN(id)) {
return;
}
return {
owner,
repo,
type: type as UnfurlResourceType.Issue | UnfurlResourceType.PR,
id,
url,
};
} catch (_err) {
// Invalid URL format
return;
}
}
private static authenticateAsApp = () => {
if (!GitHub.appOctokit) {
GitHub.appOctokit = new CustomOctokit({
authStrategy: createAppAuth,
auth: {
appId: GitHub.appId,
privateKey: GitHub.appKey,
clientId: GitHub.clientId,
clientSecret: GitHub.clientSecret,
},
});
}
return GitHub.appOctokit;
};
/**
* [Authenticates as a GitHub user](https://github.com/octokit/auth-app.js/?tab=readme-ov-file#authenticate-as-installation)
*
* @param code Temporary code received in callback url after user authorizes
* @param state A string received in callback url to protect against CSRF
* @returns {Octokit} User-authenticated octokit instance
*/
public static authenticateAsUser = async (
code: string,
state?: string | null
) =>
GitHub.authenticateAsApp().auth({
type: "oauth-user",
code,
state,
factory: (options: OAuthWebFlowAuthOptions) =>
new CustomOctokit({
authStrategy: createOAuthUserAuth,
auth: options,
}),
}) as Promise<InstanceType<typeof CustomOctokit>>;
/**
* [Authenticates as a GitHub app installation](https://github.com/octokit/auth-app.js/?tab=readme-ov-file#authenticate-as-installation)
*
* @param installationId Id of an installation
* @returns {Octokit} Installation-authenticated octokit instance
*/
public static authenticateAsInstallation = async (installationId: number) =>
GitHub.authenticateAsApp().auth({
type: "installation",
installationId,
factory: (options: InstallationAuthOptions) =>
new CustomOctokit({
authStrategy: createAppAuth,
auth: options,
}),
}) as Promise<InstanceType<typeof CustomOctokit>>;
/**
*
* @param url GitHub resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitHub Pull Request details
*/
public static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
const resource = GitHub.parseUrl(url);
if (!resource || !actor) {
return;
}
// Find integration, prioritizing one where the installation account matches the resource owner
const integration = (await Integration.findOne({
where: {
service: IntegrationService.GitHub,
teamId: actor.teamId,
},
order: [
[
Sequelize.literal(
`CASE WHEN "settings"->'github'->'installation'->'account'->>'name' = :owner THEN 0 ELSE 1 END`
),
"ASC",
],
],
replacements: {
owner: resource.owner,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const client = await GitHub.authenticateAsInstallation(
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" };
}
return GitHub.transformData(res.data, resource.type);
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return { error: err.message || "Unknown error" };
}
};
private static async unfurlProject(
client: InstanceType<typeof CustomOctokit>,
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,
id: `#${issue.number}`,
title: issue.title,
description: issue.body_text ?? null,
author: {
name: issue.user?.login ?? "",
avatarUrl: issue.user?.avatar_url ?? "",
},
labels: issue.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: issueState,
color: GitHubUtils.getColorForStatus(issueState),
},
createdAt: issue.created_at,
} satisfies UnfurlIssueOrPR;
}
const pr = data as PR;
const prState = pr.merged ? "merged" : pr.state;
return {
type: UnfurlResourceType.PR,
url: pr.html_url,
id: `#${pr.number}`,
title: pr.title,
description: pr.body,
author: {
name: pr.user.login,
avatarUrl: pr.user.avatar_url,
},
state: {
name: prState,
color: GitHubUtils.getColorForStatus(prState, !!pr.draft),
draft: pr.draft,
},
createdAt: pr.created_at,
} satisfies UnfurlIssueOrPR;
}
}