Code Intel: Add inference script preview (#48898)

This commit is contained in:
Tom Ross 2023-03-09 13:26:00 +00:00 committed by GitHub
parent 64cd22d127
commit 971891ad27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1261 additions and 277 deletions

View File

@ -12,6 +12,11 @@ export interface SaveToolbarProps {
saving?: boolean
error?: Error
/**
* Determine if consumer children is added before or after the toolbar actions.
*/
childrenPosition?: 'start' | 'end'
onSave: () => void
onDiscard: () => void
/**
@ -32,7 +37,17 @@ export type SaveToolbarPropsGenerator<T extends object> = (
export const SaveToolbar: React.FunctionComponent<
React.PropsWithChildren<React.PropsWithChildren<SaveToolbarProps>>
> = ({ dirty, saving, error, onSave, onDiscard, children, willShowError, saveDiscardDisabled }) => {
> = ({
dirty,
saving,
error,
onSave,
onDiscard,
children,
childrenPosition = 'end',
willShowError,
saveDiscardDisabled,
}) => {
const disabled = saveDiscardDisabled ? saveDiscardDisabled() : saving || !dirty
let saveDiscardTitle: string | undefined
if (saving) {
@ -54,6 +69,7 @@ export const SaveToolbar: React.FunctionComponent<
</div>
)}
<div className={classNames('mt-2', styles.actions)}>
{childrenPosition === 'start' && children}
<Button
disabled={disabled}
title={saveDiscardTitle || 'Save changes'}
@ -72,7 +88,7 @@ export const SaveToolbar: React.FunctionComponent<
>
Discard changes
</Button>
{children}
{childrenPosition === 'end' && children}
{saving && (
<span className={classNames(styles.item, styles.message)}>
<LoadingSpinner /> Saving...

View File

@ -1,114 +0,0 @@
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { useIsLightTheme } from '@sourcegraph/shared/src/theme'
import { LoadingSpinner, PageHeader, screenReaderAnnounce, ErrorAlert } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../../../auth'
import { PageTitle } from '../../../../components/PageTitle'
import { SaveToolbar, SaveToolbarProps, SaveToolbarPropsGenerator } from '../../../../components/SaveToolbar'
import { DynamicallyImportedMonacoSettingsEditor } from '../../../../settings/DynamicallyImportedMonacoSettingsEditor'
import { INFERENCE_SCRIPT, useInferenceScript } from '../hooks/useInferenceScript'
import { useUpdateInferenceScript } from '../hooks/useUpdateInferenceScript'
import styles from './CodeIntelConfigurationPageHeader.module.scss'
export interface InferenceScriptEditorProps extends TelemetryProps {
authenticatedUser: AuthenticatedUser | null
}
export const InferenceScriptEditor: FunctionComponent<InferenceScriptEditorProps> = ({
authenticatedUser,
telemetryService,
}) => {
const { inferenceScript, loadingScript, fetchError } = useInferenceScript()
const { updateInferenceScript, isUpdating, updatingError } = useUpdateInferenceScript()
const save = useCallback(
async (script: string) =>
updateInferenceScript({
variables: { script },
refetchQueries: [INFERENCE_SCRIPT],
}).then(() => {
screenReaderAnnounce('Saved successfully')
setDirty(false)
}),
[updateInferenceScript]
)
const [dirty, setDirty] = useState<boolean>()
const isLightTheme = useIsLightTheme()
const customToolbar = useMemo<{
saveToolbar: FunctionComponent<SaveToolbarProps>
propsGenerator: SaveToolbarPropsGenerator<{}>
}>(
() => ({
saveToolbar: SaveToolbar,
propsGenerator: props => {
const mergedProps = {
...props,
loading: isUpdating,
}
mergedProps.willShowError = () => !mergedProps.saving
mergedProps.saveDiscardDisabled = () => mergedProps.saving || !dirty
return mergedProps
},
}),
[dirty, isUpdating]
)
const title = (
<>
<PageTitle title="Code graph inference script" />
<div className={styles.grid}>
<PageHeader
headingElement="h2"
path={[
{
text: <>Code graph inference script</>,
},
]}
description={`Lua script that emits complete and/or partial auto-indexing
job specifications. `}
className="mb-3"
/>
</div>
</>
)
if (fetchError) {
return (
<>
{title}
<ErrorAlert prefix="Error fetching inference script" error={fetchError} />
</>
)
}
return (
<>
{title}
{updatingError && <ErrorAlert prefix="Error saving index configuration" error={updatingError} />}
{loadingScript ? (
<LoadingSpinner />
) : (
<DynamicallyImportedMonacoSettingsEditor
value={inferenceScript}
language="lua"
canEdit={authenticatedUser?.siteAdmin}
readOnly={!authenticatedUser?.siteAdmin}
onSave={save}
saving={isUpdating}
height={600}
isLightTheme={isLightTheme}
telemetryService={telemetryService}
customSaveToolbar={authenticatedUser?.siteAdmin ? customToolbar : undefined}
onDirtyChange={setDirty}
/>
)}
</>
)
}

View File

@ -0,0 +1,3 @@
.command-input {
max-height: 20rem;
}

View File

@ -0,0 +1,94 @@
import React, { useMemo, useRef } from 'react'
import { defaultKeymap, history } from '@codemirror/commands'
import { StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language'
import { shell } from '@codemirror/legacy-modes/mode/shell'
import { EditorState, Extension } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { tags } from '@lezer/highlight'
import classNames from 'classnames'
import { useCodeMirror, defaultSyntaxHighlighting } from '@sourcegraph/shared/src/components/CodeMirrorEditor'
import styles from './CommandInput.module.scss'
const shellHighlighting: Extension = [
syntaxHighlighting(HighlightStyle.define([{ tag: [tags.keyword], class: 'hljs-keyword' }])),
defaultSyntaxHighlighting,
]
const staticExtensions: Extension = [
keymap.of(defaultKeymap),
history(),
EditorView.theme({
'&': {
flex: 1,
backgroundColor: 'var(--input-bg)',
borderRadius: 'var(--border-radius)',
borderColor: 'var(--border-color)',
marginRight: '0.5rem',
},
'&.cm-editor.cm-focused': {
outline: 'none',
},
'.cm-scroller': {
overflowX: 'hidden',
},
'.cm-content': {
caretColor: 'var(--search-query-text-color)',
fontFamily: 'var(--code-font-family)',
fontSize: 'var(--code-font-size)',
},
'.cm-content.focus-visible': {
boxShadow: 'none',
},
'.cm-line': {
padding: '0',
},
}),
StreamLanguage.define(shell),
shellHighlighting,
]
interface CommandInputProps {
value: string
onChange?: (value: string) => void
readOnly: boolean
className?: string
}
export const CommandInput: React.FunctionComponent<CommandInputProps> = React.memo(function CodeMirrorComandInput({
value,
className,
readOnly,
onChange = () => {},
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<EditorView | null>(null)
useCodeMirror(
editorRef,
containerRef,
value,
useMemo(
() => [
staticExtensions,
EditorState.readOnly.of(readOnly),
EditorView.updateListener.of(update => {
if (update.docChanged) {
onChange(update.state.sliceDoc())
}
}),
],
[onChange, readOnly]
)
)
return (
<div
ref={containerRef}
data-editor="codemirror6"
className={classNames('form-control', styles.commandInput, className)}
/>
)
})

View File

@ -0,0 +1,14 @@
.job-field {
display: grid;
grid-template-columns: [label] 15% [value] 85%;
&:not(:last-of-type) {
margin-bottom: 0.5rem;
}
}
.job-label {
margin-top: 0.25rem;
grid-area: label;
margin-bottom: 0;
}

View File

@ -0,0 +1,19 @@
import { Label } from '@sourcegraph/wildcard'
import styles from './IndexJobLabel.module.scss'
interface IndexJobLabelProps {
label: string
}
export const IndexJobLabel: React.FunctionComponent<React.PropsWithChildren<IndexJobLabelProps>> = ({
label,
children,
}) => (
<>
<li className={styles.jobField}>
<Label className={styles.jobLabel}>{label}:</Label>
{children}
</li>
</>
)

View File

@ -0,0 +1,92 @@
.job-anchor {
display: block;
position: relative;
top: -4rem;
visibility: hidden;
}
// IndexJob
.job {
padding: 1rem;
margin-bottom: 1.5rem;
}
.job-content {
padding: 0.25rem;
margin-bottom: 0;
list-style-type: none;
}
// IndexJobStep
.job-step-container {
padding: 0 1rem;
margin-top: 1rem;
border: 3px solid var(--subtle-bg);
}
.job-step {
padding: 1rem 0;
border-bottom: 3px solid var(--subtle-bg);
&:last-of-type {
border-bottom: none;
}
}
.job-step-content {
padding: 0;
list-style-type: none;
}
// Misc
.job-header,
.job-step-header {
font-weight: 600;
display: flex;
justify-content: space-between;
margin-top: -0.25rem;
margin-left: -0.25rem;
margin-bottom: 1rem;
}
.job-input {
grid-area: value;
// Ensure consistent height even when no value
min-height: 2rem;
height: auto;
margin-bottom: 0.5rem;
overflow: auto;
input[readonly] {
// Override disabled bg-color
background-color: var(--input-bg);
}
&:last-of-type {
margin-bottom: 0;
}
}
.job-input-action {
grid-area: value;
min-height: 2rem;
margin-bottom: 0.5rem;
max-width: 7rem;
&:last-of-type {
margin-bottom: 0;
}
}
.job-command-container {
grid-area: value;
padding: 1rem;
border: 3px solid var(--subtle-bg);
}
.job-command {
display: flex;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}

View File

@ -0,0 +1,248 @@
import React from 'react'
import { mdiClose, mdiPlus } from '@mdi/js'
import { uniqueId } from 'lodash'
import { Button, Container, H3, H4, Icon, Input } from '@sourcegraph/wildcard'
import { CommandInput } from './CommandInput'
import { IndexJobLabel } from './IndexJobLabel'
import { InferenceArrayValue, InferenceFormJob, InferenceFormJobStep } from './types'
import styles from './IndexJobNode.module.scss'
interface IndexJobNodeProps {
job: InferenceFormJob
jobNumber: number
readOnly: boolean
onChange: (name: keyof InferenceFormJob, value: unknown) => void
onRemove: () => void
}
export const IndexJobNode: React.FunctionComponent<IndexJobNodeProps> = ({
job,
jobNumber,
readOnly,
onChange,
onRemove,
}) => {
const comparisonKey = job.meta.id
return (
<Container id={comparisonKey} className={styles.job}>
<div className={styles.jobHeader}>
<H3 className="mb-0">Job #{jobNumber}</H3>
{!readOnly && (
<Button variant="icon" className="ml-2 text-danger" aria-label="Remove" onClick={onRemove}>
<Icon svgPath={mdiClose} aria-hidden={true} />
</Button>
)}
</div>
<ul className={styles.jobContent}>
<IndexJobLabel label="Root">
<Input
value={job.root}
onChange={event => onChange('root', event.target.value)}
readOnly={readOnly}
className={styles.jobInput}
/>
</IndexJobLabel>
<IndexJobLabel label="Indexer">
<CommandInput
value={job.indexer}
onChange={value => onChange('indexer', value)}
readOnly={readOnly}
className={styles.jobInput}
/>
</IndexJobLabel>
<IndexJobLabel label="Indexer args">
<IndexCommandNode
commands={job.indexer_args}
name="indexer_args"
addLabel="arg"
readOnly={readOnly}
onChange={onChange}
/>
</IndexJobLabel>
<IndexJobLabel label="Requested env vars">
<IndexCommandNode
commands={job.requestedEnvVars ?? []}
name="requestedEnvVars"
addLabel="env var"
readOnly={readOnly}
onChange={onChange}
/>
</IndexJobLabel>
<IndexJobLabel label="Local steps">
<IndexCommandNode
commands={job.local_steps}
name="local_steps"
addLabel="local step"
readOnly={readOnly}
onChange={onChange}
/>
</IndexJobLabel>
<IndexJobLabel label="Outfile">
<Input
value={job.outfile}
onChange={event => onChange('outfile', event.target.value)}
readOnly={readOnly}
className={styles.jobInput}
/>
</IndexJobLabel>
{job.steps.length > 0 && (
<Container className={styles.jobStepContainer} as="li">
{job.steps.map((step, index) => (
<div className={styles.jobStep} key={step.meta.id}>
<div className={styles.jobStepHeader}>
<H4 className="mb-0">Step #{index + 1}</H4>
{!readOnly && (
<Button
variant="icon"
className="ml-2 text-danger"
aria-label="Remove"
onClick={() => {
const steps = [...job.steps]
steps.splice(index, 1)
onChange('steps', steps)
}}
>
<Icon svgPath={mdiClose} aria-hidden={true} />
</Button>
)}
</div>
<IndexStepNode
step={step}
readOnly={readOnly}
onChange={(name, value) => {
const steps = [...job.steps]
steps[index] = { ...steps[index], [name]: value }
onChange('steps', steps)
}}
/>
</div>
))}
</Container>
)}
{!readOnly && (
<Button
variant="secondary"
className="d-block mt-2 ml-auto"
onClick={() => {
onChange('steps', [
...job.steps,
{ root: '', image: '', commands: [], meta: { id: uniqueId() } },
])
}}
>
<Icon svgPath={mdiPlus} aria-hidden={true} className="mr-1" />
Add step
</Button>
)}
</ul>
</Container>
)
}
interface IndexStepNodeProps {
step: InferenceFormJobStep
readOnly: boolean
onChange: (name: keyof InferenceFormJobStep, value: unknown) => void
}
const IndexStepNode: React.FunctionComponent<IndexStepNodeProps> = ({ step, readOnly, onChange }) => (
<ul className={styles.jobStepContent}>
<IndexJobLabel label="Root">
<Input
value={step.root}
onChange={event => onChange('root', event.target.value)}
readOnly={readOnly}
className={styles.jobInput}
/>
</IndexJobLabel>
<IndexJobLabel label="Image">
<CommandInput
value={step.image}
onChange={value => onChange('image', value)}
readOnly={readOnly}
className={styles.jobInput}
/>
</IndexJobLabel>
<IndexJobLabel label="Commands">
<IndexCommandNode<keyof InferenceFormJobStep>
commands={step.commands}
name="commands"
addLabel="command"
readOnly={readOnly}
onChange={onChange}
/>
</IndexJobLabel>
</ul>
)
interface IndexCommandNodeProps<formKey = keyof InferenceFormJob> {
name: formKey
addLabel: string
commands: InferenceArrayValue[]
onChange: (name: formKey, value: unknown) => void
readOnly: boolean
}
const IndexCommandNode = <formKey,>({
name,
addLabel,
commands,
onChange,
readOnly,
}: IndexCommandNodeProps<formKey>): JSX.Element | null => (
<div className={styles.jobCommandContainer}>
{commands.map((command, index) => (
<div className={styles.jobCommand} key={command.meta.id}>
<CommandInput
value={command.value}
onChange={value => {
const prevCommands = [...commands]
prevCommands[index].value = value
onChange(name, prevCommands)
}}
readOnly={readOnly}
className={styles.jobInput}
/>
{!readOnly && (
<Button
variant="icon"
className="ml-2 text-danger"
aria-label="Remove"
onClick={() => {
const prevCommands = [...commands]
prevCommands.splice(index, 1)
onChange(name, prevCommands)
}}
>
<Icon svgPath={mdiClose} aria-hidden={true} />
</Button>
)}
</div>
))}
{!readOnly && (
<Button
variant="secondary"
size="sm"
onClick={() => {
onChange(name, [
...commands,
{
value: '',
meta: {
id: uniqueId(),
},
},
])
}}
>
<Icon svgPath={mdiPlus} aria-hidden={true} className="mr-1" />
Add {addLabel}
</Button>
)}
</div>
)

View File

@ -0,0 +1,103 @@
import React, { useCallback, useState } from 'react'
import AJV from 'ajv'
import addFormats from 'ajv-formats'
import { Button, Form } from '@sourcegraph/wildcard'
import { AutoIndexJobDescriptionFields } from '../../../../../graphql-operations'
import schema from '../../schema.json'
import { autoIndexJobsToFormData } from './auto-index-to-form-job'
import { formDataToSchema } from './form-data-to-schema'
import { IndexJobNode } from './IndexJobNode'
import { InferenceFormData, InferenceFormJob, SchemaCompatibleInferenceFormData } from './types'
const ajv = new AJV({ strict: false })
addFormats(ajv)
interface InferenceFormProps {
readOnly: boolean
jobs: AutoIndexJobDescriptionFields[]
onSubmit?: (data: SchemaCompatibleInferenceFormData) => void
}
export const InferenceForm: React.FunctionComponent<InferenceFormProps> = ({ jobs, readOnly, onSubmit }) => {
const [formData, setFormData] = useState<InferenceFormData>(autoIndexJobsToFormData(jobs))
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!onSubmit) {
return
}
const schemaCompatibleFormData = formDataToSchema(formData)
// Validate form data against JSONSchema
const isValid = ajv.validate(schema, schemaCompatibleFormData)
if (isValid) {
onSubmit(schemaCompatibleFormData)
}
},
[formData, onSubmit]
)
const getChangeHandler = useCallback(
(id: string) => (name: keyof InferenceFormJob, value: unknown) => {
setFormData(previous => {
const index = previous.index_jobs.findIndex(job => job.meta.id === id)
const job = previous.index_jobs[index]
return {
index_jobs: [
...previous.index_jobs.slice(0, index),
{
...job,
[name]: value,
},
...previous.index_jobs.slice(index + 1),
],
}
})
},
[]
)
const getRemoveHandler = useCallback(
(id: string) => () => {
if (!window.confirm('Are you sure you want to remove this entire job?')) {
return
}
setFormData(previous => ({
index_jobs: previous.index_jobs.filter(job => job.meta.id !== id),
}))
},
[]
)
return (
<Form onSubmit={handleSubmit}>
<>
{formData.index_jobs.map((job, index) => (
<IndexJobNode
key={job.meta.id}
job={job}
jobNumber={index + 1}
readOnly={readOnly}
onChange={getChangeHandler(job.meta.id)}
onRemove={getRemoveHandler(job.meta.id)}
/>
))}
</>
{!readOnly && (
<Button type="submit" variant="primary">
Save
</Button>
)}
</Form>
)
}

View File

@ -0,0 +1,136 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`autoIndexToAutoIndexSchema should build form data as expected 1`] = `
Object {
"index_jobs": Array [
Object {
"indexer": "indexer-1@123",
"indexer_args": Array [
Object {
"meta": Object {
"id": "test-id",
},
"value": "arg-1",
},
Object {
"meta": Object {
"id": "test-id",
},
"value": "arg-2",
},
],
"local_steps": Array [
Object {
"meta": Object {
"id": "test-id",
},
"value": "command-1",
},
Object {
"meta": Object {
"id": "test-id",
},
"value": "command-2",
},
],
"meta": Object {
"id": "key-1",
},
"outfile": "outfile-1",
"requestedEnvVars": Array [
Object {
"meta": Object {
"id": "test-id",
},
"value": "ENV_VAR",
},
Object {
"meta": Object {
"id": "test-id",
},
"value": "ENV_VAR_2",
},
],
"root": "root-1",
"steps": Array [
Object {
"commands": Array [
Object {
"meta": Object {
"id": "test-id",
},
"value": "pre-command-1",
},
Object {
"meta": Object {
"id": "test-id",
},
"value": "pre-command-2",
},
],
"image": "indexer-1@123",
"meta": Object {
"id": "test-id",
},
"root": "pre-root-1",
},
],
},
Object {
"indexer": "",
"indexer_args": Array [],
"local_steps": Array [],
"meta": Object {
"id": "key-2",
},
"outfile": "",
"requestedEnvVars": Array [],
"root": "",
"steps": Array [],
},
],
}
`;
exports[`autoIndexToAutoIndexSchema should build schema data as expected 1`] = `
Object {
"index_jobs": Array [
Object {
"indexer": "indexer-1@123",
"indexer_args": Array [
"arg-1",
"arg-2",
],
"local_steps": Array [
"command-1",
"command-2",
],
"outfile": "outfile-1",
"requestedEnvVars": Array [
"ENV_VAR",
"ENV_VAR_2",
],
"root": "root-1",
"steps": Array [
Object {
"commands": Array [
"pre-command-1",
"pre-command-2",
],
"image": "indexer-1@123",
"root": "pre-root-1",
},
],
},
Object {
"indexer": "",
"indexer_args": Array [],
"local_steps": Array [],
"outfile": "",
"requestedEnvVars": Array [],
"root": "",
"steps": Array [],
},
],
}
`;

View File

@ -0,0 +1,51 @@
import { uniqueId } from 'lodash'
import { AutoIndexJobDescriptionFields, AutoIndexLsifPreIndexFields } from '../../../../../graphql-operations'
import { InferenceFormData, InferenceFormJobStep, InferenceFormJob } from './types'
const autoIndexStepToFormStep = (step: AutoIndexLsifPreIndexFields): InferenceFormJobStep => ({
root: step.root,
image: step.image ?? '',
commands: step.commands.map(arg => ({
value: arg,
meta: {
id: uniqueId(),
},
})),
meta: {
id: uniqueId(),
},
})
const autoIndexJobToFormJob = (job: AutoIndexJobDescriptionFields): InferenceFormJob => ({
root: job.root,
indexer: job.indexer?.imageName ?? '',
indexer_args: job.steps.index.indexerArgs.map(arg => ({
value: arg,
meta: {
id: uniqueId(),
},
})),
requestedEnvVars: (job.steps.index.requestedEnvVars ?? []).map(envVar => ({
value: envVar,
meta: {
id: uniqueId(),
},
})),
local_steps: job.steps.index.commands.map(command => ({
value: command,
meta: {
id: uniqueId(),
},
})),
outfile: job.steps.index.outfile ?? '',
steps: job.steps.preIndex.map(autoIndexStepToFormStep),
meta: {
id: job.comparisonKey,
},
})
export const autoIndexJobsToFormData = (jobs: AutoIndexJobDescriptionFields[]): InferenceFormData => ({
index_jobs: jobs.map(autoIndexJobToFormJob),
})

View File

@ -0,0 +1,76 @@
import AJV from 'ajv'
import addFormats from 'ajv-formats'
import { AutoIndexJobDescriptionFields } from '../../../../../graphql-operations'
import schema from '../../schema.json'
import { autoIndexJobsToFormData } from './auto-index-to-form-job'
import { formDataToSchema } from './form-data-to-schema'
const ajv = new AJV({ strict: false })
addFormats(ajv)
const mockAutoIndexJobs: AutoIndexJobDescriptionFields[] = [
{
comparisonKey: 'key-1',
indexer: {
imageName: 'indexer-1@123',
name: 'indexer-1',
url: 'https://example.com',
key: 'key-indexer-1',
},
root: 'root-1',
steps: {
index: {
commands: ['command-1', 'command-2'],
indexerArgs: ['arg-1', 'arg-2'],
requestedEnvVars: ['ENV_VAR', 'ENV_VAR_2'],
outfile: 'outfile-1',
},
preIndex: [
{
commands: ['pre-command-1', 'pre-command-2'],
root: 'pre-root-1',
image: 'indexer-1@123',
},
],
},
},
{
comparisonKey: 'key-2',
indexer: null,
root: '',
steps: {
index: {
commands: [],
indexerArgs: [],
requestedEnvVars: null,
outfile: null,
},
preIndex: [],
},
},
]
describe('autoIndexToAutoIndexSchema', () => {
it('should build form data as expected', () => {
const formData = autoIndexJobsToFormData(mockAutoIndexJobs)
expect(formData).toMatchSnapshot()
})
it('should build schema data as expected', () => {
const formData = autoIndexJobsToFormData(mockAutoIndexJobs)
const schemaData = formDataToSchema(formData)
expect(schemaData).toMatchSnapshot()
})
it('should build valid schema data', () => {
const formData = autoIndexJobsToFormData(mockAutoIndexJobs)
const schemaData = formDataToSchema(formData)
// Validate form data against JSONSchema
const isValid = ajv.validate(schema, schemaData)
expect(isValid).toBe(true)
})
})

View File

@ -0,0 +1,25 @@
import { InferenceFormData, SchemaCompatibleInferenceFormData } from './types'
export const formDataToSchema = (formData: InferenceFormData): SchemaCompatibleInferenceFormData => {
// Remove all meta information from the form data
const cleanJobs = formData.index_jobs.map(job => {
const { meta, ...cleanJob } = job
return {
...cleanJob,
steps: job.steps.map(step => {
const { meta, ...cleanStep } = step
return {
...cleanStep,
commands: step.commands.map(command => command.value),
}
}),
indexer_args: job.indexer_args.map(arg => arg.value),
requestedEnvVars: job.requestedEnvVars.map(envVar => envVar.value),
local_steps: job.local_steps.map(step => step.value),
}
})
return {
index_jobs: cleanJobs,
}
}

View File

@ -0,0 +1,51 @@
interface SchemaCompatibleInferenceFormStep {
root: string
image: string
commands: string[]
}
interface SchemaCompatibleInferenceFormJob {
root: string
indexer: string
indexer_args: string[]
requestedEnvVars: string[]
local_steps: string[]
outfile: string
steps: SchemaCompatibleInferenceFormStep[]
}
export interface SchemaCompatibleInferenceFormData {
index_jobs: SchemaCompatibleInferenceFormJob[]
}
/**
* Form data with additional metadata that is unrelated to submission
*/
interface MetaIdentifier {
meta: {
id: string
}
}
// InferenceJobs only return a unique ID for the actual job, not values within the job.
// As we want to build a dynamic form, we need each array of values to have a unique id.
export interface InferenceArrayValue extends MetaIdentifier {
value: string
}
export interface InferenceFormJobStep extends Omit<SchemaCompatibleInferenceFormStep, 'commands'>, MetaIdentifier {
commands: InferenceArrayValue[]
}
export interface InferenceFormJob
extends Omit<SchemaCompatibleInferenceFormJob, 'indexer_args' | 'requestedEnvVars' | 'local_steps' | 'steps'>,
MetaIdentifier {
indexer_args: InferenceArrayValue[]
requestedEnvVars: InferenceArrayValue[]
local_steps: InferenceArrayValue[]
steps: InferenceFormJobStep[]
}
export interface InferenceFormData {
index_jobs: InferenceFormJob[]
}

View File

@ -0,0 +1,91 @@
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { useIsLightTheme } from '@sourcegraph/shared/src/theme'
import { screenReaderAnnounce, ErrorAlert, Button } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../../../../auth'
import { SaveToolbar, SaveToolbarProps, SaveToolbarPropsGenerator } from '../../../../../components/SaveToolbar'
import { DynamicallyImportedMonacoSettingsEditor } from '../../../../../settings/DynamicallyImportedMonacoSettingsEditor'
import { INFERENCE_SCRIPT } from '../../hooks/useInferenceScript'
import { useUpdateInferenceScript } from '../../hooks/useUpdateInferenceScript'
export interface InferenceScriptEditorProps extends TelemetryProps {
script: string
authenticatedUser: AuthenticatedUser | null
setPreviewScript: (script: string) => void
previewDisabled: boolean
setTab: (index: number) => void
}
export const InferenceScriptEditor: FunctionComponent<InferenceScriptEditorProps> = ({
script: inferenceScript,
setPreviewScript,
previewDisabled,
setTab,
authenticatedUser,
telemetryService,
}) => {
const { updateInferenceScript, isUpdating, updatingError } = useUpdateInferenceScript()
const save = useCallback(
async (script: string) =>
updateInferenceScript({
variables: { script },
refetchQueries: [INFERENCE_SCRIPT],
}).then(() => {
screenReaderAnnounce('Saved successfully')
setDirty(false)
}),
[updateInferenceScript]
)
const [dirty, setDirty] = useState<boolean>()
const isLightTheme = useIsLightTheme()
const customToolbar = useMemo<{
saveToolbar: FunctionComponent<SaveToolbarProps>
propsGenerator: SaveToolbarPropsGenerator<{}>
}>(
() => ({
saveToolbar: props => (
<SaveToolbar childrenPosition="start" {...props}>
<Button variant="success" className="mr-2" onClick={() => setTab(1)} disabled={previewDisabled}>
Preview
</Button>
</SaveToolbar>
),
propsGenerator: props => {
const mergedProps = {
...props,
loading: isUpdating,
}
mergedProps.willShowError = () => !mergedProps.saving
mergedProps.saveDiscardDisabled = () => mergedProps.saving || !dirty
return mergedProps
},
}),
[dirty, isUpdating, previewDisabled, setTab]
)
return (
<>
{updatingError && <ErrorAlert prefix="Error saving index configuration" error={updatingError} />}
<DynamicallyImportedMonacoSettingsEditor
value={inferenceScript}
language="lua"
canEdit={authenticatedUser?.siteAdmin}
readOnly={!authenticatedUser?.siteAdmin}
onSave={save}
onChange={setPreviewScript}
saving={isUpdating}
height={600}
isLightTheme={isLightTheme}
telemetryService={telemetryService}
customSaveToolbar={authenticatedUser?.siteAdmin ? customToolbar : undefined}
onDirtyChange={setDirty}
/>
</>
)
}

View File

@ -0,0 +1,15 @@
.container {
padding: 1rem;
}
.action-container {
height: 100%;
width: 100%;
margin-bottom: 1rem;
}
.action-input {
margin-right: 0.5rem;
width: 25rem;
flex: 1;
}

View File

@ -0,0 +1,96 @@
import React, { useEffect } from 'react'
import { useLazyQuery } from '@sourcegraph/http-client'
import {
LoadingSpinner,
ErrorAlert,
Input,
Button,
getDefaultInputProps,
useField,
useForm,
Form,
Label,
} from '@sourcegraph/wildcard'
import {
GetRepoIdResult,
GetRepoIdVariables,
InferAutoIndexJobsForRepoResult,
InferAutoIndexJobsForRepoVariables,
} from '../../../../../graphql-operations'
import { RepositoryField } from '../../../../insights/components'
import { InferenceForm } from '../inference-form/InferenceForm'
import { GET_REPO_ID, INFER_JOBS_SCRIPT } from './backend'
import styles from './InferenceScriptPreview.module.scss'
interface InferenceScriptPreviewFormValues {
repository: string
}
interface InferenceScriptPreviewProps {
active: boolean
script: string
setTab: (index: number) => void
}
export const InferenceScriptPreview: React.FunctionComponent<InferenceScriptPreviewProps> = ({ active, script }) => {
const [getRepoId, repoData] = useLazyQuery<GetRepoIdResult, GetRepoIdVariables>(GET_REPO_ID, {})
const [inferJobs, { data, loading, error }] = useLazyQuery<
InferAutoIndexJobsForRepoResult,
InferAutoIndexJobsForRepoVariables
>(INFER_JOBS_SCRIPT, {})
const form = useForm<InferenceScriptPreviewFormValues>({
initialValues: { repository: '' },
onSubmit: async ({ repository }) => getRepoId({ variables: { name: repository } }),
})
useEffect(() => {
const id = repoData?.data?.repository?.id
if (active && id) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
inferJobs({ variables: { repository: id, script, rev: null }, fetchPolicy: 'cache-first' })
}
}, [active, inferJobs, repoData, script])
const repository = useField({
name: 'repository',
formApi: form.formAPI,
})
return (
<div className={styles.container}>
<Form className={styles.actionContainer} ref={form.ref} noValidate={true} onSubmit={form.handleSubmit}>
<Label id="preview-label">Run your script against a repository</Label>
<div className="d-flex align-items-center">
<Input
as={RepositoryField}
required={true}
autoFocus={true}
aria-label="Repository"
placeholder="Example: github.com/sourcegraph/sourcegraph"
{...getDefaultInputProps(repository)}
className={styles.actionInput}
/>
<Button variant="success" type="submit">
Preview results
</Button>
</div>
</Form>
{loading ? (
<LoadingSpinner className="d-block mx-auto mt-3" />
) : error ? (
<ErrorAlert error={error} />
) : data ? (
<InferenceForm jobs={data.inferAutoIndexJobsForRepo} readOnly={true} />
) : (
<></>
)}
</div>
)
}

View File

@ -0,0 +1,53 @@
import { gql } from '@sourcegraph/http-client'
export const GET_REPO_ID = gql`
query GetRepoId($name: String!) {
repository(name: $name) {
id
}
}
`
export const INFER_JOBS_SCRIPT = gql`
query InferAutoIndexJobsForRepo($repository: ID!, $rev: String, $script: String) {
inferAutoIndexJobsForRepo(repository: $repository, rev: $rev, script: $script) {
...AutoIndexJobDescriptionFields
}
}
fragment AutoIndexJobDescriptionFields on AutoIndexJobDescription {
comparisonKey
root
indexer {
key
imageName
name
url
}
steps {
...AutoIndexLsifIndexStepsFields
}
}
fragment AutoIndexLsifIndexStepsFields on IndexSteps {
preIndex {
...AutoIndexLsifPreIndexFields
}
index {
...AutoIndexLsifIndexFields
}
}
fragment AutoIndexLsifPreIndexFields on PreIndexStep {
root
image
commands
}
fragment AutoIndexLsifIndexFields on IndexStep {
indexerArgs
outfile
commands
requestedEnvVars
}
`

View File

@ -1,81 +0,0 @@
import { ApolloError, gql, useQuery } from '@apollo/client'
import { InferAutoIndexJobsForRepoVariables, InferAutoIndexJobsForRepoResult } from '../../../../graphql-operations'
interface InferJobsScriptResult {
data?: InferAutoIndexJobsForRepoResult
loading: boolean
error: ApolloError | undefined
}
export const INFER_JOBS_SCRIPT = gql`
query InferAutoIndexJobsForRepo($repository: ID!, $rev: String, $script: String) {
inferAutoIndexJobsForRepo(repository: $repository, rev: $rev, script: $script) {
...AutoIndexJobDescriptionFields
}
}
fragment AutoIndexJobDescriptionFields on AutoIndexJobDescription {
root
indexer {
name
url
}
steps {
...LsifIndexStepsFields
}
}
fragment LsifIndexStepsFields on IndexSteps {
setup {
...ExecutionLogEntryFields
}
preIndex {
root
image
commands
logEntry {
...ExecutionLogEntryFields
}
}
index {
indexerArgs
outfile
logEntry {
...ExecutionLogEntryFields
}
}
upload {
...ExecutionLogEntryFields
}
teardown {
...ExecutionLogEntryFields
}
}
fragment ExecutionLogEntryFields on ExecutionLogEntry {
key
command
startTime
exitCode
out
durationMilliseconds
}
`
export const useInferJobs = ({
variables,
}: {
variables: InferAutoIndexJobsForRepoVariables
}): InferJobsScriptResult => {
const { data, loading, error } = useQuery<InferAutoIndexJobsForRepoResult>(INFER_JOBS_SCRIPT, {
variables,
nextFetchPolicy: 'cache-first',
})
return {
data,
loading,
error,
}
}

View File

@ -1,10 +1,13 @@
import { FunctionComponent } from 'react'
import { FunctionComponent, useState, useCallback } from 'react'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { ErrorAlert, LoadingSpinner, PageHeader, Tab, TabList, TabPanel, TabPanels, Tabs } from '@sourcegraph/wildcard'
import { AuthenticatedUser } from '../../../../auth'
import { PageTitle } from '../../../../components/PageTitle'
import { InferenceScriptEditor } from '../components/InferenceScriptEditor'
import { InferenceScriptEditor } from '../components/inference-script/InferenceScriptEditor'
import { InferenceScriptPreview } from '../components/inference-script/InferenceScriptPreview'
import { useInferenceScript } from '../hooks/useInferenceScript'
export interface CodeIntelInferenceConfigurationPageProps extends TelemetryProps {
authenticatedUser: AuthenticatedUser | null
@ -13,10 +16,59 @@ export interface CodeIntelInferenceConfigurationPageProps extends TelemetryProps
export const CodeIntelInferenceConfigurationPage: FunctionComponent<CodeIntelInferenceConfigurationPageProps> = ({
authenticatedUser,
...props
}) => (
<>
<PageTitle title="Code graph index configuration inference" />
}) => {
const [activeTabIndex, setActiveTabIndex] = useState<number>(0)
const { inferenceScript, loadingScript, fetchError } = useInferenceScript()
const [previewScript, setPreviewScript] = useState<string | null>(null)
const setTab = useCallback((index: number) => {
setActiveTabIndex(index)
}, [])
<InferenceScriptEditor authenticatedUser={authenticatedUser} {...props} />
</>
)
const inferencePreview = previewScript !== null ? previewScript : inferenceScript
const previewDisabled = inferencePreview === ''
return (
<>
<PageTitle title="Code graph inference script" />
<PageHeader
headingElement="h2"
path={[
{
text: <>Code graph inference script</>,
},
]}
description="Lua script that emits complete and/or partial auto-indexing job specifications."
className="mb-3"
/>
{fetchError && <ErrorAlert prefix="Error fetching inference script" error={fetchError} />}
{loadingScript && <LoadingSpinner />}
<Tabs size="large" index={activeTabIndex} onChange={setTab}>
<TabList>
<Tab key="script">Script</Tab>
<Tab key="preview" disabled={previewDisabled}>
Preview
</Tab>
</TabList>
<TabPanels>
<TabPanel>
<InferenceScriptEditor
script={inferenceScript}
authenticatedUser={authenticatedUser}
setPreviewScript={setPreviewScript}
previewDisabled={previewDisabled}
setTab={setTab}
{...props}
/>
</TabPanel>
<TabPanel>
<InferenceScriptPreview
active={activeTabIndex === 1}
script={inferencePreview}
setTab={setTab}
/>
</TabPanel>
</TabPanels>
</Tabs>
</>
)
}

View File

@ -338,6 +338,7 @@
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/language": "^6.2.0",
"@codemirror/legacy-modes": "^6.3.1",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.1",
"@codemirror/state": "^6.2.0",

View File

@ -29,6 +29,7 @@ importers:
'@codemirror/lang-json': ^6.0.0
'@codemirror/lang-markdown': ^6.0.0
'@codemirror/language': ^6.2.0
'@codemirror/legacy-modes': ^6.3.1
'@codemirror/lint': ^6.0.0
'@codemirror/search': ^6.0.1
'@codemirror/state': ^6.2.0
@ -402,6 +403,7 @@ importers:
'@codemirror/lang-json': 6.0.0
'@codemirror/lang-markdown': 6.0.0
'@codemirror/language': 6.2.0
'@codemirror/legacy-modes': 6.3.1
'@codemirror/lint': 6.0.0
'@codemirror/search': 6.0.1
'@codemirror/state': 6.2.0
@ -3916,6 +3918,12 @@ packages:
style-mod: 4.0.0
dev: false
/@codemirror/legacy-modes/6.3.1:
resolution: {integrity: sha512-icXmCs4Mhst2F8mE0TNpmG6l7YTj1uxam3AbZaFaabINH5oWAdg2CfR/PVi+d/rqxJ+TuTnvkKK5GILHrNThtw==}
dependencies:
'@codemirror/language': 6.2.0
dev: false
/@codemirror/lint/6.0.0:
resolution: {integrity: sha512-nUUXcJW1Xp54kNs+a1ToPLK8MadO0rMTnJB8Zk4Z8gBdrN0kqV7uvUraU/T2yqg+grDNR38Vmy/MrhQN/RgwiA==}
dependencies:
@ -13040,7 +13048,7 @@ packages:
'@babel/core': 7.20.5
find-cache-dir: 3.3.2
schema-utils: 4.0.0
webpack: 5.75.0_4wpfvhs5obqvjbkpkxtqpnn5oe
webpack: 5.75.0_cf7cgeqdkm72g3fdehkr7aaod4
dev: true
/babel-plugin-add-react-displayname/0.0.5:
@ -15295,7 +15303,7 @@ packages:
postcss-modules-values: 4.0.0_postcss@8.4.21
postcss-value-parser: 4.2.0
semver: 7.3.8
webpack: 5.75.0_esbuild@0.17.8
webpack: 5.75.0_cf7cgeqdkm72g3fdehkr7aaod4
/css-minimizer-webpack-plugin/4.2.2_6hyl5w2uqyeivowpusuiulbmoy:
resolution: {integrity: sha512-s3Of/4jKfw1Hj9CxEO1E5oXhQAxlayuHO2y/ML+C6I9sQ7FdzfEV6QgMLN3vI+qFsjJGIAFLKtQK7t8BOXAIyA==}
@ -20113,7 +20121,7 @@ packages:
lodash: 4.17.21
pretty-error: 4.0.0
tapable: 2.2.1
webpack: 5.75.0_yrajokeiryagdtuqucziuwdxti
webpack: 5.75.0_cf7cgeqdkm72g3fdehkr7aaod4
dev: true
/htmlparser2/6.1.0:
@ -26920,7 +26928,7 @@ packages:
klona: 2.0.5
postcss: 8.4.21
semver: 7.3.8
webpack: 5.75.0_esbuild@0.17.8
webpack: 5.75.0_cf7cgeqdkm72g3fdehkr7aaod4
/postcss-media-query-parser/0.2.3:
resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
@ -29778,7 +29786,7 @@ packages:
klona: 2.0.5
neo-async: 2.6.2
sass: 1.32.4
webpack: 5.75.0_esbuild@0.17.8
webpack: 5.75.0_cf7cgeqdkm72g3fdehkr7aaod4
/sass/1.32.4:
resolution: {integrity: sha512-N0BT0PI/t3+gD8jKa83zJJUb7ssfQnRRfqN+GIErokW6U4guBpfYl8qYB+OFLEho+QvnV5ZH1R9qhUC/Z2Ch9w==}
@ -31677,7 +31685,7 @@ packages:
schema-utils: 3.1.1
serialize-javascript: 6.0.0
terser: 5.16.1
webpack: 5.75.0_esbuild@0.17.8
webpack: 5.75.0_cf7cgeqdkm72g3fdehkr7aaod4
/terser-webpack-plugin/5.3.6_oa2ac2s5skpozptxi7rtd3zsrm:
resolution: {integrity: sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==}
@ -31705,32 +31713,6 @@ packages:
webpack: 5.75.0_yrajokeiryagdtuqucziuwdxti
dev: true
/terser-webpack-plugin/5.3.6_tqhwwsvj4cnhuarwr7flkmuvna:
resolution: {integrity: sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==}
engines: {node: '>= 10.13.0'}
peerDependencies:
'@swc/core': '*'
esbuild: '*'
uglify-js: '*'
webpack: ^5.1.0
peerDependenciesMeta:
'@swc/core':
optional: true
esbuild:
optional: true
uglify-js:
optional: true
dependencies:
'@jridgewell/trace-mapping': 0.3.17
'@swc/core': 1.3.28
esbuild: 0.17.8
jest-worker: 27.5.1
schema-utils: 3.1.1
serialize-javascript: 6.0.0
terser: 5.16.1
webpack: 5.75.0_4wpfvhs5obqvjbkpkxtqpnn5oe
dev: true
/terser/4.8.1:
resolution: {integrity: sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==}
engines: {node: '>=6.0.0'}
@ -33759,46 +33741,6 @@ packages:
resolution: {integrity: sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw==}
dev: true
/webpack/5.75.0_4wpfvhs5obqvjbkpkxtqpnn5oe:
resolution: {integrity: sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==}
engines: {node: '>=10.13.0'}
hasBin: true
peerDependencies:
webpack-cli: '*'
peerDependenciesMeta:
webpack-cli:
optional: true
dependencies:
'@types/eslint-scope': 3.7.3
'@types/estree': 0.0.51
'@webassemblyjs/ast': 1.11.1
'@webassemblyjs/wasm-edit': 1.11.1
'@webassemblyjs/wasm-parser': 1.11.1
acorn: 8.8.1
acorn-import-assertions: 1.8.0_acorn@8.8.1
browserslist: 4.21.4
chrome-trace-event: 1.0.2
enhanced-resolve: 5.10.0
es-module-lexer: 0.9.3
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.10
json-parse-even-better-errors: 2.3.1
loader-runner: 4.2.0
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.1.1
tapable: 2.2.1
terser-webpack-plugin: 5.3.6_tqhwwsvj4cnhuarwr7flkmuvna
watchpack: 2.4.0
webpack-sources: 3.2.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
dev: true
/webpack/5.75.0_cf7cgeqdkm72g3fdehkr7aaod4:
resolution: {integrity: sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==}
engines: {node: '>=10.13.0'}
@ -33877,6 +33819,7 @@ packages:
- '@swc/core'
- esbuild
- uglify-js
dev: false
/webpack/5.75.0_yrajokeiryagdtuqucziuwdxti:
resolution: {integrity: sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==}