Files
Rico Berger b8fdbf17a2 Rework Edge Functions (#247)
Instead of using an `import_map.json` file to define the versions for
dependencies, they are now defined directly within the import. Since the
`import_map.json` file should not be used anymore and instead a
`deno.json` file per function should be used, we decided to define them
directly with the code. The overhead compared to a `deno.json` file per
function shouldn't be that large and it makes using functions in a
self-hosted setup easier.
2025-04-23 17:00:21 +02:00

788 lines
25 KiB
TypeScript

import { SupabaseClient } from "jsr:@supabase/supabase-js@2";
import { Redis } from "https://deno.land/x/redis@v0.32.0/mod.ts";
import { ISource } from "../models/source.ts";
import { IItem } from "../models/item.ts";
import { IProfile } from "../models/profile.ts";
import { utils } from "../utils/index.ts";
import { feedutils } from "./utils/index.ts";
export const getGithubFeed = async (
supabaseClient: SupabaseClient,
_redisClient: Redis | undefined,
profile: IProfile,
source: ISource,
_feedData: string | undefined,
): Promise<{ source: ISource; items: IItem[] }> => {
if (!source.options?.github || !source.options?.github.type) {
throw new feedutils.FeedValidationError("Invalid source options");
}
if (!profile.accountGithub?.token) {
throw new Error("GitHub token is missing");
}
const token = await utils.decrypt(profile.accountGithub.token);
if (
source.options.github.type === "notifications" ||
source.options.github.type === "repositorynotifications"
) {
/**
* With `notifications` and `repositorynotifications` type users can add
* there GitHub notifications or repository notifications as source to
* FeedDeck. The notifications are retrieved from the GitHub API via the
* list notifications endpoint.
*
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#list-notifications-for-the-authenticated-user
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#list-repository-notifications-for-the-authenticated-user
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons
*/
const notifications = [];
if (source.options.github.type === "notifications") {
const tmpNotifications = await request("/notifications", {
token: token,
params: {
all: "true",
participating: source.options.github.participating ? "true" : "false",
page: "1",
per_page: "50",
},
});
const user = await request("/user", {
token: token,
});
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.participating}`;
source.title = user.login;
source.icon = user.avatar_url;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
notifications.push(...tmpNotifications);
} else if (
source.options.github.type === "repositorynotifications" &&
source.options.github.repository
) {
const [owner, repo] = source.options.github.repository.split("/");
const tmpNotifications = await request(
`/repos/${owner}/${repo}/notifications`,
{
token: token,
params: {
all: "true",
participating: source.options.github.participating
? "true"
: "false",
page: "1",
per_page: "50",
},
},
);
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}--${source.options.github.participating}-${source.options.github.repository}`;
source.title = `${owner}/${repo}`;
if (
tmpNotifications.length > 0 &&
tmpNotifications[0].repository?.owner?.avatar_url
) {
source.icon = tmpNotifications[0].repository.owner.avatar_url;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
} else {
source.icon = `https://github.com/${owner}.png`;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
}
notifications.push(...tmpNotifications);
} else {
throw new feedutils.FeedValidationError("Invalid source options");
}
source.type = "github";
source.link = "https://github.com/notifications";
const items: IItem[] = [];
for (const [_, notification] of notifications.entries()) {
items.push({
id: `${source.id}-${notification.id}`,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: notification.subject?.title ?? "Notification",
link: getLinkFromApiUrl(notification.subject?.url),
media: notification.repository.owner.avatar_url,
description: formatDescription(notification),
author: notification.repository?.full_name,
publishedAt: Math.floor(
new Date(notification.updated_at).getTime() / 1000,
),
});
}
return { source, items };
} else if (
source.options.github.type === "useractivities" ||
source.options.github.type === "repositoryactivities" ||
source.options.github.type === "organizationactivitiespublic" ||
source.options.github.type === "organizationactivitiesprivate"
) {
/**
* `useractivities`, `repositoryactivities`, `organizationactivitiespublic`
* and `organizationactivitiesprivate` lets a user add the user, repository
* or organization notifications as source. We are using the corresponding
* events endpoint to get the notifications and add the id, title, icon and
* link to the source. Then we are going though all the events and adding
* the actor of an event as author. The title, description and link is
* different for each notification type and generated via the `formatEvent`
* function.
*
* - https://docs.github.com/en/developers/webhooks-and-events/events/github-event-types
* - GitHubTypeUserActivities: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-events-received-by-the-authenticated-user
* - GitHubTypeRepositoryActivities: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-organization-events-for-the-authenticated-user
* - GitHubTypeOrganizationActivitiesPublic: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-public-organization-events
* - GitHubTypeOrganizationActivitiesPrivate: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-organization-events-for-the-authenticated-user
*/
const events = [];
if (
source.options.github.type === "useractivities" &&
source.options.github.user
) {
const tmpEvents = await request(
`/users/${source.options.github.user}/received_events/public`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const user = await request(`/users/${source.options.github.user}`, {
token: token,
});
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.user}`;
source.title = source.options.github.user;
source.icon = user.avatar_url;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${source.options.github.user}`;
events.push(...tmpEvents);
} else if (
source.options.github.type === "repositoryactivities" &&
source.options.github.repository
) {
const [owner, repo] = source.options.github.repository.split("/");
const tmpEvents = await request(`/repos/${owner}/${repo}/events`, {
token: token,
params: {
page: "1",
per_page: "100",
},
});
const user = await request(`/users/${owner}`, {
token: token,
});
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}-${owner}-${repo}`;
source.title = `${owner}/${repo}`;
source.icon = user.avatar_url;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${owner}/${repo}`;
events.push(...tmpEvents);
} else if (
source.options.github.type === "organizationactivitiespublic" &&
source.options.github.organization
) {
const tmpEvents = await request(
`/orgs/${source.options.github.organization}/events`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const user = await request(
`/users/${source.options.github.organization}`,
{
token: token,
},
);
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.organization}`;
source.title = source.options.github.organization;
source.icon = user.avatar_url;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${source.options.github.organization}`;
events.push(...tmpEvents);
} else if (
source.options.github.type === "organizationactivitiesprivate" &&
source.options.github.organization
) {
const user = await request("/user", {
token: token,
});
const tmpEvents = await request(
`/users/${user.login}/events/orgs/${source.options.github.organization}`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const org = await request(
`/users/${source.options.github.organization}`,
{
token: token,
},
);
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.organization}`;
source.title = source.options.github.organization;
source.icon = org.avatar_url;
source.icon = await feedutils.uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${source.options.github.organization}`;
events.push(...tmpEvents);
} else {
throw new feedutils.FeedValidationError("Invalid source options");
}
source.type = "github";
const items: IItem[] = [];
for (const [_, event] of events.entries()) {
const eventDetails = formatEvent(event);
if (eventDetails) {
items.push({
id: `${source.id}-${event.id}`,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: eventDetails.title ?? "",
link: eventDetails.link ?? "",
media: event.actor?.avatar_url
? event.actor?.avatar_url
: event.actor?.login
? `https://github.com/${event.actor.login}.png`
: undefined,
description: eventDetails.description,
author: event.actor?.login ? event.actor.login : undefined,
publishedAt: Math.floor(new Date(event.created_at).getTime() / 1000),
});
}
}
return { source, items };
} else if (
source.options.github.type === "searchissuesandpullrequests" &&
source.options.github.query
) {
/**
* The `searchissuesandpullrequests` let a user add a seach query as source.
* With this type it is possible to follow all issues and pull requests of
* an user, repository or organization. Since we allow a custom query we do
* not add a icon and link to the source. The id of the source is generated
* based on a hash of the query. Then we are going through all the returned
* issues and generating an item for each issue, where we are using the user
* as author (we were also thinking about using the repository, but decided
* against it).
*
* - https://docs.github.com/en/rest/search?apiVersion=2022-11-28#search-issues-and-pull-requests
*/
const issues = await request(`/search/issues`, {
token: token,
params: {
q: source.options.github.query,
sort: "created",
direction: "desc",
page: "1",
per_page: "100",
},
});
source.id = `github-${source.userId}-${source.columnId}-${source.options.github.type}-${await utils.md5(
source.options.github.query,
)}`;
source.type = "github";
source.title = source.options.github.queryName || "Search";
source.icon = undefined;
source.link = undefined;
const items: IItem[] = [];
for (const [_, issue] of issues.items.entries()) {
items.push({
id: `${source.id}-${issue.node_id}`,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: issue.title,
link: issue.html_url,
media: issue?.user?.avatar_url
? issue.user.avatar_url
: issue?.user?.login
? `https://github.com/${issue.user.login}.png`
: undefined,
description: `${issue.repository_url.replace(
"https://api.github.com/repos/",
"",
)} #${issue.number}`,
author: issue.user.login,
publishedAt: Math.floor(new Date(issue.created_at).getTime() / 1000),
});
}
return { source, items };
}
throw new feedutils.FeedValidationError("Invalid source options");
};
/**
* `request` is a helper function to make a request to the GitHub API.
*/
const request = async (
url: string,
options: { token: string; params?: Record<string, string> },
) => {
const res = await utils.fetchWithTimeout(
`https://api.github.com${url}${
options.params ? `?${new URLSearchParams(options.params).toString()}` : ""
}`,
{
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${options.token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
},
5000,
);
if (!res.ok) {
const error = await res.json();
throw new Error(error?.message ?? "Unknown error");
}
return await res.json();
};
/**
* `getLinkFromApiUrl` returns a link which can be clicked by a user based on
* the given GitHub API url. If there is no url given we return the default
* GitHub notifications link.
*/
const getLinkFromApiUrl = (url?: string): string => {
if (!url) {
return "https://github.com/notifications";
}
if (/^https:\/\/api.github.com\/repos\/.*\/.*\/pulls\/\d+$/.test(url)) {
const n = url.lastIndexOf("pulls");
url = url.slice(0, n) + url.slice(n).replace("pulls", "pull");
}
return `https://github.com/${url.replace(
"https://api.github.com/repos/",
"",
)}`;
};
/**
* `formatDescription` formats the description for a notification based on the
* notification reason.
* See: https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons
*/
// deno-lint-ignore no-explicit-any
const formatDescription = (notification: any): string | undefined => {
switch (notification.reason) {
case "assign":
return "You were assigned to the issue.";
case "author":
return "You created the thread.";
case "comment":
return "You commented on the thread.";
case "ci_activity":
return "A GitHub Actions workflow run that you triggered was completed.";
case "invitation":
return "You accepted an invitation to contribute to the repository.";
case "manual":
return "You subscribed to the thread (via an issue or pull request).";
case "mention":
return "You were specifically @mentioned in the content.";
case "review_requested":
return "You, or a team you're a member of, were requested to review a pull request.";
case "security_alert":
return "GitHub discovered a security vulnerability in your repository.";
case "state_change":
return "You changed the thread state (for example, closing an issue or merging a pull request).";
case "subscribed":
return "You're watching the repository.";
case "team_mention":
return "You were on a team that was mentioned.";
default:
return undefined;
}
};
/**
* `formatEvent` formats the given event by returning a proper title, link and
* description for a item as we save it in our database. If the event type is
* not supported or a required field is missing we return an error.
*/
const formatEvent = (
// deno-lint-ignore no-explicit-any
event: any,
):
| {
title: string | undefined;
link: string | undefined;
description: string | undefined;
}
| undefined => {
switch (event.type) {
case "CommitCommentEvent":
if (event.payload?.comment?.html_url) {
return {
title: "",
link: event.payload.comment.html_url,
description: "Added a comment to a commit",
};
}
return undefined;
case "CreateEvent":
if (event.payload?.ref_type) {
let description = "";
switch (event.payload?.ref_type) {
case "repository":
description = "Created a new repository";
break;
case "branch":
description = "Created a new branch";
break;
case "tag":
description = "Created a new tag";
break;
default:
description = "Created something";
break;
}
return {
title: "",
link: event.repo.html_url,
description: description,
};
}
return undefined;
case "DeleteEvent":
if (event.payload?.ref_type) {
let description = "";
switch (event.payload?.ref_type) {
case "repository":
description = "Deleted a repository";
break;
case "branch":
description = "Deleted a branch";
break;
case "tag":
description = "Deleted a tag";
break;
default:
description = "Deleted something";
break;
}
return {
title: "",
link: event.repo.html_url,
description: description,
};
}
return undefined;
case "ForkEvent":
if (event.payload?.forkee) {
return {
title: event.payload.forkee.name,
link: event.payload.forkee.html_url,
description: "Forked a repository",
};
}
return undefined;
case "GollumEvent":
if (event.payload?.pages && event.payload?.pages.length > 0) {
let description = "";
switch (event.payload?.pages[0].action) {
case "created":
description = "Created a new wiki page";
break;
default:
description = "Updated a wiki page";
break;
}
return {
title: event.payload.pages[0].title,
link: event.payload.pages[0].html_url,
description: description,
};
}
return undefined;
case "IssueCommentEvent":
if (event.payload?.issue && event.payload?.comment) {
return {
title: event.payload.issue.title,
link: event.payload.comment.html_url,
description: "Added a comment",
};
}
return undefined;
case "IssuesEvent":
if (event.payload?.action && event.payload?.issue) {
let description = "";
switch (event.payload?.action) {
case "assigned":
description = "Assigned";
break;
case "unassigned":
description = "Unassigned";
break;
case "review_requested":
description = "Requested a review";
break;
case "review_request_removed":
description = "Removed a requested review";
break;
case "labeled":
description = "Added a label";
break;
case "unlabeled":
description = "Removed a label";
break;
case "opened":
description = "Opened an issue";
if (event.payload.issue.pull_request) {
description = "Opened a pull request";
}
break;
case "closed":
description = "Closed an issue";
if (event.payload.issue.pull_request) {
description = "Closed a pull request";
}
break;
case "reopened":
description = "Reopened an issue";
if (event.payload.issue.pull_request) {
description = "Reopened a pull request";
}
break;
case "synchronize":
description = "Synchronized an issue";
if (event.payload.issue.pull_request) {
description = "Synchronized a pull request";
}
break;
case "edited":
description = "Edited an issue";
if (event.payload.issue.pull_request) {
description = "Edited a pull request";
}
break;
}
return {
title: event.payload.issue.title,
link: event.payload.issue.html_url,
description: description,
};
}
return undefined;
case "MemberEvent":
if (event.payload?.action && event.payload?.member) {
let description = "";
switch (event.payload?.action) {
case "added":
description = "Was added as member";
break;
}
return {
title: event.payload.member.login,
link: event.payload.member.html_url,
description: description,
};
}
return undefined;
case "PullRequestEvent":
if (event.payload?.action && event.payload?.pull_request) {
let description = "";
switch (event.payload?.action) {
case "assigned":
description = "Assigned";
break;
case "unassigned":
description = "Unassigned";
break;
case "review_requested":
description = "Requested a review";
break;
case "review_request_removed":
description = "Removed a requested review";
break;
case "labeled":
description = "Added a label";
break;
case "unlabeled":
description = "Removed a label";
break;
case "opened":
description = "Opened";
break;
case "closed":
if (
event.payload?.pull_request.merged &&
event.payload?.pull_request.merged == true
) {
description = "Merged";
break;
} else {
description = "Closed";
break;
}
case "reopened":
description = "Reopened";
break;
case "synchronize":
description = "Synchronized";
break;
case "edited":
description = "Edited";
break;
}
return {
title: event.payload.pull_request.title,
link: event.payload.pull_request.html_url,
description: description,
};
}
return undefined;
case "PullRequestReviewEvent":
if (event.payload?.pull_request && event.payload?.review) {
return {
title: event.payload.pull_request.title,
link: event.payload.review.html_url,
description: "Added a review",
};
}
return undefined;
case "PullRequestReviewCommentEvent":
if (
event.payload?.action &&
event.payload?.pull_request &&
event.payload?.comment
) {
let description = "";
switch (event.payload?.action) {
case "created":
description = "Added a review comment";
break;
case "edited":
description = "Updated a review comment";
break;
case "deleted":
description = "Deleted a review comment";
break;
}
return {
title: event.payload.pull_request.title,
link: event.payload.comment.html_url,
description: description,
};
}
return undefined;
case "PushEvent":
if (event.payload?.repository) {
return {
title: "",
link: event.payload.repository.html_url,
description: event.payload.commits
? event.payload.commits.length === 1
? `Pushed ${event.payload.commits.length} commit`
: `Pushed ${event.payload.commits.length} commits`
: "",
};
}
return undefined;
case "ReleaseEvent":
if (event.payload?.action && event.payload?.release) {
let description = "";
switch (event.payload?.action) {
case "created":
description = "Release was created";
break;
case "deleted":
description = "Release was created";
break;
case "edited":
description = "Release was updated";
break;
case "prereleased":
description = "Prerelease was created";
break;
case "published":
description = "Release was published";
break;
case "released":
description = "Release was released";
break;
case "unpublished":
description = "Release was unpublished";
break;
}
return {
title: event.payload.release.name,
link: event.payload.release.html_url,
description: description,
};
}
return undefined;
case "WatchEvent":
if (event.actor) {
return {
title: "",
link: `https://github.com/${event.actor.login}`,
description: `${event.actor.login} starred ${
event.payload?.repository?.name || "the repository"
}`,
};
}
return undefined;
default:
return undefined;
}
};