diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index cc1ee0d89..91f729b9b 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -72,7 +72,10 @@ import { defaultAuth, defaultSettings } from './defaults'; export interface AppContextState { auth: AuthState; isLoggedIn: boolean; - loginWithDeviceFlowStart: (hostname?: Hostname) => Promise; + loginWithDeviceFlowStart: ( + hostname?: Hostname, + scopes?: string[], + ) => Promise; loginWithDeviceFlowPoll: ( session: DeviceFlowSession, ) => Promise; @@ -383,7 +386,8 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { * Initiate device flow session. */ const loginWithDeviceFlowStart = useCallback( - async (hostname?: Hostname) => await startGitHubDeviceFlow(hostname), + async (hostname?: Hostname, scopes?: string[]) => + await startGitHubDeviceFlow(hostname, scopes), [], ); diff --git a/src/renderer/routes/LoginWithDeviceFlow.test.tsx b/src/renderer/routes/LoginWithDeviceFlow.test.tsx index f9883c11a..c079beadf 100644 --- a/src/renderer/routes/LoginWithDeviceFlow.test.tsx +++ b/src/renderer/routes/LoginWithDeviceFlow.test.tsx @@ -18,7 +18,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { vi.clearAllMocks(); }); - it('should render and initialize device flow', async () => { + it('should render scope choice buttons', async () => { const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({ hostname: 'github.com', clientId: 'test-id', @@ -33,10 +33,39 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); - expect(loginWithDeviceFlowStartMock).toHaveBeenCalled(); + expect(screen.getByText('Receive notifications for:')).toBeInTheDocument(); + expect(screen.getByTestId('device-scope-public')).toBeInTheDocument(); + expect(screen.getByTestId('device-scope-full')).toBeInTheDocument(); - await screen.findByText(/USER-1234/); - expect(screen.getByText(/github.com\/login\/device/)).toBeInTheDocument(); + // Device flow should not start until user makes a choice + expect(loginWithDeviceFlowStartMock).not.toHaveBeenCalled(); + }); + + it('should start device flow with public scope when clicking Public', async () => { + const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({ + hostname: 'github.com', + clientId: 'test-id', + deviceCode: 'device-code', + userCode: 'USER-1234', + verificationUri: 'https://github.com/login/device', + intervalSeconds: 5, + expiresAt: Date.now() + 900000, + }); + + renderWithAppContext(, { + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, + }); + + await userEvent.click(screen.getByTestId('device-scope-public')); + + expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith(undefined, [ + 'notifications', + 'read:user', + 'public_repo', + ]); + + await screen.findByTestId('device-user-code'); + expect(screen.getByTestId('device-verification-link')).toBeInTheDocument(); // Verify auto-copy and auto-open were called expect(copyToClipboardSpy).toHaveBeenCalledWith('USER-1234'); @@ -45,6 +74,32 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { ); }); + it('should start device flow with full scope when clicking Public and Private', async () => { + const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({ + hostname: 'github.com', + clientId: 'test-id', + deviceCode: 'device-code', + userCode: 'USER-1234', + verificationUri: 'https://github.com/login/device', + intervalSeconds: 5, + expiresAt: Date.now() + 900000, + }); + + renderWithAppContext(, { + loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, + }); + + await userEvent.click(screen.getByTestId('device-scope-full')); + + expect(loginWithDeviceFlowStartMock).toHaveBeenCalledWith(undefined, [ + 'notifications', + 'read:user', + 'repo', + ]); + + await screen.findByTestId('device-user-code'); + }); + it('should copy user code to clipboard when clicking copy button', async () => { const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({ hostname: 'github.com', @@ -60,12 +115,13 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); - await screen.findByText(/USER-1234/); + await userEvent.click(screen.getByTestId('device-scope-public')); + await screen.findByTestId('device-user-code'); // Clear the auto-copy call from initialization copyToClipboardSpy.mockClear(); - await userEvent.click(screen.getByLabelText('Copy device code')); + await userEvent.click(screen.getByTestId('copy-device-code')); expect(copyToClipboardSpy).toHaveBeenCalledWith('USER-1234'); }); @@ -79,10 +135,12 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); + await userEvent.click(screen.getByTestId('device-scope-full')); + await screen.findByText(/Failed to start authentication/); }); - it('should navigate back on cancel', async () => { + it('should navigate back on cancel from scope choice', async () => { const loginWithDeviceFlowStartMock = vi.fn().mockResolvedValueOnce({ hostname: 'github.com', clientId: 'test-id', @@ -97,9 +155,7 @@ describe('renderer/routes/LoginWithDeviceFlow.tsx', () => { loginWithDeviceFlowStart: loginWithDeviceFlowStartMock, }); - await screen.findByText(/USER-1234/); - - await userEvent.click(screen.getByText('Cancel')); + await userEvent.click(screen.getByTestId('cancel-button')); expect(navigateMock).toHaveBeenCalledWith(-1); }); diff --git a/src/renderer/routes/LoginWithDeviceFlow.tsx b/src/renderer/routes/LoginWithDeviceFlow.tsx index 9230758c3..45748bd48 100644 --- a/src/renderer/routes/LoginWithDeviceFlow.tsx +++ b/src/renderer/routes/LoginWithDeviceFlow.tsx @@ -1,14 +1,20 @@ -import { type FC, useCallback, useEffect, useState } from 'react'; +import { + type FC, + type ReactNode, + useCallback, + useEffect, + useState, +} from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { CopyIcon, SignInIcon, SyncIcon } from '@primer/octicons-react'; import { + Banner, Button, IconButton, Link as PrimerLink, Stack, Text, - Tooltip, } from '@primer/react'; import { useAppContext } from '../hooks/useAppContext'; @@ -21,6 +27,10 @@ import { Header } from '../components/primitives/Header'; import type { Account, Link } from '../types'; import type { DeviceFlowSession } from '../utils/auth/types'; +import { + getAlternateScopeNames, + getRecommendedScopeNames, +} from '../utils/auth/utils'; import { rendererLogError } from '../utils/core/logger'; import { copyToClipboard, openExternalLink } from '../utils/system/comms'; @@ -28,6 +38,8 @@ interface LocationState { account?: Account; } +type ScopeChoice = 'public' | 'full'; + export const LoginWithDeviceFlowRoute: FC = () => { const navigate = useNavigate(); const location = useLocation(); @@ -42,13 +54,20 @@ export const LoginWithDeviceFlowRoute: FC = () => { const [session, setSession] = useState(null); const [isPolling, setIsPolling] = useState(false); const [error, setError] = useState(null); + const [scopeChoice, setScopeChoice] = useState(null); // Initialize device flow session, copy code, and open browser useEffect(() => { const initializeDeviceFlow = async () => { try { + const scopes = + scopeChoice === 'public' + ? getAlternateScopeNames() + : getRecommendedScopeNames(); + const newSession = await loginWithDeviceFlowStart( reAuthAccount?.hostname, + scopes, ); setSession(newSession); @@ -67,8 +86,10 @@ export const LoginWithDeviceFlowRoute: FC = () => { } }; - initializeDeviceFlow(); - }, [loginWithDeviceFlowStart, reAuthAccount]); + if (scopeChoice) { + initializeDeviceFlow(); + } + }, [loginWithDeviceFlowStart, reAuthAccount, scopeChoice]); // Poll for device flow completion useEffect(() => { @@ -133,125 +154,171 @@ export const LoginWithDeviceFlowRoute: FC = () => { } }, [session?.userCode]); - return ( - -
Authorize with GitHub
+ // Render UI states as separate functions for clarity + const renderSessionUI = () => { + if (!session) { + return null; + } - - {error && ( -
+ + + Go to{' '} + + {session.verificationUri} + + + and enter your device code when prompted: + + + + - {error} -
- )} + {session.userCode} + + + - {session ? ( - - - - Go to{' '} - - {session.verificationUri} - - - and enter your device code when prompted: - - -
- - {session.userCode} - - - - -
- - - - We're waiting for authorization... - - {isPolling && ( - - - - Polling for authorization - - - )} - -
- ) : ( - + + We're waiting for authorization... + + {isPolling && ( + {' '} - Initializing authentication... + /> + + Polling for authorization + )} + + ); + }; + + const renderScopeChoiceUI = () => ( + + Receive notifications for: + + + + + + + + ); + + const renderInitializingUI = () => ( + + + Initializing authentication... + + ); + + let mainContent: ReactNode; + if (session) { + mainContent = renderSessionUI(); + } else if (!scopeChoice) { + mainContent = renderScopeChoiceUI(); + } else { + mainContent = renderInitializingUI(); + } + + return ( + +
Authorize with GitHub
+ + + {error && ( + + + {error} + + + } + hideTitle + title="Login errors" + variant="critical" + /> + )} + {mainContent}
- - - {/* {session && ( - - )} */}
); diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts index e4eba2e30..032069c9c 100644 --- a/src/renderer/utils/api/graphql/generated/graphql.ts +++ b/src/renderer/utils/api/graphql/generated/graphql.ts @@ -1276,6 +1276,34 @@ export type CreateIpAllowListEntryInput = { ownerId: Scalars['ID']['input']; }; +/** Autogenerated input type of CreateIssueField */ +export type CreateIssueFieldInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The data type of the issue field. */ + dataType: IssueFieldDataType; + /** A description of the issue field. */ + description?: InputMaybe; + /** The name of the issue field. */ + name: Scalars['String']['input']; + /** The options for the issue field if applicable. */ + options?: InputMaybe>; + /** The ID of the organization where the issue field will be created. */ + ownerId: Scalars['ID']['input']; + /** The visibility of the issue field. */ + visibility?: InputMaybe; +}; + +/** Autogenerated input type of CreateIssueFieldValue */ +export type CreateIssueFieldValueInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The field value to create. */ + issueField: IssueFieldCreateOrUpdateInput; + /** The ID of the issue. */ + issueId: Scalars['ID']['input']; +}; + /** Autogenerated input type of CreateIssue */ export type CreateIssueInput = { /** Configuration for assigning Copilot to this issue. */ @@ -1286,6 +1314,8 @@ export type CreateIssueInput = { body?: InputMaybe; /** A unique identifier for the client performing the mutation. */ clientMutationId?: InputMaybe; + /** An array of issue fields to set on the issue during creation */ + issueFields?: InputMaybe>; /** The name of an issue template in the repository, assigns labels and assignees from the template to the issue */ issueTemplate?: InputMaybe; /** The Node ID of the issue type for this issue */ @@ -1414,6 +1444,16 @@ export type CreateProjectV2Input = { title: Scalars['String']['input']; }; +/** Autogenerated input type of CreateProjectV2IssueField */ +export type CreateProjectV2IssueFieldInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The ID of the IssueField to create the field for. */ + issueFieldId: Scalars['ID']['input']; + /** The ID of the Project to create the field in. */ + projectId: Scalars['ID']['input']; +}; + /** Autogenerated input type of CreateProjectV2StatusUpdate */ export type CreateProjectV2StatusUpdateInput = { /** The body of the status update. */ @@ -1762,6 +1802,24 @@ export type DeleteIssueCommentInput = { id: Scalars['ID']['input']; }; +/** Autogenerated input type of DeleteIssueField */ +export type DeleteIssueFieldInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The ID of the field to delete. */ + fieldId: Scalars['ID']['input']; +}; + +/** Autogenerated input type of DeleteIssueFieldValue */ +export type DeleteIssueFieldValueInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The ID of the field to delete. */ + fieldId: Scalars['ID']['input']; + /** The ID of the issue. */ + issueId: Scalars['ID']['input']; +}; + /** Autogenerated input type of DeleteIssue */ export type DeleteIssueInput = { /** A unique identifier for the client performing the mutation. */ @@ -2853,6 +2911,86 @@ export type IssueDependencyOrderField = /** Order issue dependencies by time of when the dependency relationship was added */ | 'DEPENDENCY_ADDED_AT'; +/** Represents an issue field value that must be set on an issue during issue creation */ +export type IssueFieldCreateOrUpdateInput = { + /** The date value, for a date field */ + dateValue?: InputMaybe; + /** Set to true to delete the field value */ + delete?: InputMaybe; + /** The ID of the issue field */ + fieldId: Scalars['ID']['input']; + /** The numeric value, for a number field */ + numberValue?: InputMaybe; + /** The ID of the selected option, for a single select field */ + singleSelectOptionId?: InputMaybe; + /** The text value, for a text field */ + textValue?: InputMaybe; +}; + +/** The type of an issue field. */ +export type IssueFieldDataType = + /** Date */ + | 'DATE' + /** Number */ + | 'NUMBER' + /** Single Select */ + | 'SINGLE_SELECT' + /** Text */ + | 'TEXT'; + +/** Ordering options for issue field connections */ +export type IssueFieldOrder = { + /** The ordering direction. */ + direction: OrderDirection; + /** The field to order issue fields by. */ + field: IssueFieldOrderField; +}; + +/** Properties by which issue field connections can be ordered. */ +export type IssueFieldOrderField = + /** Order issue fields by creation time */ + | 'CREATED_AT' + /** Order issue fields by name */ + | 'NAME'; + +/** The display color of a single-select field option. */ +export type IssueFieldSingleSelectOptionColor = + /** blue */ + | 'BLUE' + /** gray */ + | 'GRAY' + /** green */ + | 'GREEN' + /** orange */ + | 'ORANGE' + /** pink */ + | 'PINK' + /** purple */ + | 'PURPLE' + /** red */ + | 'RED' + /** yellow */ + | 'YELLOW'; + +/** A single selection option for an issue field. */ +export type IssueFieldSingleSelectOptionInput = { + /** The color associated with the option. */ + color: IssueFieldSingleSelectOptionColor; + /** A description of the option. */ + description?: InputMaybe; + /** The name of the option. */ + name: Scalars['String']['input']; + /** The priority of the option in the list. */ + priority: Scalars['Int']['input']; +}; + +/** The visibility of an issue field. */ +export type IssueFieldVisibility = + /** All */ + | 'ALL' + /** Org Only */ + | 'ORG_ONLY'; + /** Ways in which to filter lists of issues. */ export type IssueFilters = { /** List issues assigned to given name. Pass in `null` for issues with no assigned user, and `*` for issues assigned to any user. */ @@ -3757,6 +3895,14 @@ export type PinEnvironmentInput = { pinned: Scalars['Boolean']['input']; }; +/** Autogenerated input type of PinIssueComment */ +export type PinIssueCommentInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The ID of the Issue Comment to pin. Comment pinning is not supported on Pull Requests. */ + issueCommentId: Scalars['ID']['input']; +}; + /** Autogenerated input type of PinIssue */ export type PinIssueInput = { /** A unique identifier for the client performing the mutation. */ @@ -5607,6 +5753,16 @@ export type SetEnterpriseIdentityProviderInput = { ssoUrl: Scalars['URI']['input']; }; +/** Autogenerated input type of SetIssueFieldValue */ +export type SetIssueFieldValueInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The issue fields to set on the issue */ + issueFields: Array; + /** The ID of the Issue to set the field value on. */ + issueId: Scalars['ID']['input']; +}; + /** Autogenerated input type of SetOrganizationInteractionLimit */ export type SetOrganizationInteractionLimitInput = { /** A unique identifier for the client performing the mutation. */ @@ -6749,6 +6905,14 @@ export type UnminimizeCommentInput = { subjectId: Scalars['ID']['input']; }; +/** Autogenerated input type of UnpinIssueComment */ +export type UnpinIssueCommentInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The ID of the Issue Comment to unpin. Comment pinning is not supported on Pull Requests. */ + issueCommentId: Scalars['ID']['input']; +}; + /** Autogenerated input type of UnpinIssue */ export type UnpinIssueInput = { /** A unique identifier for the client performing the mutation. */ @@ -7159,6 +7323,32 @@ export type UpdateIssueCommentInput = { id: Scalars['ID']['input']; }; +/** Autogenerated input type of UpdateIssueField */ +export type UpdateIssueFieldInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** A description of the issue field. */ + description?: InputMaybe; + /** The ID of the issue field to update. */ + id: Scalars['ID']['input']; + /** The name of the issue field. */ + name?: InputMaybe; + /** The options for the issue field if applicable. */ + options?: InputMaybe>; + /** The visibility of the issue field. */ + visibility?: InputMaybe; +}; + +/** Autogenerated input type of UpdateIssueFieldValue */ +export type UpdateIssueFieldValueInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** The field value to update. */ + issueField: IssueFieldCreateOrUpdateInput; + /** The ID of the issue. */ + issueId: Scalars['ID']['input']; +}; + /** Autogenerated input type of UpdateIssue */ export type UpdateIssueInput = { /** Configuration for assigning an AI agent to this issue. */ diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index b555d6961..29712e354 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -16,6 +16,7 @@ import { exchangeDeviceCode, exchangeWebFlowCode, } from '@octokit/oauth-methods'; +import { RequestError } from '@octokit/request-error'; import type { MockedFunction } from 'vitest'; @@ -36,7 +37,11 @@ import type { Token, } from '../../types'; import type { GetAuthenticatedUserResponse } from '../api/types'; -import type { AuthMethod, LoginOAuthWebOptions } from './types'; +import type { + AuthMethod, + DeviceFlowSession, + LoginOAuthWebOptions, +} from './types'; import * as comms from '../../utils/system/comms'; import * as apiClient from '../api/client'; @@ -75,41 +80,91 @@ describe('renderer/utils/auth/utils.ts', () => { vi.clearAllMocks(); }); - describe('performGitHubDeviceOAuth', () => { - it('should authenticate using device flow for GitHub app', async () => { - createDeviceCodeMock.mockResolvedValueOnce({ - data: { - device_code: 'device-code', - user_code: 'user-code', - verification_uri: 'https://github.com/login/device', - expires_in: 900, - interval: 5, - }, - } as unknown as Awaited>); - - exchangeDeviceCodeMock.mockResolvedValueOnce({ - authentication: { - token: 'device-token', - }, - } as unknown as Awaited>); - - const token = await authUtils.performGitHubDeviceOAuth(); - - expect(createDeviceCodeMock).toHaveBeenCalledWith({ - clientType: 'oauth-app', - clientId: 'FAKE_CLIENT_ID_123', - scopes: getRecommendedScopeNames(), - request: expect.any(Function), + describe('Gitify GitHub OAuth - Device Code Flow', () => { + describe('startGitHubDeviceFlow', () => { + it('should request a device code and return a session', async () => { + createDeviceCodeMock.mockResolvedValueOnce({ + data: { + device_code: 'device-code-xyz', + user_code: 'user-code-xyz', + verification_uri: 'https://github.com/login/device', + expires_in: 600, + interval: 5, + }, + } as unknown as Awaited>); + + const session = await authUtils.startGitHubDeviceFlow(); + + expect(createDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + scopes: getRecommendedScopeNames(), + request: expect.any(Function), + }); + + expect(session.deviceCode).toBe('device-code-xyz'); + expect(session.userCode).toBe('user-code-xyz'); + expect(session.verificationUri).toBe('https://github.com/login/device'); + expect(session.intervalSeconds).toBe(5); + expect(session.expiresAt).toBeGreaterThan(Date.now()); }); + }); - expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ - clientType: 'oauth-app', + describe('pollGitHubDeviceFlow', () => { + const baseSession = { + hostname: 'github.com', clientId: 'FAKE_CLIENT_ID_123', - code: 'device-code', - request: expect.any(Function), + deviceCode: 'device-code', + userCode: 'user-code', + verificationUri: 'https://github.com/login/device', + intervalSeconds: 5, + expiresAt: Date.now() + 10000, + } as const; + + it('returns token on successful exchange', async () => { + exchangeDeviceCodeMock.mockResolvedValueOnce({ + authentication: { token: 'device-token-xyz' }, + } as unknown as Awaited>); + + const token = await authUtils.pollGitHubDeviceFlow( + baseSession as DeviceFlowSession, + ); + + expect(exchangeDeviceCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + code: 'device-code', + request: expect.any(Function), + }); + + expect(token).toBe('device-token-xyz'); }); - expect(token).toBe('device-token'); + it('returns null when authorization is pending or slow_down', async () => { + const pendingErr = Object.create(RequestError.prototype); + pendingErr.response = { data: { error: 'authorization_pending' } }; + + exchangeDeviceCodeMock.mockRejectedValueOnce(pendingErr); + + const token = await authUtils.pollGitHubDeviceFlow( + baseSession as DeviceFlowSession, + ); + + expect(token).toBeNull(); + }); + + it('throws on other errors', async () => { + const otherErr = new Error('boom'); + + exchangeDeviceCodeMock.mockRejectedValueOnce(otherErr); + + await expect( + async () => + await authUtils.pollGitHubDeviceFlow( + baseSession as DeviceFlowSession, + ), + ).rejects.toThrow('boom'); + }); }); }); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 12cef6ef8..06cd790f5 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -108,15 +108,17 @@ export function performGitHubWebOAuth( * (user code, verification URI, expiry) needed to complete the flow. * * @param hostname - The GitHub hostname to authenticate against. Defaults to github.com. + * @param scopes - Array of scope names to request. Defaults to recommended (full) scopes. * @returns The device flow session data. */ export async function startGitHubDeviceFlow( hostname: Hostname = Constants.GITHUB_HOSTNAME, + scopes: string[] = getRecommendedScopeNames(), ): Promise { const deviceCode = await createDeviceCode({ clientType: 'oauth-app' as const, clientId: Constants.OAUTH_DEVICE_FLOW_CLIENT_ID, - scopes: getRecommendedScopeNames(), + scopes: scopes, request: request.defaults({ baseUrl: getGitHubAuthBaseUrl(hostname).toString(), }), @@ -177,33 +179,6 @@ export async function pollGitHubDeviceFlow( } } -/** - * Orchestrate a complete GitHub Device OAuth flow. - * - * Starts a device flow session, then polls at the session-specified interval - * until the user approves the request or the device code expires. - * - * @returns The access token on successful authorization. - * @throws If the device code expires before the user approves. - */ -export async function performGitHubDeviceOAuth(): Promise { - const session = await startGitHubDeviceFlow(); - - const intervalMs = Math.max(5000, session.intervalSeconds * 1000); - - while (Date.now() < session.expiresAt) { - const token = await pollGitHubDeviceFlow(session); - - if (token) { - return token; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error('Device code expired before authorization completed'); -} - /** * Exchange an OAuth authorization code for an access token. *