diff --git a/.vscode/launch.json b/.vscode/launch.json index 9e810bb..8a1ea42 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,7 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "2.0.1", + "version": "3.0.0", "configurations": [ { "name": "Run Extension", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3bbc029..d3aacba 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,7 +1,7 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format { - "version": "2.0.1", + "version": "2.0.0", "tasks": [ { "type": "npm", diff --git a/package.json b/package.json index 7144003..265d2c6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publisher": "Pullflow", "displayName": "Pullflow", "description": "Code review collaboration across GitHub, Slack, and VS Code.", - "version": "2.0.1", + "version": "3.0.0", "preview": true, "license": "MIT", "engines": { diff --git a/src/api/messageApi.ts b/src/api/messageApi.ts index 6467fce..81a10e5 100644 --- a/src/api/messageApi.ts +++ b/src/api/messageApi.ts @@ -22,13 +22,13 @@ export const MessageApi = { body, parentMessageXid, chatChannelId, - authToken, + accessToken, context, }: { body: string parentMessageXid: string chatChannelId: string - authToken: string + accessToken: string context: ExtensionContext }) => { log.info( @@ -38,7 +38,7 @@ export const MessageApi = { module ) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(SEND_CODE_REVIEW_THREAD_MESSAGE, { body, @@ -57,14 +57,14 @@ export const MessageApi = { codeAccountXid, codeReviewId, chatLink, - authToken, + accessToken, message, fromAuthor, context, }: { codeAccountXid: string chatLink: string - authToken: string + accessToken: string codeReviewId: string message: string fromAuthor: boolean @@ -72,7 +72,7 @@ export const MessageApi = { }) => { log.info(`nudging user: ${{ codeAccountXid }}, ${{ chatLink }}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(SEND_CODE_REVIEW_DIRECT_MESSAGE, { codeAccountXid, diff --git a/src/api/presenceApi.ts b/src/api/presenceApi.ts index 065bca4..625ed20 100644 --- a/src/api/presenceApi.ts +++ b/src/api/presenceApi.ts @@ -14,18 +14,18 @@ const SET_USER_PRESENCE = ` export const PresenceApi = { setPresence: async ({ status, - authToken, + accessToken, repoInfo, context, }: { status: PresenceStatus - authToken: string + accessToken: string repoInfo: RepoInfo context: ExtensionContext }) => { log.info(`setting user presence: ${{ status }}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(SET_USER_PRESENCE, { status, diff --git a/src/api/pullRequestQuickActionsApi.ts b/src/api/pullRequestQuickActionsApi.ts index 3a2cdf1..969ab4c 100644 --- a/src/api/pullRequestQuickActionsApi.ts +++ b/src/api/pullRequestQuickActionsApi.ts @@ -61,19 +61,19 @@ mutation setCodeReviewReminder($codeReviewId: String!, $duration: Int!) { export const PullRequestQuickActionsApi = { addLabels: async ({ - authToken, + accessToken, labels, codeReviewId, context, }: { - authToken: string + accessToken: string labels: string codeReviewId: string context: ExtensionContext }) => { log.info(`adding labels to pull request: ${{ labels }}}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(ADD_LABELS, { labels, @@ -88,19 +88,19 @@ export const PullRequestQuickActionsApi = { }, approve: async ({ - authToken, + accessToken, codeReviewId, body, context, }: { - authToken: string + accessToken: string codeReviewId: string body: string context: ExtensionContext }) => { log.info(`approving pull request: ${{ codeReviewId }}}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(APPROVE_CODE_REVIEW, { codeReviewId, @@ -115,19 +115,19 @@ export const PullRequestQuickActionsApi = { }, addAssignee: async ({ - authToken, + accessToken, codeReviewId, assigneeXid, context, }: { - authToken: string + accessToken: string codeReviewId: string assigneeXid: string context: ExtensionContext }) => { log.info(`add assignee to pull request: ${{ assigneeXid }}}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(ADD_ASSIGNEE, { codeReviewId, @@ -144,17 +144,17 @@ export const PullRequestQuickActionsApi = { requestReview: async ({ codeReviewId, reviewerXid, - authToken, + accessToken, context, }: { codeReviewId: string reviewerXid: string - authToken: string + accessToken: string context: ExtensionContext }) => { log.info(`requesting review: ${{ reviewerXid }}}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(REQUEST_REVIEW, { codeReviewId, @@ -170,16 +170,16 @@ export const PullRequestQuickActionsApi = { refresh: async ({ codeReviewId, - authToken, + accessToken, context, }: { codeReviewId: string - authToken: string + accessToken: string context: ExtensionContext }) => { log.info(`requesting review: ${{ codeReviewId }}}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(REFRESH_CODE_REVIEW, { codeReviewId, @@ -194,17 +194,17 @@ export const PullRequestQuickActionsApi = { setReminder: async ({ duration, codeReviewId, - authToken, + accessToken, context, }: { duration: number codeReviewId: string - authToken: string + accessToken: string context: ExtensionContext }) => { log.info(`setting reminder: ${{ duration, codeReviewId }}}`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(SET_REMINDER, { codeReviewId, diff --git a/src/api/spaceUsersApi.ts b/src/api/spaceUsersApi.ts index a6e2cb5..1ef3583 100644 --- a/src/api/spaceUsersApi.ts +++ b/src/api/spaceUsersApi.ts @@ -16,17 +16,17 @@ const SPACE_USERS_QUERY = `query spaceUsersForVscode($codeReviewId: String!) { ` export const SpaceUsersApi = { get: async ({ - authToken, + accessToken, codeReviewId, context, }: { - authToken: string + accessToken: string codeReviewId: string context: ExtensionContext }) => { log.info(`fetching space users`, module) - const pullflowApi = new PullflowApi(context, authToken) + const pullflowApi = new PullflowApi(context, accessToken) try { const data = await pullflowApi.fetch(SPACE_USERS_QUERY, { codeReviewId }) return data.spaceUsersForVscode diff --git a/src/commands/reconnect.ts b/src/commands/reconnect.ts index 886cf04..26c70b3 100644 --- a/src/commands/reconnect.ts +++ b/src/commands/reconnect.ts @@ -35,9 +35,7 @@ export const Reconnect = async ( if (codeReviews.error) { log.info(`Failed to reconnect`, module) - window.showInformationMessage( - `Pullflow: Failed to reconnect. Please check your internet connection and try again.` - ) + StatusBar.update({ context, statusBar, state: StatusBarState.Error }) return } diff --git a/src/commands/signOut.ts b/src/commands/signOut.ts index bff5ece..41a7b95 100644 --- a/src/commands/signOut.ts +++ b/src/commands/signOut.ts @@ -21,6 +21,7 @@ export const SignOut = async ({ } }) => { await context.secrets.store(AppConfig.app.sessionSecret, '') + await context.secrets.store('userRefreshToken', '') await Store.clear(context) StatusBar.update({ context, statusBar, state: StatusBarState.SignedOut }) clearInterval(pollIntervalId) // stopping polling interval diff --git a/src/extension.ts b/src/extension.ts index 845ec1d..816ca48 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -33,12 +33,13 @@ export async function activate(context: ExtensionContext) { const query = new URLSearchParams(uri.query) const user = { username: query.get('username') as string, - authToken: query.get('accessToken') as string, + accessToken: query.get('accessToken') as string, + refreshToken: query.get('refreshToken') as string, } await Store.set(context, { user, }) - if (user.authToken) { + if (user.accessToken) { await Authorization.createSession({ user, context }) await initialize({ context, statusBar }) } diff --git a/src/messages/messagePublisher.ts b/src/messages/messagePublisher.ts index c638e73..9583ab7 100644 --- a/src/messages/messagePublisher.ts +++ b/src/messages/messagePublisher.ts @@ -34,7 +34,7 @@ export const MessagePublisher = { body: inputText || "I'm on it.", parentMessageXid: codeReview.parentMessageXid, chatChannelId: codeReview.chatChannelId, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) @@ -98,7 +98,7 @@ export const MessagePublisher = { message, codeAccountXid: toAccount.xid, chatLink, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', codeReviewId, fromAuthor, context, diff --git a/src/models/presence.ts b/src/models/presence.ts index 8d9db79..dc9b6c9 100644 --- a/src/models/presence.ts +++ b/src/models/presence.ts @@ -29,7 +29,7 @@ export const Presence = { const response = await PresenceApi.setPresence({ status, - authToken: session.accessToken, + accessToken: session.accessToken, context, repoInfo, }) diff --git a/src/models/spaceUsers.ts b/src/models/spaceUsers.ts index 6d4ec60..0d77c86 100644 --- a/src/models/spaceUsers.ts +++ b/src/models/spaceUsers.ts @@ -13,7 +13,7 @@ export const SpaceUsers = { const session = await Authorization.currentSession(context) const response = await SpaceUsersApi.get({ codeReviewId, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) diff --git a/src/pullRequestQuickActions/pullRequestQuickActions.test.ts b/src/pullRequestQuickActions/pullRequestQuickActions.test.ts index 6d25b72..54ac356 100644 --- a/src/pullRequestQuickActions/pullRequestQuickActions.test.ts +++ b/src/pullRequestQuickActions/pullRequestQuickActions.test.ts @@ -31,7 +31,7 @@ describe('Pull Request Quick Actions', () => { ).toHaveBeenCalledWith({ body: mockText, codeReviewId: mockModel.id, - authToken: mockSession.accessToken, + accessToken: mockSession.accessToken, context: mockModel, }) expect(mockPresence.Presence.set).toBeCalled() diff --git a/src/pullRequestQuickActions/pullRequestQuickActions.ts b/src/pullRequestQuickActions/pullRequestQuickActions.ts index ba17918..f8f2a38 100644 --- a/src/pullRequestQuickActions/pullRequestQuickActions.ts +++ b/src/pullRequestQuickActions/pullRequestQuickActions.ts @@ -34,7 +34,7 @@ export const PullRequestQuickActions = { const response = await PullRequestQuickActionsApi.addLabels({ labels: inputText, codeReviewId: codeReview.id, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) @@ -80,7 +80,7 @@ export const PullRequestQuickActions = { const response = await PullRequestQuickActionsApi.approve({ body: inputText, codeReviewId: codeReview.id, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) @@ -139,7 +139,7 @@ export const PullRequestQuickActions = { const response = await PullRequestQuickActionsApi.addAssignee({ assigneeXid: item[0].description, codeReviewId: codeReview.id, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) @@ -198,7 +198,7 @@ export const PullRequestQuickActions = { const response = await PullRequestQuickActionsApi.requestReview({ reviewerXid: item[0].description, codeReviewId: codeReview.id, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) @@ -244,7 +244,7 @@ export const PullRequestQuickActions = { const session = await Authorization.currentSession(context) const response = await PullRequestQuickActionsApi.refresh({ codeReviewId, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) if (response.error || response.message) { @@ -287,7 +287,7 @@ export const PullRequestQuickActions = { const response = await PullRequestQuickActionsApi.setReminder({ codeReviewId: codeReview.id, duration, - authToken: session?.accessToken ?? '', + accessToken: session?.accessToken ?? '', context, }) if (response.message || response.error || !response.success) { diff --git a/src/utils/authorization.ts b/src/utils/authorization.ts index 59dfb9d..e3d24ed 100644 --- a/src/utils/authorization.ts +++ b/src/utils/authorization.ts @@ -14,7 +14,7 @@ export const Authorization = { }) => { const session: AuthenticationSession = { id: user.username, - accessToken: user.authToken, + accessToken: user.accessToken, account: { label: user.username, id: user.username, @@ -25,6 +25,7 @@ export const Authorization = { AppConfig.app.sessionSecret, JSON.stringify(session) ) + await context.secrets.store('userRefreshToken', user.refreshToken) return session }, diff --git a/src/utils/pullRequestsState.ts b/src/utils/pullRequestsState.ts index c0c5d24..b8b18d1 100644 --- a/src/utils/pullRequestsState.ts +++ b/src/utils/pullRequestsState.ts @@ -9,7 +9,7 @@ import { log } from './logger' const MAX_ERROR_COUNT = 3 const module = 'pullRequestState.ts' -const DELAY_TIME = 30000 // 3 seconds +const DELAY_TIME = 30000 // 30 seconds export const PullRequestState = { update: async ({ diff --git a/src/utils/pullflowApi.ts b/src/utils/pullflowApi.ts index c4dbe3c..7490343 100644 --- a/src/utils/pullflowApi.ts +++ b/src/utils/pullflowApi.ts @@ -1,11 +1,29 @@ -import { ExtensionContext } from 'vscode' -import fetch from 'node-fetch' +import { commands, ExtensionContext, window } from 'vscode' +import fetch, { RequestInit } from 'node-fetch' import { ApiVariables } from './types' import { AppConfig } from './appConfig' +import { log } from '../utils/logger' +import { Authorization } from './authorization' +import { Command } from './commands' +const module = 'pullflowApi.ts' + +const TOKEN_REFRESH_QUERY = `mutation requestTokenRefresh($token: String!) { + requestTokenRefresh(token: $token) { + data { accessToken refreshToken } + error + } +} +` + +type PullflowResponse = { + errors: { extensions: { code: string }; message: string }[] + data: unknown +} export class PullflowApi { apiUrl: string - options: {} + options: { method: string; headers: {} } + context: ExtensionContext constructor( context: ExtensionContext, @@ -24,6 +42,69 @@ export class PullflowApi { }, } this.apiUrl = AppConfig.pullflow.graphqlUrl + this.context = context + } + + async refreshToken() { + log.info('refreshing access token', module) + const refreshToken = await this.context.secrets.get('userRefreshToken') + + if (!refreshToken) { + log.error('No refresh token in store', module) + window.showInformationMessage(`Something went wrong. Please login again.`) + commands.executeCommand(Command.signOut) + return + } + + const response = await fetch(this.apiUrl, { + ...this.options, + body: JSON.stringify({ + query: TOKEN_REFRESH_QUERY, + variables: { token: refreshToken }, + }), + }) + + if (!response.ok) { + log.error('Invalid response from refresh request', module) + window.showInformationMessage(`Something went wrong. Please login again.`) + commands.executeCommand(Command.signOut) + return + } + const jsonResponse = (await response.json()) as { + data: { + requestTokenRefresh: { + data?: { accessToken: string; refreshToken: string } + error?: string + } + } + } + const { data, error } = jsonResponse.data.requestTokenRefresh + + if (error || !data) { + log.error(error ? error : 'No data returned', module) + window.showInformationMessage(`Something went wrong. Please login again.`) + commands.executeCommand(Command.signOut) + return + } + + const { refreshToken: newRefreshToken, accessToken: newAccessToken } = data + await this.context.secrets.store('userRefreshToken', newRefreshToken) + const currentSession = await Authorization.currentSession(this.context) + + await this.context.secrets.store( + AppConfig.app.sessionSecret, + JSON.stringify({ ...currentSession, accessToken: newAccessToken }) + ) + + this.options = { + ...this.options, + headers: { + ...this.options?.headers, + authorization: `Bearer ${newAccessToken}`, + }, + } + + return this.options } async fetch(query: string, variables?: ApiVariables) { @@ -34,10 +115,35 @@ export class PullflowApi { ...(variables && { variables }), }), } - const response = await fetch(this.apiUrl, options) - if (!response.ok) throw new Error(response.statusText) - const data: any = await response.json() - if (data.errors) throw new Error(data.errors[0].message) - return data.data + + const sendRequest = async (opt: RequestInit | undefined) => { + const response = await fetch(this.apiUrl, opt) + if (!response.ok) throw new Error(response.statusText) + const data = (await response.json()) as PullflowResponse + if (data.errors) { + if (data.errors[0]?.extensions?.code === 'UNAUTHENTICATED') + return { error: 'UNAUTHENTICATED' } + return { error: data.errors[0].message } // return the message + } + return { data } + } + + const sendRequestWithRetry = async (opt: RequestInit | undefined) => { + const response = await sendRequest(opt) + if ('error' in response && response.error === 'UNAUTHENTICATED') { + const updatedOpts: any = await this.refreshToken() + return sendRequest({ ...opt, ...updatedOpts }) + } + return response + } + + const response: { data: PullflowResponse } | { error: string } = + query.includes('presence') + ? await sendRequest(options) + : await sendRequestWithRetry(options) + + if ('error' in response) throw new Error(response.error) + + return response.data.data as any // TODO: Fix later; to avoid errors for now } } diff --git a/src/utils/types.ts b/src/utils/types.ts index 8567e3f..96f1652 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -2,7 +2,8 @@ import { QuickPickItem } from 'vscode' export type User = { username: string - authToken: string + accessToken: string + refreshToken: string } export type ChatChannel = { id: string