Repository: gitify-app/gitify
Pull request #1056 (2024-04-22 11:12:33)

refactor: extract api calls into client with types
Refactored most uses of apiRequestAuth into a new `api/client`.  Add explicit types where missing.  Fixed semantics around Release vs ReleaseComment.  

I've named each function to be close-to that documented in https://docs.github.com/en/rest/, and included a link to the public docs where available.

setchy: Thanks gents 🙇 

Patch:
diff --git a/src/context/App.tsx b/src/context/App.tsx
index 3907fb97..3f4f1464 100644
--- a/src/context/App.tsx
+++ b/src/context/App.tsx
@@ -18,11 +18,10 @@ import {
   type SettingsState,
   Theme,
 } from '../types';
-import { apiRequestAuth } from '../utils/api-requests';
+import { headNotifications } from '../utils/api/client';
 import { addAccount, authGitHub, getToken, getUserData } from '../utils/auth';
 import { setAutoLaunch, updateTrayTitle } from '../utils/comms';
 import Constants from '../utils/constants';
-import { generateGitHubAPIUrl } from '../utils/helpers';
 import { getNotificationCount } from '../utils/notifications';
 import { clearState, loadState, saveState } from '../utils/storage';
 import { setTheme } from '../utils/theme';
@@ -166,11 +165,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
 
   const validateToken = useCallback(
     async ({ token, hostname }: AuthTokenOptions) => {
-      await apiRequestAuth(
-        `${generateGitHubAPIUrl(hostname)}notifications`,
-        'HEAD',
-        token,
-      );
+      await headNotifications(hostname, token);
+
       const user = await getUserData(token, hostname);
       const updatedAccounts = addAccount(accounts, token, hostname, user);
       setAccounts(updatedAccounts);
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index c3a1053d..a2841a82 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -1,4 +1,4 @@
-import axios, { type AxiosError, type AxiosPromise } from 'axios';
+import axios, { type AxiosError } from 'axios';
 import { useCallback, useState } from 'react';
 
 import type {
@@ -8,11 +8,16 @@ import type {
   SettingsState,
 } from '../types';
 import type { GitHubRESTError, Notification } from '../typesGitHub';
-import { apiRequestAuth } from '../utils/api-requests';
+import {
+  ignoreNotificationThreadSubscription,
+  listNotificationsForAuthenticatedUser,
+  markNotificationThreadAsDone,
+  markNotificationThreadAsRead,
+  markRepositoryNotificationsAsRead,
+} from '../utils/api/client';
 import { determineFailureType } from '../utils/api/errors';
 import Constants from '../utils/constants';
 import {
-  generateGitHubAPIUrl,
   getEnterpriseAccountToken,
   getTokenForHost,
   isEnterpriseHost,
@@ -74,28 +79,25 @@ export const useNotifications = (): NotificationsState => {
 
   const fetchNotifications = useCallback(
     async (accounts: AuthState, settings: SettingsState) => {
-      function getNotifications(
-        hostname: string,
-        token: string,
-      ): AxiosPromise<Notification[]> {
-        const endpointSuffix = `notifications?participating=${settings.participating}`;
-        const url = `${generateGitHubAPIUrl(hostname)}${endpointSuffix}`;
-        return apiRequestAuth(url, 'GET', token);
-      }
-
       function getGitHubNotifications() {
         if (!isGitHubLoggedIn(accounts)) {
           return;
         }
-        return getNotifications(
+
+        return listNotificationsForAuthenticatedUser(
           Constants.DEFAULT_AUTH_OPTIONS.hostname,
           accounts.token,
+          settings,
         );
       }
 
       function getEnterpriseNotifications() {
         return accounts.enterpriseAccounts.map((account) => {
-          return getNotifications(account.hostname, account.token);
+          return listNotificationsForAuthenticatedUser(
+            account.hostname,
+            account.token,
+            settings,
+          );
         });
       }
 
@@ -220,12 +222,7 @@ export const useNotifications = (): NotificationsState => {
         : accounts.token;
 
       try {
-        await apiRequestAuth(
-          `${generateGitHubAPIUrl(hostname)}notifications/threads/${id}`,
-          'PATCH',
-          token,
-          {},
-        );
+        await markNotificationThreadAsRead(id, hostname, token);
 
         const updatedNotifications = removeNotification(
           id,
@@ -253,12 +250,7 @@ export const useNotifications = (): NotificationsState => {
         : accounts.token;
 
       try {
-        await apiRequestAuth(
-          `${generateGitHubAPIUrl(hostname)}notifications/threads/${id}`,
-          'DELETE',
-          token,
-          {},
-        );
+        await markNotificationThreadAsDone(id, hostname, token);
 
         const updatedNotifications = removeNotification(
           id,
@@ -286,14 +278,7 @@ export const useNotifications = (): NotificationsState => {
         : accounts.token;
 
       try {
-        await apiRequestAuth(
-          `${generateGitHubAPIUrl(
-            hostname,
-          )}notifications/threads/${id}/subscription`,
-          'PUT',
-          token,
-          { ignored: true },
-        );
+        await ignoreNotificationThreadSubscription(id, hostname, token);
         await markNotificationRead(accounts, id, hostname);
       } catch (err) {
         setIsFetching(false);
@@ -312,13 +297,7 @@ export const useNotifications = (): NotificationsState => {
         : accounts.token;
 
       try {
-        await apiRequestAuth(
-          `${generateGitHubAPIUrl(hostname)}repos/${repoSlug}/notifications`,
-          'PUT',
-          token,
-          {},
-        );
-
+        await markRepositoryNotificationsAsRead(repoSlug, hostname, token);
         const updatedNotifications = removeNotifications(
           repoSlug,
           notifications,
diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx
index 77c0e00e..ef87f8d0 100644
--- a/src/routes/Settings.tsx
+++ b/src/routes/Settings.tsx
@@ -21,14 +21,13 @@ import { Checkbox } from '../components/fields/Checkbox';
 import { RadioGroup } from '../components/fields/RadioGroup';
 import { AppContext } from '../context/App';
 import { Theme } from '../types';
-import { apiRequestAuth } from '../utils/api-requests';
+import { getRootHypermediaLinks } from '../utils/api/client';
 import {
   openExternalLink,
   updateTrayIcon,
   updateTrayTitle,
 } from '../utils/comms';
 import Constants from '../utils/constants';
-import { generateGitHubAPIUrl } from '../utils/helpers';
 import { setTheme } from '../utils/theme';
 
 export const SettingsRoute: FC = () => {
@@ -74,9 +73,8 @@ export const SettingsRoute: FC = () => {
 
   useMemo(() => {
     (async () => {
-      const response = await apiRequestAuth(
-        `${generateGitHubAPIUrl(Constants.DEFAULT_AUTH_OPTIONS.hostname)}`,
-        'GET',
+      const response = await getRootHypermediaLinks(
+        Constants.DEFAULT_AUTH_OPTIONS.hostname,
         accounts.token,
       );
 
diff --git a/src/typesGitHub.ts b/src/typesGitHub.ts
index a48fd0f2..164033fe 100644
--- a/src/typesGitHub.ts
+++ b/src/typesGitHub.ts
@@ -107,6 +107,7 @@ export interface UserProfile {
   two_factor_authentication: boolean;
   plan: Plan;
 }
+
 export interface Plan {
   name: string;
   space: number;
@@ -330,7 +331,8 @@ interface CommitFiles {
   contents_url: string;
   patch: string;
 }
-export interface CommitComments {
+
+export interface CommitComment {
   url: string;
   html_url: string;
   issue_url: string;
@@ -365,7 +367,7 @@ export interface Issue {
   state_reason: IssueStateReasonType | null;
 }
 
-export interface IssueComments {
+export interface IssueOrPullRequestComment {
   url: string;
   html_url: string;
   issue_url: string;
@@ -377,20 +379,22 @@ export interface IssueComments {
   body: string;
 }
 
-export interface ReleaseComments {
+export interface Release {
   url: string;
   assets_url: string;
+  upload_url: string;
   html_url: string;
   id: number;
   author: User;
   node_id: string;
   tag_name: string;
-  name: string;
+  target_commitish: string;
+  name: string | null;
+  body: string | null;
   draft: boolean;
   prerelease: boolean;
   created_at: string;
-  published_at: string;
-  body: string;
+  published_at: string | null;
 }
 
 export interface GraphQLSearch<T> {
@@ -444,3 +448,47 @@ export interface GitHubRESTError {
   message: string;
   documentation_url: string;
 }
+
+export interface NotificationThreadSubscription {
+  subscribed: boolean;
+  ignored: boolean;
+  reason: string | null;
+  created_at: string;
+  url: string;
+  thread_url: string;
+}
+
+export interface RootHypermediaLinks {
+  current_user_url: string;
+  current_user_authorizations_html_url: string;
+  authorizations_url: string;
+  code_search_url: string;
+  commit_search_url: string;
+  emails_url: string;
+  emojis_url: string;
+  events_url: string;
+  feeds_url: string;
+  followers_url: string;
+  following_url: string;
+  gists_url: string;
+  hub_url: string;
+  issue_search_url: string;
+  issues_url: string;
+  keys_url: string;
+  notifications_url: string;
+  organization_url: string;
+  organization_repositories_url: string;
+  organization_teams_url: string;
+  public_gists_url: string;
+  rate_limit_url: string;
+  repository_url: string;
+  repository_search_url: string;
+  current_user_repositories_url: string;
+  starred_url: string;
+  starred_gists_url: string;
+  topic_search_url: string;
+  user_url: string;
+  user_organizations_url: string;
+  user_repositories_url: string;
+  user_search_url: string;
+}
diff --git a/src/utils/api/client.ts b/src/utils/api/client.ts
new file mode 100644
index 00000000..c0b02b7c
--- /dev/null
+++ b/src/utils/api/client.ts
@@ -0,0 +1,215 @@
+import type { AxiosPromise } from 'axios';
+import type { SettingsState } from '../../types';
+import type {
+  Commit,
+  CommitComment,
+  Issue,
+  IssueOrPullRequestComment,
+  Notification,
+  NotificationThreadSubscription,
+  PullRequest,
+  Release,
+  RootHypermediaLinks,
+  UserDetails,
+} from '../../typesGitHub';
+import { getGitHubAPIBaseUrl } from '../helpers';
+import { apiRequestAuth } from './request';
+
+/**
+ * Get Hypermedia links to resources accessible in GitHub's REST API
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/meta/meta#github-api-root
+ */
+export function getRootHypermediaLinks(
+  hostname: string,
+  token: string,
+): AxiosPromise<RootHypermediaLinks> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(baseUrl);
+  return apiRequestAuth(url.toString(), 'GET', token);
+}
+
+/**
+ * Get the authenticated user
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/users/users#get-the-authenticated-user
+ */
+export function getAuthenticatedUser(
+  hostname: string,
+  token: string,
+): AxiosPromise<UserDetails> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(`${baseUrl}/user`);
+  return apiRequestAuth(url.toString(), 'GET', token);
+}
+
+//
+export function headNotifications(
+  hostname: string,
+  token: string,
+): AxiosPromise<void> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(`${baseUrl}/notifications`);
+  return apiRequestAuth(url.toString(), 'HEAD', token);
+}
+
+/**
+ * List all notifications for the current user, sorted by most recently updated.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/activity/notifications#list-notifications-for-the-authenticated-user
+ */
+export function listNotificationsForAuthenticatedUser(
+  hostname: string,
+  token: string,
+  settings: SettingsState,
+): AxiosPromise<Notification[]> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(`${baseUrl}/notifications`);
+  url.searchParams.append('participating', String(settings.participating));
+
+  return apiRequestAuth(url.toString(), 'GET', token);
+}
+
+/**
+ * Marks a thread as "read." Marking a thread as "read" is equivalent to
+ * clicking a notification in your notification inbox on GitHub.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/activity/notifications#mark-a-thread-as-read
+ */
+export function markNotificationThreadAsRead(
+  threadId: string,
+  hostname: string,
+  token: string,
+): AxiosPromise<void> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(`${baseUrl}/notifications/threads/${threadId}`);
+  return apiRequestAuth(url.toString(), 'PATCH', token, {});
+}
+
+/**
+ * Marks a thread as "done." Marking a thread as "done" is equivalent to marking a
+ * notification in your notification inbox on GitHub as done.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/activity/notifications#mark-a-thread-as-done
+ */
+export function markNotificationThreadAsDone(
+  threadId: string,
+  hostname: string,
+  token: string,
+): AxiosPromise<void> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(`${baseUrl}/notifications/threads/${threadId}`);
+  return apiRequestAuth(url.toString(), 'DELETE', token, {});
+}
+
+/**
+ * Ignore future notifications for threads until you comment on the thread or get an`@mention`.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/activity/notifications#delete-a-thread-subscription
+ */
+export function ignoreNotificationThreadSubscription(
+  threadId: string,
+  hostname: string,
+  token: string,
+): AxiosPromise<NotificationThreadSubscription> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(
+    `${baseUrl}/notifications/threads/${threadId}/subscriptions`,
+  );
+  return apiRequestAuth(url.toString(), 'PUT', token, { ignored: true });
+}
+
+/**
+ * Marks all notifications in a repository as "read" for the current user.
+ * If the number of notifications is too large to complete in one request,
+ * you will receive a 202 Accepted status and GitHub will run an asynchronous
+ * process to mark notifications as "read."
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/activity/notifications#mark-repository-notifications-as-read
+ */
+export function markRepositoryNotificationsAsRead(
+  repoSlug: string,
+  hostname: string,
+  token: string,
+): AxiosPromise<void> {
+  const baseUrl = getGitHubAPIBaseUrl(hostname);
+  const url = new URL(`${baseUrl}/repos/${repoSlug}/notifications`);
+  return apiRequestAuth(url.toString(), 'PUT', token, {});
+}
+
+/**
+ * Returns the contents of a single commit reference.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/commits/commits#get-a-commit
+ */
+export function getCommit(url: string, token: string): AxiosPromise<Commit> {
+  return apiRequestAuth(url, 'GET', token);
+}
+
+/**
+ * Gets a specified commit comment.
+ * 
+ * Endpoint documentation: https://docs.github.com/en/rest/commits/comments#get-a-commit-comment
+
+ */
+export function getCommitComment(
+  url: string,
+  token: string,
+): AxiosPromise<CommitComment> {
+  return apiRequestAuth(url, 'GET', token);
+}
+
+/**
+ * Get details of an issue.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/issues/issues#get-an-issue
+ */
+export function getIssue(url: string, token: string): AxiosPromise<Issue> {
+  return apiRequestAuth(url, 'GET', token);
+}
+
+/**
+ * Get comments on issues and pull requests.
+ * Every pull request is an issue, but not every issue is a pull request.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/issues/comments#get-an-issue-comment
+ */
+export function getIssueOrPullRequestComment(
+  url: string,
+  token: string,
+): AxiosPromise<IssueOrPullRequestComment> {
+  return apiRequestAuth(url, 'GET', token);
+}
+
+/**
+ * Get details of a pull request.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
+ */
+export function getPullRequest(
+  url: string,
+  token: string,
+): AxiosPromise<PullRequest> {
+  return apiRequestAuth(url, 'GET', token);
+}
+
+/**
+ * Gets a public release with the specified release ID.
+ *
+ * Endpoint documentation: https://docs.github.com/en/rest/releases/releases#get-a-release
+ */
+export function getRelease(url: string, token: string): AxiosPromise<Release> {
+  return apiRequestAuth(url, 'GET', token);
+}
+
+/**
+ * Get the `html_url` from the GitHub response
+ */
+export async function getHtmlUrl(url: string, token: string): Promise<string> {
+  try {
+    const response = (await apiRequestAuth(url, 'GET', token)).data;
+    return response.html_url;
+  } catch (err) {
+    console.error('Failed to get html url');
+  }
+}
diff --git a/src/utils/auth.ts b/src/utils/auth.ts
index 61b9b4d6..222e1900 100644
--- a/src/utils/auth.ts
+++ b/src/utils/auth.ts
@@ -7,9 +7,10 @@ import type {
   GitifyUser,
 } from '../types';
 import type { UserDetails } from '../typesGitHub';
-import { apiRequest, apiRequestAuth } from '../utils/api-requests';
 import { Constants } from '../utils/constants';
-import { generateGitHubAPIUrl, isEnterpriseHost } from './helpers';
+import { getAuthenticatedUser } from './api/client';
+import { apiRequest } from './api/request';
+import { isEnterpriseHost } from './helpers';
 
 export const authGitHub = (
   authOptions = Constants.DEFAULT_AUTH_OPTIONS,
@@ -82,9 +83,8 @@ export const getUserData = async (
   token: string,
   hostname: string,
 ): Promise<GitifyUser> => {
-  const response: UserDetails = (
-    await apiRequestAuth(`${generateGitHubAPIUrl(hostname)}user`, 'GET', token)
-  ).data;
+  const response: UserDetails = (await getAuthenticatedUser(hostname, token))
+    .data;
 
   return {
     id: response.id,
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index 3ad166fe..1161ac25 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -3,13 +3,11 @@ import type {
   Discussion,
   DiscussionComment,
   GraphQLSearch,
-  Issue,
-  IssueComments,
   Notification,
-  PullRequest,
 } from '../typesGitHub';
-import { apiRequestAuth } from '../utils/api-requests';
 import { openExternalLink } from '../utils/comms';
+import { getHtmlUrl } from './api/client';
+import { apiRequestAuth } from './api/request';
 import { Constants } from './constants';
 import { getCheckSuiteAttributes, getWorkflowRunAttributes } from './subject';
 
@@ -37,11 +35,11 @@ export function isEnterpriseHost(hostname: string): boolean {
   return !hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname);
 }
 
-export function generateGitHubAPIUrl(hostname) {
+export function getGitHubAPIBaseUrl(hostname) {
   const isEnterprise = isEnterpriseHost(hostname);
   return isEnterprise
-    ? `https://${hostname}/api/v3/`
-    : `https://api.${hostname}/`;
+    ? `https://${hostname}/api/v3`
+    : `https://api.${hostname}`;
 }
 
 export function addNotificationReferrerIdToUrl(
@@ -81,17 +79,6 @@ export function formatSearchQueryString(
   return `${title} in:title repo:${repo} updated:>${addHours(lastUpdated, -2)}`;
 }
 
-export async function getHtmlUrl(url: string, token: string): Promise<string> {
-  try {
-    const response: Issue | IssueComments | PullRequest = (
-      await apiRequestAuth(url, 'GET', token)
-    ).data;
-    return response.html_url;
-  } catch (err) {
-    console.error('Failed to get html url');
-  }
-}
-
 export function getCheckSuiteUrl(notification: Notification) {
   let url = `${notification.repository.html_url}/actions`;
   const filters = [];
diff --git a/src/utils/subject.ts b/src/utils/subject.ts
index 469ea3b9..0a49fc31 100644
--- a/src/utils/subject.ts
+++ b/src/utils/subject.ts
@@ -1,20 +1,22 @@
 import type {
   CheckSuiteAttributes,
   CheckSuiteStatus,
-  Commit,
   DiscussionStateType,
   GitifySubject,
-  Issue,
-  IssueComments,
   Notification,
-  PullRequest,
   PullRequestStateType,
-  ReleaseComments,
   SubjectUser,
   User,
   WorkflowRunAttributes,
 } from '../typesGitHub';
-import { apiRequestAuth } from './api-requests';
+import {
+  getCommit,
+  getCommitComment,
+  getIssue,
+  getIssueOrPullRequestComment,
+  getPullRequest,
+  getRelease,
+} from './api/client';
 import { fetchDiscussion, getLatestDiscussionComment } from './helpers';
 
 export async function getGitifySubjectDetails(
@@ -105,23 +107,31 @@ async function getGitifySubjectForCommit(
   token: string,
 ): Promise<GitifySubject> {
   try {
-    const commit: Commit = (
-      await apiRequestAuth(notification.subject.url, 'GET', token)
-    ).data;
+    let user: User;
 
-    const commitCommentUser = await getLatestCommentUser(notification, token);
+    if (notification.subject.latest_comment_url) {
+      const commitComment = (
+        await getCommitComment(notification.subject.latest_comment_url, token)
+      ).data;
+
+      user = commitComment.user;
+    } else {
+      const commit = (await getCommit(notification.subject.url, token)).data;
+
+      user = commit.author;
+    }
 
     return {
       state: null,
       user: {
-        login: commitCommentUser?.login ?? commit.author.login,
-        html_url: commitCommentUser?.html_url ?? commit.author.html_url,
-        avatar_url: commitCommentUser?.avatar_url ?? commit.author.avatar_url,
-        type: commitCommentUser?.type ?? commit.author.type,
+        login: user.login,
+        html_url: user.html_url,
+        avatar_url: user.avatar_url,
+        type: user.type,
       },
     };
   } catch (err) {
-    console.error('Issue subject retrieval failed');
+    console.error('Commit subject retrieval failed');
   }
 }
 
@@ -172,11 +182,19 @@ async function getGitifySubjectForIssue(
   token: string,
 ): Promise<GitifySubject> {
   try {
-    const issue: Issue = (
-      await apiRequestAuth(notification.subject.url, 'GET', token)
-    ).data;
-
-    const issueCommentUser = await getLatestCommentUser(notification, token);
+    const issue = (await getIssue(notification.subject.url, token)).data;
+
+    let issueCommentUser: User;
+
+    if (notification.subject.latest_comment_url) {
+      const issueComment = (
+        await getIssueOrPullRequestComment(
+          notification.subject.latest_comment_url,
+          token,
+        )
+      ).data;
+      issueCommentUser = issueComment.user;
+    }
 
     return {
       state: issue.state_reason ?? issue.state,
@@ -197,9 +215,7 @@ async function getGitifySubjectForPullRequest(
   token: string,
 ): Promise<GitifySubject> {
   try {
-    const pr: PullRequest = (
-      await apiRequestAuth(notification.subject.url, 'GET', token)
-    ).data;
+    const pr = (await getPullRequest(notification.subject.url, token)).data;
 
     let prState: PullRequestStateType = pr.state;
     if (pr.merged) {
@@ -208,7 +224,17 @@ async function getGitifySubjectForPullRequest(
       prState = 'draft';
     }
 
-    const prCommentUser = await getLatestCommentUser(notification, token);
+    let prCommentUser: User;
+
+    if (notification.subject.latest_comment_url) {
+      const prComment = (
+        await getIssueOrPullRequestComment(
+          notification.subject.latest_comment_url,
+          token,
+        )
+      ).data;
+      prCommentUser = prComment.user;
+    }
 
     return {
       state: prState,
@@ -228,15 +254,15 @@ async function getGitifySubjectForRelease(
   notification: Notification,
   token: string,
 ): Promise<GitifySubject> {
-  const releaseCommentUser = await getLatestCommentUser(notification, token);
+  const release = (await getRelease(notification.subject.url, token)).data;
 
   return {
     state: null,
     user: {
-      login: releaseCommentUser.login,
-      html_url: releaseCommentUser.html_url,
-      avatar_url: releaseCommentUser.avatar_url,
-      type: releaseCommentUser.type,
+      login: release.author.login,
+      html_url: release.author.html_url,
+      avatar_url: release.author.avatar_url,
+      type: release.author.type,
     },
   };
 }
@@ -289,28 +315,3 @@ function getWorkflowRunStatus(statusDisplayName: string): CheckSuiteStatus {
       return null;
   }
 }
-
-async function getLatestCommentUser(
-  notification: Notification,
-  token: string,
-): Promise<User> | null {
-  if (!notification.subject.latest_comment_url) {
-    return null;
-  }
-
-  try {
-    const response: IssueComments | ReleaseComments = (
-      await apiRequestAuth(
-        notification.subject.latest_comment_url,
-        'GET',
-        token,
-      )
-    )?.data;
-
-    return (
-      (response as IssueComments)?.user ?? (response as ReleaseComments).author
-    );
-  } catch (err) {
-    console.error('Discussion latest comment retrieval failed');
-  }
-}


Test patch:
diff --git a/src/context/App.test.tsx b/src/context/App.test.tsx
index 3280d577..6e004344 100644
--- a/src/context/App.test.tsx
+++ b/src/context/App.test.tsx
@@ -4,7 +4,7 @@ import { useContext } from 'react';
 import { mockAccounts, mockSettings } from '../__mocks__/mock-state';
 import { useNotifications } from '../hooks/useNotifications';
 import type { AuthState, SettingsState } from '../types';
-import * as apiRequests from '../utils/api-requests';
+import * as apiRequests from '../utils/api/request';
 import * as comms from '../utils/comms';
 import Constants from '../utils/constants';
 import * as notifications from '../utils/notifications';
diff --git a/src/routes/Settings.test.tsx b/src/routes/Settings.test.tsx
index f19b2955..94b8e5e2 100644
--- a/src/routes/Settings.test.tsx
+++ b/src/routes/Settings.test.tsx
@@ -8,7 +8,7 @@ import type { AxiosResponse } from 'axios';
 import { shell } from 'electron';
 import { mockAccounts, mockSettings } from '../__mocks__/mock-state';
 import { AppContext } from '../context/App';
-import * as apiRequests from '../utils/api-requests';
+import * as apiRequests from '../utils/api/request';
 import Constants from '../utils/constants';
 import { SettingsRoute } from './Settings';
 
diff --git a/src/utils/api/__snapshots__/client.test.ts.snap b/src/utils/api/__snapshots__/client.test.ts.snap
new file mode 100644
index 00000000..85d09f88
--- /dev/null
+++ b/src/utils/api/__snapshots__/client.test.ts.snap
@@ -0,0 +1,145 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`utils/api/client.ts getAuthenticatedUser should fetch authenticated user - enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts getAuthenticatedUser should fetch authenticated user - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts getRootHypermediaLinks should fetch root hypermedia links - enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts getRootHypermediaLinks should fetch root hypermedia links - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts headNotifications should fetch notifications head - enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts headNotifications should fetch notifications head - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts ignoreNotificationThreadSubscription should ignore notification thread subscription - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts ignoreNotificationThreadSubscription should ignore notification thread subscription- enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts listNotificationsForAuthenticatedUser should list notifications for user - enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts listNotificationsForAuthenticatedUser should list notifications for user - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts markNotificationThreadAsDone should mark notification thread as done - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts markNotificationThreadAsDone should mark notification thread as done- enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts markNotificationThreadAsRead should mark notification thread as read - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts markNotificationThreadAsRead should mark notification thread as read- enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts markRepositoryNotificationsAsRead should mark repository notifications as read - enterprise 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
+
+exports[`utils/api/client.ts markRepositoryNotificationsAsRead should mark repository notifications as read - github 1`] = `
+{
+  "Accept": "application/json",
+  "Authorization": "token yourAuthToken",
+  "Cache-Control": "no-cache",
+  "Content-Type": "application/json",
+}
+`;
diff --git a/src/utils/__snapshots__/api-requests.test.ts.snap b/src/utils/api/__snapshots__/request.test.ts.snap
similarity index 79%
rename from src/utils/__snapshots__/api-requests.test.ts.snap
rename to src/utils/api/__snapshots__/request.test.ts.snap
index 420db2eb..bc9cdb84 100644
--- a/src/utils/__snapshots__/api-requests.test.ts.snap
+++ b/src/utils/api/__snapshots__/request.test.ts.snap
@@ -18,7 +18,7 @@ exports[`apiRequestAuth should make an authenticated request with the correct pa
 }
 `;
 
-exports[`utils/api-requests.ts should make a request with the correct parameters 1`] = `
+exports[`utils/api/request.ts should make a request with the correct parameters 1`] = `
 {
   "Accept": "application/json",
   "Cache-Control": "no-cache",
@@ -26,7 +26,7 @@ exports[`utils/api-requests.ts should make a request with the correct parameters
 }
 `;
 
-exports[`utils/api-requests.ts should make a request with the correct parameters and default data 1`] = `
+exports[`utils/api/request.ts should make a request with the correct parameters and default data 1`] = `
 {
   "Accept": "application/json",
   "Cache-Control": "no-cache",
diff --git a/src/utils/api/client.test.ts b/src/utils/api/client.test.ts
new file mode 100644
index 00000000..f0cda8ba
--- /dev/null
+++ b/src/utils/api/client.test.ts
@@ -0,0 +1,302 @@
+import axios, { type AxiosPromise, type AxiosResponse } from 'axios';
+import type { SettingsState } from '../../types';
+import {
+  getAuthenticatedUser,
+  getHtmlUrl,
+  getRootHypermediaLinks,
+  headNotifications,
+  ignoreNotificationThreadSubscription,
+  listNotificationsForAuthenticatedUser,
+  markNotificationThreadAsDone,
+  markNotificationThreadAsRead,
+  markRepositoryNotificationsAsRead,
+} from './client';
+import * as apiRequests from './request';
+
+jest.mock('axios');
+
+const mockGitHubHostname = 'github.com';
+const mockEnterpriseHostname = 'example.com';
+const mockToken = 'yourAuthToken';
+const mockThreadId = '1234';
+const mockRepoSlug = 'gitify-app/notification-test';
+
+describe('utils/api/client.ts', () => {
+  afterEach(() => {
+    jest.resetAllMocks();
+  });
+
+  describe('getRootHypermediaLinks', () => {
+    it('should fetch root hypermedia links - github', async () => {
+      await getRootHypermediaLinks(mockGitHubHostname, mockToken);
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://api.github.com/',
+        method: 'GET',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should fetch root hypermedia links - enterprise', async () => {
+      await getRootHypermediaLinks(mockEnterpriseHostname, mockToken);
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://example.com/api/v3',
+        method: 'GET',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('getAuthenticatedUser', () => {
+    it('should fetch authenticated user - github', async () => {
+      await getAuthenticatedUser(mockGitHubHostname, mockToken);
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://api.github.com/user',
+        method: 'GET',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should fetch authenticated user - enterprise', async () => {
+      await getAuthenticatedUser(mockEnterpriseHostname, mockToken);
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://example.com/api/v3/user',
+        method: 'GET',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('headNotifications', () => {
+    it('should fetch notifications head - github', async () => {
+      await headNotifications(mockGitHubHostname, mockToken);
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://api.github.com/notifications',
+        method: 'HEAD',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should fetch notifications head - enterprise', async () => {
+      await headNotifications(mockEnterpriseHostname, mockToken);
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://example.com/api/v3/notifications',
+        method: 'HEAD',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('listNotificationsForAuthenticatedUser', () => {
+    const mockSettings: Partial<SettingsState> = {
+      participating: true,
+    };
+
+    it('should list notifications for user - github', async () => {
+      await listNotificationsForAuthenticatedUser(
+        mockGitHubHostname,
+        mockToken,
+        mockSettings as SettingsState,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://api.github.com/notifications?participating=true',
+        method: 'GET',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should list notifications for user - enterprise', async () => {
+      await listNotificationsForAuthenticatedUser(
+        mockEnterpriseHostname,
+        mockToken,
+        mockSettings as SettingsState,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: 'https://example.com/api/v3/notifications?participating=true',
+        method: 'GET',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('markNotificationThreadAsRead', () => {
+    it('should mark notification thread as read - github', async () => {
+      await markNotificationThreadAsRead(
+        mockThreadId,
+        mockGitHubHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://api.github.com/notifications/threads/${mockThreadId}`,
+        method: 'PATCH',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should mark notification thread as read- enterprise', async () => {
+      await markNotificationThreadAsRead(
+        mockThreadId,
+        mockEnterpriseHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://example.com/api/v3/notifications/threads/${mockThreadId}`,
+        method: 'PATCH',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('markNotificationThreadAsDone', () => {
+    it('should mark notification thread as done - github', async () => {
+      await markNotificationThreadAsDone(
+        mockThreadId,
+        mockGitHubHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://api.github.com/notifications/threads/${mockThreadId}`,
+        method: 'DELETE',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should mark notification thread as done- enterprise', async () => {
+      await markNotificationThreadAsDone(
+        mockThreadId,
+        mockEnterpriseHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://example.com/api/v3/notifications/threads/${mockThreadId}`,
+        method: 'DELETE',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('ignoreNotificationThreadSubscription', () => {
+    it('should ignore notification thread subscription - github', async () => {
+      await ignoreNotificationThreadSubscription(
+        mockThreadId,
+        mockGitHubHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://api.github.com/notifications/threads/${mockThreadId}/subscriptions`,
+        method: 'PUT',
+        data: { ignored: true },
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should ignore notification thread subscription- enterprise', async () => {
+      await ignoreNotificationThreadSubscription(
+        mockThreadId,
+        mockEnterpriseHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://example.com/api/v3/notifications/threads/${mockThreadId}/subscriptions`,
+        method: 'PUT',
+        data: { ignored: true },
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('markRepositoryNotificationsAsRead', () => {
+    it('should mark repository notifications as read - github', async () => {
+      await markRepositoryNotificationsAsRead(
+        mockRepoSlug,
+        mockGitHubHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://api.github.com/repos/${mockRepoSlug}/notifications`,
+        method: 'PUT',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+
+    it('should mark repository notifications as read - enterprise', async () => {
+      await markRepositoryNotificationsAsRead(
+        mockRepoSlug,
+        mockEnterpriseHostname,
+        mockToken,
+      );
+
+      expect(axios).toHaveBeenCalledWith({
+        url: `https://example.com/api/v3/repos/${mockRepoSlug}/notifications`,
+        method: 'PUT',
+        data: {},
+      });
+
+      expect(axios.defaults.headers.common).toMatchSnapshot();
+    });
+  });
+
+  describe('getHtmlUrl', () => {
+    it('should return the HTML URL', async () => {
+      const apiRequestAuthMock = jest.spyOn(apiRequests, 'apiRequestAuth');
+
+      const requestPromise = new Promise((resolve) =>
+        resolve({
+          data: {
+            html_url: 'https://github.com/gitify-app/gitify/issues/785',
+          },
+        } as AxiosResponse),
+      ) as AxiosPromise;
+
+      apiRequestAuthMock.mockResolvedValue(requestPromise);
+
+      const result = await getHtmlUrl(
+        'https://api.github.com/repos/gitify-app/gitify/issues/785',
+        '123',
+      );
+      expect(result).toBe('https://github.com/gitify-app/gitify/issues/785');
+    });
+  });
+});
diff --git a/src/utils/api-requests.test.ts b/src/utils/api/request.test.ts
similarity index 93%
rename from src/utils/api-requests.test.ts
rename to src/utils/api/request.test.ts
index 7d0bc663..45b5ee7a 100644
--- a/src/utils/api-requests.test.ts
+++ b/src/utils/api/request.test.ts
@@ -1,12 +1,12 @@
 import axios from 'axios';
-import { apiRequest, apiRequestAuth } from './api-requests';
+import { apiRequest, apiRequestAuth } from './request';
 
 jest.mock('axios');
 
 const url = 'https://example.com';
 const method = 'get';
 
-describe('utils/api-requests.ts', () => {
+describe('utils/api/request.ts', () => {
   afterEach(() => {
     jest.resetAllMocks();
   });
diff --git a/src/utils/api-requests.ts b/src/utils/api/request.ts
similarity index 100%
rename from src/utils/api-requests.ts
rename to src/utils/api/request.ts
diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts
index fc80b045..dc6d9d22 100644
--- a/src/utils/auth.test.ts
+++ b/src/utils/auth.test.ts
@@ -4,7 +4,7 @@ import remote from '@electron/remote';
 const browserWindow = new remote.BrowserWindow();
 
 import type { AuthState } from '../types';
-import * as apiRequests from './api-requests';
+import * as apiRequests from './api/request';
 import * as auth from './auth';
 
 describe('utils/auth.tsx', () => {
diff --git a/src/utils/helpers.test.ts b/src/utils/helpers.test.ts
index 4fa85c67..54a1909a 100644
--- a/src/utils/helpers.test.ts
+++ b/src/utils/helpers.test.ts
@@ -6,16 +6,15 @@ import {
   mockedUser,
 } from '../__mocks__/mockedData';
 import type { SubjectType } from '../typesGitHub';
-import * as apiRequests from './api-requests';
+import * as apiRequests from './api/request';
 import {
   addHours,
   addNotificationReferrerIdToUrl,
   formatForDisplay,
   formatSearchQueryString,
-  generateGitHubAPIUrl,
   generateGitHubWebUrl,
   generateNotificationReferrerId,
-  getHtmlUrl,
+  getGitHubAPIBaseUrl,
   isEnterpriseHost,
   isGitHubLoggedIn,
 } from './helpers';
@@ -93,13 +92,13 @@ describe('utils/helpers.ts', () => {
 
   describe('generateGitHubAPIUrl', () => {
     it('should generate a GitHub API url - non enterprise', () => {
-      const result = generateGitHubAPIUrl('github.com');
-      expect(result).toBe('https://api.github.com/');
+      const result = getGitHubAPIBaseUrl('github.com');
+      expect(result).toBe('https://api.github.com');
     });
 
     it('should generate a GitHub API url - enterprise', () => {
-      const result = generateGitHubAPIUrl('github.manos.im');
-      expect(result).toBe('https://github.manos.im/api/v3/');
+      const result = getGitHubAPIBaseUrl('github.manos.im');
+      expect(result).toBe('https://github.manos.im/api/v3');
     });
   });
 
@@ -130,32 +129,6 @@ describe('utils/helpers.ts', () => {
     });
   });
 
-  describe('getHtmlUrl', () => {
-    const apiRequestAuthMock = jest.spyOn(apiRequests, 'apiRequestAuth');
-
-    afterEach(() => {
-      jest.clearAllMocks();
-    });
-
-    it('should return the HTML URL', async () => {
-      const requestPromise = new Promise((resolve) =>
-        resolve({
-          data: {
-            html_url: 'https://github.com/gitify-app/gitify/issues/785',
-          },
-        } as AxiosResponse),
-      ) as AxiosPromise;
-
-      apiRequestAuthMock.mockResolvedValue(requestPromise);
-
-      const result = await getHtmlUrl(
-        'https://api.github.com/repos/gitify-app/gitify/issues/785',
-        '123',
-      );
-      expect(result).toBe('https://github.com/gitify-app/gitify/issues/785');
-    });
-  });
-
   describe('generateGitHubWebUrl', () => {
     const mockedHtmlUrl = 'https://github.com/gitify-app/gitify/issues/785';
     const mockedNotificationReferrer =