Implement UI for Github Apps <> Batch Changes integration (#63597)

Closes SRCH-660
Closes SRCH-661

This PR adds the UI flow for adding a GitHub app as a Batch Changes
credential. The actual functionality to create the GitHub app and store
it's information will be done in a follow up PR.



https://github.com/sourcegraph/sourcegraph/assets/25608335/1089a363-070d-4fd8-8a03-af63cd378947

## Test plan

<!-- REQUIRED; info at
https://docs-legacy.sourcegraph.com/dev/background-information/testing_principles
-->

Manual testing.

## Changelog

<!-- OPTIONAL; info at
https://www.notion.so/sourcegraph/Writing-a-changelog-entry-dd997f411d524caabf0d8d38a24a878c
-->
This commit is contained in:
Bolaji Olajide 2024-07-02 14:56:17 +01:00 committed by GitHub
parent fcff7a218b
commit 2eafe0fcfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 454 additions and 186 deletions

View File

@ -73,7 +73,5 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cody.codebase": "github.com/sourcegraph/sourcegraph",
"rust-analyzer.linkedProjects": [
"docker-images/syntax-highlighter/Cargo.toml"
],
"rust-analyzer.linkedProjects": ["docker-images/syntax-highlighter/Cargo.toml"]
}

View File

@ -1,23 +1,15 @@
import React, { type FC, useState, useCallback, useRef, useEffect } from 'react'
import classNames from 'classnames'
import { noop } from 'lodash'
import { useNavigate } from 'react-router-dom'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
import {
Alert,
Container,
Button,
Input,
Label,
Text,
PageHeader,
ButtonLink,
Checkbox,
Link,
} from '@sourcegraph/wildcard'
import { Alert, Container, Button, Input, Label, Text, PageHeader, Checkbox, Link } from '@sourcegraph/wildcard'
import { GitHubAppDomain } from '../../graphql-operations'
import type { AuthenticatedUser } from '../../auth'
import type { GitHubAppDomain, GitHubAppKind } from '../../graphql-operations'
import { PageTitle } from '../PageTitle'
interface StateResponse {
@ -51,6 +43,8 @@ export interface CreateGitHubAppPageProps extends TelemetryV2Props {
headerAnnotation?: React.ReactNode
/** The domain the new GitHub App is meant to be used for in Sourcegraph. */
appDomain: GitHubAppDomain
/** The purpose of the GitHub App to be created. This is only applicable when the appDomain is BATCHES. */
appKind: GitHubAppKind
/** The name to use for the new GitHub App. Defaults to "Sourcegraph". */
defaultAppName?: string
/*
@ -63,6 +57,13 @@ export interface CreateGitHubAppPageProps extends TelemetryV2Props {
* or a string with an error message reason if not.
*/
validateURL?: (url: string) => true | string
/** The currently authenticated user */
authenticatedUser: AuthenticatedUser
/**
* Whether or not the page is being rendered in a minimized mode.
* Minimized mode is when this component is rendered in a modal.
*/
minimizedMode?: boolean
}
/**
@ -79,7 +80,11 @@ export const CreateGitHubAppPage: FC<CreateGitHubAppPageProps> = ({
baseURL,
validateURL,
telemetryRecorder,
appKind,
authenticatedUser,
minimizedMode,
}) => {
const navigate = useNavigate()
const ref = useRef<HTMLFormElement>(null)
const formInput = useRef<HTMLInputElement>(null)
const [name, setName] = useState<string>(defaultAppName)
@ -143,24 +148,30 @@ export const CreateGitHubAppPage: FC<CreateGitHubAppPageProps> = ({
const createState = useCallback(async () => {
setError(undefined)
try {
const response = await fetch(
`/githubapp/new-app-state?appName=${name}&webhookURN=${url}&domain=${appDomain}&baseURL=${url}`
)
let appStateUrl = `/githubapp/new-app-state?appName=${encodeURIComponent(
name
)}&webhookURN=${url}&domain=${appDomain}&baseURL=${encodeURIComponent(url)}&kind=${appKind}`
if (authenticatedUser) {
appStateUrl = `${appStateUrl}&userID=${authenticatedUser.id}`
}
// We encode the name and url here so that special characters like `#` are interpreted as
// part of the URL and not the fragment.
const response = await fetch(appStateUrl)
if (!response.ok) {
if (response.body instanceof ReadableStream) {
const error = await response.text()
throw new Error(error)
}
}
const state = (await response.json()) as StateResponse
const jsonResponse = (await response.json()) as StateResponse
let webhookURL: string | undefined
if (state.webhookUUID?.length) {
webhookURL = new URL(`/.api/webhooks/${state.webhookUUID}`, originURL).href
if (jsonResponse.webhookUUID?.length) {
webhookURL = new URL(`/.api/webhooks/${jsonResponse.webhookUUID}`, originURL).href
}
if (!state.state?.length) {
if (!jsonResponse.state?.length) {
throw new Error('Response from server missing state parameter')
}
submitForm({ state: state.state, webhookURL, name })
submitForm({ state: jsonResponse.state, webhookURL, name })
} catch (error_) {
if (error_ instanceof Error) {
setError(error_.message)
@ -170,7 +181,7 @@ export const CreateGitHubAppPage: FC<CreateGitHubAppPageProps> = ({
setError('Unknown error occurred.')
}
}
}, [submitForm, name, appDomain, url, originURL])
}, [submitForm, name, appDomain, url, originURL, appKind, authenticatedUser])
const handleNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value)
@ -207,34 +218,38 @@ export const CreateGitHubAppPage: FC<CreateGitHubAppPageProps> = ({
const handleOrgChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => setOrg(event.target.value), [])
const toggleIsPublic = useCallback(() => setIsPublic(isPublic => !isPublic), [])
const cancelUrl = `/site-admin/${appDomain === GitHubAppDomain.BATCHES ? 'batch-changes' : 'github-apps'}`
return (
<>
<PageTitle title={pageTitle} />
<PageHeader
path={[{ text: pageTitle }]}
headingElement="h2"
description={
headerDescription || (
<>
Register a GitHub App to better manage GitHub code host connections.{' '}
<Link to="/help/admin/code_hosts/github#using-a-github-app" target="_blank">
See how GitHub App configuration works.
</Link>
</>
)
}
annotation={headerAnnotation}
className="mb-3"
/>
{!minimizedMode && (
<>
<PageTitle title={pageTitle} />
<PageHeader
path={[{ text: pageTitle }]}
headingElement="h2"
description={
headerDescription || (
<>
Register a GitHub App to better manage GitHub code host connections.{' '}
<Link to="/help/admin/external_service/github#using-a-github-app" target="_blank">
See how GitHub App configuration works.
</Link>
</>
)
}
annotation={headerAnnotation}
className="mb-3"
/>
</>
)}
<Container className="mb-3">
{error && <Alert variant="danger">Error creating GitHub App: {error}</Alert>}
<Text>
Provide the details for a new GitHub App with the form below. Once you click "Create GitHub App",
you will be routed to {baseURL || 'GitHub'} to create the App and choose which repositories to grant
it access to. Once created on {baseURL || 'GitHub'}, you'll be redirected back here to finish
connecting it to Sourcegraph.
you will be routed to <strong>{baseURL || 'GitHub'}</strong> to create the App and choose which
repositories to grant it access to. Once created on <strong>{baseURL || 'GitHub'}</strong>, you'll
be redirected back here to finish connecting it to Sourcegraph.
</Text>
<Label className="w-100">
<Text alignment="left" className="mb-2">
@ -305,7 +320,7 @@ export const CreateGitHubAppPage: FC<CreateGitHubAppPageProps> = ({
Your GitHub App must be public if you want to install it on multiple organizations or user
accounts.{' '}
<Link
to="/help/admin/code_hosts/github#multiple-installations"
to="/help/admin/external_service/github#multiple-installations"
target="_blank"
rel="noopener noreferrer"
>
@ -320,13 +335,24 @@ export const CreateGitHubAppPage: FC<CreateGitHubAppPageProps> = ({
<input ref={formInput} name="manifest" onChange={noop} hidden={true} />
</form>
</Container>
<div>
<div
className={classNames({
'd-flex flex-row-reverse': minimizedMode,
})}
>
<Button variant="primary" onClick={createState} disabled={!!nameError || !!urlError}>
Create Github App
</Button>
<ButtonLink className="ml-2" to={cancelUrl} variant="secondary">
<Button
className={classNames({
'ml-2': !minimizedMode,
'mr-2': minimizedMode,
})}
onClick={() => navigate(-1)}
variant="secondary"
>
Cancel
</ButtonLink>
</Button>
</div>
</>
)

View File

@ -1,5 +1,10 @@
.add-credential-modal {
&__modal-step-ruler {
&__container {
max-height: 40rem;
overflow-y: auto;
}
&__step-ruler {
height: 0.125rem;
width: 100%;
border-radius: 0.125rem;

View File

@ -6,7 +6,7 @@ import { getDocumentNode } from '@sourcegraph/http-client'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { WebStory } from '../../../components/WebStory'
import { ExternalServiceKind } from '../../../graphql-operations'
import { ExternalServiceKind, type UserAreaUserFields } from '../../../graphql-operations'
import { AddCredentialModal } from './AddCredentialModal'
import { CREATE_BATCH_CHANGES_CREDENTIAL } from './backend'
@ -24,6 +24,14 @@ const config: Meta = {
},
}
const user = {
__typename: 'User',
id: '123',
username: 'alice',
avatarURL: null,
viewerCanAdminister: true,
} as UserAreaUserFields
export default config
export const RequiresSSHstep1: StoryFn = args => (
@ -54,7 +62,7 @@ export const RequiresSSHstep1: StoryFn = args => (
>
<AddCredentialModal
{...props}
userID="user-id-1"
user={user}
externalServiceKind={args.externalServiceKind}
externalServiceURL="https://github.com/"
requiresSSH={true}
@ -83,7 +91,7 @@ export const RequiresSSHstep2: StoryFn = args => (
{props => (
<AddCredentialModal
{...props}
userID="user-id-1"
user={user}
externalServiceKind={args.externalServiceKind}
externalServiceURL="https://github.com/"
requiresSSH={true}
@ -112,7 +120,7 @@ export const GitHub: StoryFn = () => (
{props => (
<AddCredentialModal
{...props}
userID="user-id-1"
user={user}
externalServiceKind={ExternalServiceKind.GITHUB}
externalServiceURL="https://github.com/"
requiresSSH={false}
@ -131,7 +139,7 @@ export const GitLab: StoryFn = () => (
{props => (
<AddCredentialModal
{...props}
userID="user-id-1"
user={user}
externalServiceKind={ExternalServiceKind.GITLAB}
externalServiceURL="https://gitlab.com/"
requiresSSH={false}
@ -150,7 +158,7 @@ export const BitbucketServer: StoryFn = () => (
{props => (
<AddCredentialModal
{...props}
userID="user-id-1"
user={user}
externalServiceKind={ExternalServiceKind.BITBUCKETSERVER}
externalServiceURL="https://bitbucket.sgdev.org/"
requiresSSH={false}
@ -167,7 +175,7 @@ export const BitbucketCloud: StoryFn = () => (
{props => (
<AddCredentialModal
{...props}
userID="user-id-1"
user={user}
externalServiceKind={ExternalServiceKind.BITBUCKETCLOUD}
externalServiceURL="https://bitbucket.org/"
requiresSSH={false}

View File

@ -1,14 +1,17 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback, useState, type FC } from 'react'
import classNames from 'classnames'
import { logger } from '@sourcegraph/common'
import { Button, Modal, Link, Code, Label, Text, Input, ErrorAlert, Form } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
import { Button, Modal, Link, Code, Label, Text, Input, ErrorAlert, Form, Select } from '@sourcegraph/wildcard'
import { LoaderButton } from '../../../components/LoaderButton'
import { ExternalServiceKind, type Scalars } from '../../../graphql-operations'
import { useFeatureFlag } from '../../../featureFlags/useFeatureFlag'
import { ExternalServiceKind, GitHubAppKind, type UserAreaUserFields } from '../../../graphql-operations'
import { useCreateBatchChangesCredential } from './backend'
import { BatchChangesCreateGitHubAppPage } from './BatchChangesCreateGitHubAppPage'
import { CodeHostSshPublicKey } from './CodeHostSshPublicKey'
import { ModalHeader } from './ModalHeader'
@ -17,7 +20,7 @@ import styles from './AddCredentialModal.module.scss'
export interface AddCredentialModalProps {
onCancel: () => void
afterCreate: () => void
userID: Scalars['ID'] | null
user: UserAreaUserFields | null
externalServiceKind: ExternalServiceKind
externalServiceURL: string
requiresSSH: boolean
@ -77,10 +80,17 @@ const scopeRequirements: Record<ExternalServiceKind, JSX.Element> = {
type Step = 'add-token' | 'get-ssh-key'
export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren<AddCredentialModalProps>> = ({
const AuthenticationStrategy = {
PERSONAL_ACCESS_TOKEN: 'PERSONAL_ACCESS_TOKEN',
GITHUB_APP: 'GITHUB_APP',
} as const
type AuthenticationStrategyType = typeof AuthenticationStrategy[keyof typeof AuthenticationStrategy]
export const AddCredentialModal: FC<React.PropsWithChildren<AddCredentialModalProps>> = ({
onCancel,
afterCreate,
userID,
user,
externalServiceKind,
externalServiceURL,
requiresSSH,
@ -92,6 +102,10 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
const [sshPublicKey, setSSHPublicKey] = useState<string>()
const [username, setUsername] = useState<string>('')
const [step, setStep] = useState<Step>(initialStep)
const [authStrategy, setAuthStrategy] = useState<AuthenticationStrategyType>(
AuthenticationStrategy.PERSONAL_ACCESS_TOKEN
)
const [isGithubAppIntegrationEnabled] = useFeatureFlag('batches-github-app-integration')
const onChangeCredential = useCallback<React.ChangeEventHandler<HTMLInputElement>>(event => {
setCredential(event.target.value)
@ -110,7 +124,7 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
try {
const { data } = await createBatchChangesCredential({
variables: {
user: userID,
user: user?.id || null,
credential,
username: requiresUsername ? username : null,
externalServiceKind,
@ -130,7 +144,7 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
},
[
createBatchChangesCredential,
userID,
user?.id,
credential,
requiresUsername,
username,
@ -141,21 +155,32 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
]
)
const patLabel =
externalServiceKind === ExternalServiceKind.PERFORCE
? 'Ticket'
: externalServiceKind === ExternalServiceKind.BITBUCKETCLOUD
? 'App password'
: 'Personal access token'
const isTokenSection = step === 'add-token'
const isGitHubKind = externalServiceKind === ExternalServiceKind.GITHUB
// addCredentialModalStepRuler
return (
<Modal onDismiss={onCancel} aria-labelledby={labelId}>
<div className="test-add-credential-modal">
<Modal onDismiss={onCancel} aria-labelledby={labelId} position="center">
<div className={classNames('test-add-credential-modal', styles.addCredentialModalContainer)}>
<ModalHeader
id={labelId}
externalServiceKind={externalServiceKind}
externalServiceURL={externalServiceURL}
/>
{isGitHubKind && isGithubAppIntegrationEnabled && isTokenSection && (
<Select
id="credential-kind"
selectSize="sm"
label="Select an Authentication strategy for your credential"
value={authStrategy}
onChange={event => setAuthStrategy(event.target.value as AuthenticationStrategyType)}
>
<option value={AuthenticationStrategy.PERSONAL_ACCESS_TOKEN} defaultChecked={true}>
Personal Access Token
</option>
<option value={AuthenticationStrategy.GITHUB_APP}>GitHub App</option>
</Select>
)}
{requiresSSH && (
<div className="d-flex w-100 justify-content-between mb-4">
<div className="flex-grow-1 mr-2">
@ -164,8 +189,8 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
</Text>
<div
className={classNames(
styles.addCredentialModalModalStepRuler,
styles.addCredentialModalModalStepRulerPurple
styles.addCredentialModalStepRuler,
styles.addCredentialModalStepRulerPurple
)}
/>
</div>
@ -175,83 +200,32 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
</Text>
<div
className={classNames(
styles.addCredentialModalModalStepRuler,
step === 'add-token' && styles.addCredentialModalModalStepRulerGray,
step === 'get-ssh-key' && styles.addCredentialModalModalStepRulerBlue
styles.addCredentialModalStepRuler,
step === 'add-token' && styles.addCredentialModalStepRulerGray,
step === 'get-ssh-key' && styles.addCredentialModalStepRulerBlue
)}
/>
</div>
</div>
)}
{step === 'add-token' && (
<>
{error && <ErrorAlert error={error} />}
<Form onSubmit={onSubmit}>
<div className="form-group">
{requiresUsername && (
<>
<Input
id="username"
name="username"
autoComplete="off"
inputClassName="mb-2"
className="mb-0"
required={true}
spellCheck="false"
minLength={1}
value={username}
onChange={onChangeUsername}
label="Username"
/>
</>
)}
<Label htmlFor="token">{patLabel}</Label>
<Input
id="token"
name="token"
type="password"
autoComplete="off"
data-testid="test-add-credential-modal-input"
required={true}
spellCheck="false"
minLength={1}
value={credential}
onChange={onChangeCredential}
/>
<Text className="form-text">
<Link
to={HELP_TEXT_LINK_URL}
rel="noreferrer noopener"
target="_blank"
aria-label={`Follow our docs to learn how to create a new ${patLabel.toLocaleLowerCase()} on this code host`}
>
Create a new {patLabel.toLocaleLowerCase()}
</Link>{' '}
{scopeRequirements[externalServiceKind]}
</Text>
</div>
<div className="d-flex justify-content-end">
<Button
disabled={loading}
className="mr-2"
onClick={onCancel}
outline={true}
variant="secondary"
>
Cancel
</Button>
<LoaderButton
type="submit"
disabled={loading || credential.length === 0}
className="test-add-credential-modal-submit"
variant="primary"
loading={loading}
alwaysShowLabel={true}
label={requiresSSH ? 'Next' : 'Add credential'}
/>
</div>
</Form>
</>
<AddToken
step={step}
error={error}
credential={credential}
onChangeCredential={onChangeCredential}
username={username}
onChangeUsername={onChangeUsername}
requiresUsername={requiresUsername}
externalServiceKind={externalServiceKind}
onSubmit={onSubmit}
requiresSSH={requiresSSH}
loading={loading}
onCancel={onCancel}
authStrategy={authStrategy}
externalServiceURL={externalServiceURL}
user={user}
/>
)}
{step === 'get-ssh-key' && (
<>
@ -273,3 +247,149 @@ export const AddCredentialModal: React.FunctionComponent<React.PropsWithChildren
</Modal>
)
}
const computeCredentialLabel = (
externalServiceKind: ExternalServiceKind,
authStrategy: AuthenticationStrategyType
): string => {
if (externalServiceKind === ExternalServiceKind.PERFORCE) {
return 'Ticket'
}
if (externalServiceKind === ExternalServiceKind.BITBUCKETCLOUD) {
return 'App password'
}
if (externalServiceKind === ExternalServiceKind.GITHUB && authStrategy === AuthenticationStrategy.GITHUB_APP) {
return 'Create GitHub App'
}
return 'Personal access token'
}
interface AddTokenProps {
step: Step
error: unknown
onSubmit: React.FormEventHandler<Element>
requiresUsername: boolean
credential: string
username: string
onChangeUsername: React.ChangeEventHandler<HTMLInputElement>
onChangeCredential: React.ChangeEventHandler<HTMLInputElement>
externalServiceKind: ExternalServiceKind
requiresSSH: boolean
loading: boolean
onCancel: () => void
authStrategy: AuthenticationStrategyType
externalServiceURL: string
user: UserAreaUserFields | null
}
const AddToken: FC<AddTokenProps> = ({
step,
error,
onSubmit,
requiresUsername,
credential,
username,
onChangeUsername,
onChangeCredential,
externalServiceKind,
requiresSSH,
loading,
onCancel,
authStrategy,
externalServiceURL,
user,
}) => {
const patLabel = computeCredentialLabel(externalServiceKind, authStrategy)
const isStrategyPAT = authStrategy === AuthenticationStrategy.PERSONAL_ACCESS_TOKEN
const kind = user ? GitHubAppKind.USER_CREDENTIAL : GitHubAppKind.SITE_CREDENTIAL
if (step === 'add-token') {
return (
<>
{error && <ErrorAlert error={error} />}
{isStrategyPAT ? (
<Form onSubmit={onSubmit}>
<div className="form-group">
{requiresUsername && (
<>
<Input
id="username"
name="username"
autoComplete="off"
inputClassName="mb-2"
className="mb-0"
required={true}
spellCheck="false"
minLength={1}
value={username}
onChange={onChangeUsername}
label="Username"
/>
</>
)}
<Label htmlFor="token">{patLabel}</Label>
<Input
id="token"
name="token"
type="password"
autoComplete="off"
data-testid="test-add-credential-modal-input"
required={true}
spellCheck="false"
minLength={1}
value={credential}
onChange={onChangeCredential}
/>
<Text className="form-text">
<Link
to={HELP_TEXT_LINK_URL}
rel="noreferrer noopener"
target="_blank"
aria-label={`Follow our docs to learn how to create a new ${patLabel.toLocaleLowerCase()} on this code host`}
>
Create a new {patLabel.toLocaleLowerCase()}
</Link>{' '}
{scopeRequirements[externalServiceKind]}
</Text>
</div>
<div className="d-flex justify-content-end align-items-center">
{isStrategyPAT && (
<>
<Button
disabled={loading}
className="mr-2"
onClick={onCancel}
outline={true}
variant="secondary"
>
Cancel
</Button>
<LoaderButton
type="submit"
disabled={loading || credential.length === 0}
className="test-add-credential-modal-submit"
variant="primary"
loading={loading}
alwaysShowLabel={true}
label={requiresSSH ? 'Next' : 'Add credential'}
/>
</>
)}
</div>
</Form>
) : (
<BatchChangesCreateGitHubAppPage
authenticatedUser={user as unknown as AuthenticatedUser}
minimizedMode={true}
kind={kind}
/>
)}
</>
)
}
return null
}

View File

@ -1,12 +1,14 @@
import { useCallback } from 'react'
import { useCallback, type FC } from 'react'
import { capitalize } from 'lodash'
import { useLocation } from 'react-router-dom'
import type { AuthenticatedUser } from '@sourcegraph/shared/src/auth'
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { FeedbackBadge, Link } from '@sourcegraph/wildcard'
import { Link, FeedbackBadge } from '@sourcegraph/wildcard'
import { CreateGitHubAppPage } from '../../../components/gitHubApps/CreateGitHubAppPage'
import { GitHubAppDomain } from '../../../graphql-operations'
import { GitHubAppDomain, GitHubAppKind } from '../../../graphql-operations'
import { useGlobalBatchChangesCodeHostConnection } from './backend'
@ -17,9 +19,22 @@ const DEFAULT_PERMISSIONS = {
metadata: 'read',
}
export const BatchChangesCreateGitHubAppPage: React.FunctionComponent = () => {
interface BatchChangesCreateGitHubAppPageProps {
authenticatedUser: AuthenticatedUser
minimizedMode?: boolean
kind: GitHubAppKind
}
export const BatchChangesCreateGitHubAppPage: FC<BatchChangesCreateGitHubAppPageProps> = ({
minimizedMode,
kind,
authenticatedUser,
}) => {
const location = useLocation()
const baseURL = new URLSearchParams(location.search).get('baseURL')
const searchParams = new URLSearchParams(location.search)
const baseURL = searchParams.get('baseURL')
const isGitHubAppKindCredential = kind === GitHubAppKind.USER_CREDENTIAL || kind === GitHubAppKind.SITE_CREDENTIAL
const { connection } = useGlobalBatchChangesCodeHostConnection()
// validateURL compares a provided URL against the URLs of existing commit signing
@ -35,26 +50,48 @@ export const BatchChangesCreateGitHubAppPage: React.FunctionComponent = () => {
// assume this call will succeed.
const asURL = new URL(url)
const isDuplicate = connection.nodes.some(node => {
const existingURL = node.commitSigningConfiguration?.baseURL
const existingURL = isGitHubAppKindCredential
? node.externalServiceURL
: node.commitSigningConfiguration?.baseURL
if (!existingURL) {
return false
}
return new URL(existingURL).hostname === asURL.hostname
})
return isDuplicate ? 'A commit signing integration for the code host at this URL already exists.' : true
const errorMsg = `A ${
isGitHubAppKindCredential ? 'GitHub app' : 'commit signing'
} integration for the code host at this URL already exists.`
return isDuplicate ? errorMsg : true
},
[connection]
[connection, isGitHubAppKindCredential]
)
const pageTitle = isGitHubAppKindCredential
? `Create GitHub app for ${
kind === GitHubAppKind.USER_CREDENTIAL ? authenticatedUser.username : 'Global'
} Batch Changes credential`
: 'Create GitHub app for commit signing'
const defaultAppName = computeAppName(kind, authenticatedUser?.username)
// COMMIT SIGNING apps do not need permissions to create pull request, we duplicate the
// commit using the GraphQL request and the changeset is created with the PAT.
const permissions = {
...DEFAULT_PERMISSIONS,
...(isGitHubAppKindCredential ? { pull_requests: 'write' } : {}),
}
return (
<CreateGitHubAppPage
minimizedMode={minimizedMode}
authenticatedUser={authenticatedUser}
appKind={kind}
defaultEvents={DEFAULT_EVENTS}
defaultPermissions={DEFAULT_PERMISSIONS}
pageTitle="Create GitHub App for commit signing"
defaultPermissions={permissions}
pageTitle={pageTitle}
headerDescription={
<>
Register a GitHub App to enable Sourcegraph to sign commits for Batch Change changesets on your
behalf.
Register a GitHub App to enable Sourcegraph{' '}
{isGitHubAppKindCredential ? 'create' : 'sign commits for'} Batch Change changesets on your behalf.
{/* TODO (@BolajiOlajide/@bahrmichael) update link here for credential github app */}
<Link to="/help/admin/config/batch_changes#commit-signing-for-github" className="ml-1">
See how GitHub App configuration works.
</Link>
@ -62,10 +99,26 @@ export const BatchChangesCreateGitHubAppPage: React.FunctionComponent = () => {
}
headerAnnotation={<FeedbackBadge status="beta" feedback={{ mailto: 'support@sourcegraph.com' }} />}
appDomain={GitHubAppDomain.BATCHES}
defaultAppName="Sourcegraph Commit Signing"
defaultAppName={defaultAppName}
baseURL={baseURL?.length ? baseURL : undefined}
validateURL={validateURL}
telemetryRecorder={noOpTelemetryRecorder}
/>
)
}
const computeAppName = (kind: GitHubAppKind, username?: string): string => {
switch (kind) {
case GitHubAppKind.COMMIT_SIGNING: {
return 'Sourcegraph Commit Signing'
}
case GitHubAppKind.USER_CREDENTIAL: {
return `${capitalize(username)}'s Batch Changes GitHub App`
}
default: {
return 'Batch Changes GitHub App'
}
}
}

View File

@ -9,6 +9,7 @@ import {
type BatchChangesCredentialFields,
ExternalServiceKind,
type UserBatchChangesCodeHostsResult,
type UserAreaUserFields,
} from '../../../graphql-operations'
import { BATCH_CHANGES_SITE_CONFIGURATION } from '../backend'
import { noRolloutWindowMockResult, rolloutWindowConfigMockResult } from '../mocks'
@ -133,7 +134,7 @@ export const Overview: StoryFn = () => (
},
]}
>
<BatchChangesSettingsArea {...props} user={{ id: 'user-id-1' }} />
<BatchChangesSettingsArea {...props} user={{ id: 'user-id-1' } as UserAreaUserFields} />
</MockedTestProvider>
)}
</WebStory>
@ -214,7 +215,7 @@ export const ConfigAdded: StoryFn = () => (
},
]}
>
<BatchChangesSettingsArea {...props} user={{ id: 'user-id-2' }} />
<BatchChangesSettingsArea {...props} user={{ id: 'user-id-2' } as UserAreaUserFields} />
</MockedTestProvider>
)}
</WebStory>
@ -297,7 +298,7 @@ export const RolloutWindowsConfigurationStory: StoryFn = () => (
},
]}
>
<BatchChangesSettingsArea {...props} user={{ id: 'user-id-2' }} />
<BatchChangesSettingsArea {...props} user={{ id: 'user-id-2' } as UserAreaUserFields} />
</MockedTestProvider>
)}
</WebStory>

View File

@ -10,7 +10,7 @@ import { UserCommitSigningIntegrations } from './CommitSigningIntegrations'
import { RolloutWindowsConfiguration } from './RolloutWindowsConfiguration'
export interface BatchChangesSettingsAreaProps {
user: Pick<UserAreaUserFields, 'id'>
user: UserAreaUserFields
}
/** The page area for all batch changes settings. It's shown in the user settings sidebar. */
@ -23,7 +23,7 @@ export const BatchChangesSettingsArea: React.FunctionComponent<
<RolloutWindowsConfiguration />
<UserCodeHostConnections
headerLine={<Text>Add access tokens to enable Batch Changes changeset creation on your code hosts.</Text>}
userID={props.user.id}
user={props.user}
/>
<UserCommitSigningIntegrations userID={props.user.id} />
</div>

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { PageHeader, Alert, Text } from '@sourcegraph/wildcard'
import { PageTitle } from '../../../components/PageTitle'

View File

@ -8,6 +8,7 @@ import {
type BatchChangesCredentialFields,
type CheckBatchChangesCredentialResult,
ExternalServiceKind,
type UserAreaUserFields,
} from '../../../graphql-operations'
import { CHECK_BATCH_CHANGES_CREDENTIAL } from './backend'
@ -67,7 +68,7 @@ export const Overview: StoryFn = () => (
commitSigningConfiguration: null,
}}
refetchAll={() => {}}
userID="123"
user={{ id: '123' } as UserAreaUserFields}
/>
</MockedTestProvider>
)}

View File

@ -13,7 +13,7 @@ import type {
BatchChangesCodeHostFields,
CheckBatchChangesCredentialResult,
CheckBatchChangesCredentialVariables,
Scalars,
UserAreaUserFields,
} from '../../../graphql-operations'
import { AddCredentialModal } from './AddCredentialModal'
@ -27,7 +27,7 @@ import styles from './CodeHostConnectionNode.module.scss'
export interface CodeHostConnectionNodeProps {
node: BatchChangesCodeHostFields
refetchAll: () => void
userID: Scalars['ID'] | null
user: UserAreaUserFields | null
}
type OpenModal = 'add' | 'view' | 'delete'
@ -35,7 +35,7 @@ type OpenModal = 'add' | 'view' | 'delete'
export const CodeHostConnectionNode: React.FunctionComponent<React.PropsWithChildren<CodeHostConnectionNodeProps>> = ({
node,
refetchAll,
userID,
user,
}) => {
const [checkCredError, setCheckCredError] = useState<ApolloError | undefined>()
const ExternalServiceIcon = defaultExternalServices[node.externalServiceKind].icon
@ -75,7 +75,7 @@ export const CodeHostConnectionNode: React.FunctionComponent<React.PropsWithChil
refetchAll()
}, [refetchAll, buttonReference])
const isEnabled = node.credential !== null && (userID === null || !node.credential.isSiteCredential)
const isEnabled = node.credential !== null && (user === null || !node.credential.isSiteCredential)
const headingAriaLabel = `Sourcegraph ${
isEnabled ? 'has credentials configured' : 'does not have credentials configured'
@ -203,7 +203,7 @@ export const CodeHostConnectionNode: React.FunctionComponent<React.PropsWithChil
<AddCredentialModal
onCancel={closeModal}
afterCreate={afterAction}
userID={userID}
user={user}
externalServiceKind={node.externalServiceKind}
externalServiceURL={node.externalServiceURL}
requiresSSH={node.requiresSSH}

View File

@ -1,7 +1,10 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import { Container, Link, H3, Text } from '@sourcegraph/wildcard'
import { DismissibleAlert } from '../../../components/DismissibleAlert'
import type { UseShowMorePaginationResult } from '../../../components/FilteredConnection/hooks/useShowMorePagination'
import {
ConnectionContainer,
@ -12,11 +15,13 @@ import {
ShowMoreButton,
SummaryContainer,
} from '../../../components/FilteredConnection/ui'
import type {
BatchChangesCodeHostFields,
GlobalBatchChangesCodeHostsResult,
Scalars,
UserBatchChangesCodeHostsResult,
import { GitHubAppFailureAlert } from '../../../components/gitHubApps/GitHubAppFailureAlert'
import {
type BatchChangesCodeHostFields,
GitHubAppKind,
type GlobalBatchChangesCodeHostsResult,
type UserBatchChangesCodeHostsResult,
type UserAreaUserFields,
} from '../../../graphql-operations'
import { useGlobalBatchChangesCodeHostConnection, useUserBatchChangesCodeHostConnection } from './backend'
@ -28,20 +33,24 @@ export interface GlobalCodeHostConnectionsProps {
export const GlobalCodeHostConnections: React.FunctionComponent<
React.PropsWithChildren<GlobalCodeHostConnectionsProps>
> = props => (
<CodeHostConnections userID={null} connectionResult={useGlobalBatchChangesCodeHostConnection()} {...props} />
)
> = props => <CodeHostConnections user={null} connectionResult={useGlobalBatchChangesCodeHostConnection()} {...props} />
export interface UserCodeHostConnectionsProps extends GlobalCodeHostConnectionsProps {
userID: Scalars['ID']
user: UserAreaUserFields
}
export const UserCodeHostConnections: React.FunctionComponent<
React.PropsWithChildren<UserCodeHostConnectionsProps>
> = props => <CodeHostConnections connectionResult={useUserBatchChangesCodeHostConnection(props.userID)} {...props} />
> = ({ user, headerLine }) => (
<CodeHostConnections
connectionResult={useUserBatchChangesCodeHostConnection(user.id)}
headerLine={headerLine}
user={user}
/>
)
interface CodeHostConnectionsProps extends GlobalCodeHostConnectionsProps {
userID: Scalars['ID'] | null
user: UserAreaUserFields | null
connectionResult: UseShowMorePaginationResult<
GlobalBatchChangesCodeHostsResult | UserBatchChangesCodeHostsResult,
BatchChangesCodeHostFields
@ -49,11 +58,17 @@ interface CodeHostConnectionsProps extends GlobalCodeHostConnectionsProps {
}
const CodeHostConnections: React.FunctionComponent<React.PropsWithChildren<CodeHostConnectionsProps>> = ({
userID,
user,
headerLine,
connectionResult,
}) => {
const { loading, hasNextPage, fetchMore, connection, error, refetchAll } = connectionResult
const location = useLocation()
const kind = new URLSearchParams(location.search).get('kind')
const success = new URLSearchParams(location.search).get('success') === 'true'
const appName = new URLSearchParams(location.search).get('app_name')
const setupError = new URLSearchParams(location.search).get('error')
const shouldShowError = !success && setupError && kind !== GitHubAppKind.COMMIT_SIGNING
return (
<Container className="mb-3">
<H3>Code host tokens</H3>
@ -61,13 +76,23 @@ const CodeHostConnections: React.FunctionComponent<React.PropsWithChildren<CodeH
<ConnectionContainer className="mb-3">
{error && <ConnectionError errors={[error.message]} />}
{loading && !connection && <ConnectionLoading />}
{success && (
<DismissibleAlert
className="mb-3"
variant="success"
partialStorageKey="batch-changes-github-app-integration-success"
>
GitHub App {appName?.length ? `"${appName}" ` : ''}successfully connected.
</DismissibleAlert>
)}
{shouldShowError && <GitHubAppFailureAlert error={setupError} />}
<ConnectionList as="ul" className="list-group" aria-label="code host connections">
{connection?.nodes?.map(node => (
<CodeHostConnectionNode
key={node.externalServiceURL}
node={node}
refetchAll={refetchAll}
userID={userID}
user={user}
/>
))}
</ConnectionList>

View File

@ -29,6 +29,7 @@ export const FEATURE_FLAGS = [
'sourcegraph-operator-site-admin-hide-maintenance',
'sourcegraph-cloud-managed-feature-flags-warning-shown',
'ab-shortened-install-first-signup-flow-cody-2024-04',
'batches-github-app-integration',
] as const
export type FeatureFlagName = typeof FEATURE_FLAGS[number]

View File

@ -11,6 +11,7 @@ import { LoadingSpinner, ErrorAlert } from '@sourcegraph/wildcard'
import {
GitHubAppDomain,
GitHubAppKind,
type SiteExternalServiceConfigResult,
type SiteExternalServiceConfigVariables,
} from '../graphql-operations'
@ -82,6 +83,7 @@ export const SiteAdminGitHubAppsArea: FC<Props> = props => {
path="new"
element={
<CreateGitHubAppPage
appKind={GitHubAppKind.REPO_SYNC}
defaultEvents={DEFAULT_EVENTS}
defaultPermissions={DEFAULT_PERMISSIONS}
appDomain={GitHubAppDomain.REPOS}

View File

@ -8,6 +8,7 @@ import { SHOW_BUSINESS_FEATURES } from '../enterprise/dotcom/productSubscription
import { OwnAnalyticsPage } from '../enterprise/own/admin-ui/OwnAnalyticsPage'
import type { SiteAdminRolesPageProps } from '../enterprise/rbac/SiteAdminRolesPage'
import type { RoleAssignmentModalProps } from '../enterprise/site-admin/UserManagement/components/RoleAssignmentModal'
import { GitHubAppKind } from '../graphql-operations'
import { checkRequestAccessAllowed } from '../util/checkRequestAccessAllowed'
import { isPackagesEnabled } from './flags'
@ -408,7 +409,12 @@ export const otherSiteAdminRoutes: readonly SiteAdminAreaRoute[] = [
},
{
path: '/batch-changes/github-apps/new',
render: () => <BatchChangesCreateGitHubAppPage />,
render: ({ authenticatedUser }) => (
<BatchChangesCreateGitHubAppPage
authenticatedUser={authenticatedUser}
kind={GitHubAppKind.COMMIT_SIGNING}
/>
),
condition: ({ batchChangesEnabled }) => batchChangesEnabled,
},
{

View File

@ -41,6 +41,28 @@ enum GitHubAppDomain {
BATCHES
}
"""
GitHubAppKind enumerates the domains in which GitHub Apps can be used.
"""
enum GitHubAppKind {
"""
GitHub Apps that are configured for commit signing.
"""
COMMIT_SIGNING
"""
GitHub Apps that are configured for a user's batch changes credential.
"""
USER_CREDENTIAL
"""
GitHub Apps that are configured for repo syncing.
"""
REPO_SYNC
"""
GitHub Apps that are configured for a site's batch changes credential.
"""
SITE_CREDENTIAL
}
"""
A list of GitHub Apps.
"""