Files
feeddeck/supabase/functions/_shared/feed/github.ts
Rico Berger 982add8fbb [core] Update Deno Modules (#100)
This commit updates all used Deno modules to their latest version.

Since some of the used modules / functions were deprecated we had to
adjust our encrypt / descrypt functions and the generation of the source
and item ids, where we have to use a new md5 function.
2023-12-12 20:32:44 +01:00

805 lines
25 KiB
TypeScript

import { SupabaseClient } from '@supabase/supabase-js';
import { Redis } from 'redis';
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,
): Promise<{ source: ISource; items: IItem[] }> => {
if (!source.options?.github || !source.options?.github.type) {
throw new Error('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 Error('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 Error('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 Error('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;
}
};