cody: add the client side app to review CLI generated code completions (#53140)

- Adds the client-side app to review CLI-generated code completions.
- This skeleton is generated via npx create-remix --template
https://github.com/remix-run/remix/tree/templates_v2_dev/templates/express
- Remix is used because:
    - It's easy to bootstrap the application with templates.
- It makes it easy to process completions on the server and visualize
them on the client.
- The DX is great out of the box (hot-reload, faster eslint-powered
builds, CSS modules)
- Aligned with our current stack (Typescript, React, React-router, CSS
modules)

<img width="1200" alt="Screenshot 2023-06-07 at 17 16 12"
src="https://github.com/sourcegraph/sourcegraph/assets/3846380/422e77f7-30ff-46f9-8877-00026077678c">

## Test plan

From the root of the repo:

1. `SOURCEGRAPH_ACCESS_TOKEN=XXX pnpm --filter cody-ai run
generate:completions`
2. `pnpm --filter @sourcegraph/completions-review-tool run dev`
3. open http://localhost:3000/
This commit is contained in:
Valery Bugakov 2023-06-16 00:19:32 -07:00 committed by GitHub
parent 54ec2cb29e
commit dc8fc49713
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2316 additions and 123 deletions

View File

@ -33,6 +33,7 @@ client/vscode/node_modules
dev/release/node_modules
client/plugin-backstage/node_modules
client/app-shell/node_modules
client/completions-review-tool/node_modules
cmd/symbols/squirrel/test_repos/starlark

View File

@ -47,6 +47,7 @@ code-intel-extensions.json
pnpm-lock.yaml
node_modules/
client/web-sveltekit/.svelte-kit
client/completions-review-tool/data/
*.module.scss.d.ts
.vscode-test

View File

@ -0,0 +1,27 @@
// @ts-check
const baseConfig = require('../../.eslintrc.js')
module.exports = {
extends: '../../.eslintrc.js',
parserOptions: {
...baseConfig.parserOptions,
project: [__dirname + '/tsconfig.json'],
},
overrides: baseConfig.overrides,
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/unbound-method': 'off',
'no-restricted-imports': 'off',
'react/forbid-dom-props': 'off',
'import/no-default-export': 'off',
'no-console': 'off',
'no-duplicate-imports': 'off',
'arrow-body-style': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
'ban/ban': 'off',
'no-sync': 'off',
},
}

View File

@ -0,0 +1,7 @@
.DS_Store
node_modules
/.cache
/build
/public/build
.env

View File

@ -0,0 +1,48 @@
load("@aspect_rules_ts//ts:defs.bzl", "ts_config")
load("@npm//:defs.bzl", "npm_link_all_packages")
load("//dev:defs.bzl", "ts_project")
load("//dev:eslint.bzl", "eslint_config_and_lint_root")
# gazelle:js_ignore_imports **/*.css
npm_link_all_packages(name = "node_modules")
eslint_config_and_lint_root()
ts_config(
name = "tsconfig",
src = "tsconfig.json",
visibility = ["//client:__subpackages__"],
deps = [
"//:tsconfig",
],
)
ts_project(
name = "completions-review-tool",
srcs = [
"globals.d.ts",
"src/entry.client.tsx",
"src/entry.server.tsx",
"src/models/completions.server.ts",
"src/root.tsx",
"src/routes/_index.tsx",
],
tsconfig = ":tsconfig",
deps = [
":node_modules/@remix-run/css-bundle",
":node_modules/@remix-run/node",
":node_modules/@remix-run/react",
":node_modules/@types/react-table",
":node_modules/isbot",
":node_modules/react",
":node_modules/react-dom",
":node_modules/react-table",
"//:node_modules/@remix-run/server-runtime", #keep
"//:node_modules/@types/marked",
"//:node_modules/@types/node",
"//:node_modules/@types/react",
"//:node_modules/@types/react-dom",
"//:node_modules/marked",
],
)

View File

@ -0,0 +1,27 @@
# Welcome to the Completions Review tool!
- Generated with [Remix](https://remix.run/docs)
## Development
Start the Remix development asset server and the Express server by running:
```sh
npm run dev
```
This starts your app in development mode, which will purge the server require cache when Remix rebuilds assets so you don't need a process manager restarting the express server.
## Deployment
First, build your app for production:
```sh
npm run build
```
Then run the app in production mode:
```sh
npm start
```

View File

@ -0,0 +1,25 @@
[
{
"completions": [
"s"
],
"timestamp": "1686127320776",
"code": "import signale from 'signale'\n\nfunction logMessage(message: string) {\n ️🔥\n}"
},
{
"completions": [
"throw new Error('Not yet implemented');}",
"throw new Error(\"Not implemented.\");"
],
"timestamp": "1686127320776",
"code": "class TextDocument implements vscode.TextDocument {\n private text: string\n\n constructor(public uri: vscode.Uri, text: string) {\n this.text = text.replace(/\r\n/gm, '\n') // normalize end of line\n }\n\n private get lines(): string[] {\n return this.text.split('\n')\n }\n\n lineAt(position: number | vscode.Position): vscode.TextLine {\n ️🔥\n }\n}"
},
{
"completions": [
"const date = new Date()",
"const fileName = path.join(__dirname, 'date.txt')"
],
"timestamp": "1686127320776",
"code": "import path from 'path'\n\nfunction writeDateToDisk() {\n ️🔥\n}"
}
]

View File

@ -0,0 +1,26 @@
[
{
"completions": [
"signale.info(message)",
"signale.info(message)"
],
"timestamp": "1686127445971",
"code": "import signale from 'signale'\n\nfunction logMessage(message: string) {\n ️🔥\n}"
},
{
"completions": [
"const date = new Date()",
"const dateString = new Date().toISOString()",
"const filename = 'file.txt'"
],
"timestamp": "1686127445971",
"code": "import path from 'path'\n\nfunction writeDateToDisk() {\n ️🔥\n}"
},
{
"completions": [
"throw new Error('Not implemented.');"
],
"timestamp": "1686127445971",
"code": "class TextDocument implements vscode.TextDocument {\n private text: string\n\n constructor(public uri: vscode.Uri, text: string) {\n this.text = text.replace(/\r\n/gm, '\n') // normalize end of line\n }\n\n private get lines(): string[] {\n return this.text.split('\n')\n }\n\n lineAt(position: number | vscode.Position): vscode.TextLine {\n ️🔥\n }\n}"
}
]

View File

@ -0,0 +1,27 @@
[
{
"completions": [
"signale.info(message)",
"signale.log(message);"
],
"timestamp": "1686127449821",
"code": "import signale from 'signale'\n\nfunction logMessage(message: string) {\n ️🔥\n}"
},
{
"completions": [
"const now = n",
"const dateStr = new Date().toISOString()",
"const now = new Date()"
],
"timestamp": "1686127449821",
"code": "import path from 'path'\n\nfunction writeDateToDisk() {\n ️🔥\n}"
},
{
"completions": [
"throw new Error('Not implemented')",
"throw new Error('unimplemented')"
],
"timestamp": "1686127449821",
"code": "class TextDocument implements vscode.TextDocument {\n private text: string\n\n constructor(public uri: vscode.Uri, text: string) {\n this.text = text.replace(/\r\n/gm, '\n') // normalize end of line\n }\n\n private get lines(): string[] {\n return this.text.split('\n')\n }\n\n lineAt(position: number | vscode.Position): vscode.TextLine {\n ️🔥\n }\n}"
}
]

View File

@ -0,0 +1,26 @@
[
{
"completions": [
"const d = new Date()",
"const date =",
"const p = path.j"
],
"timestamp": "1686127552793",
"code": "import path from 'path'\n\nfunction writeDateToDisk() {\n ️🔥\n}"
},
{
"completions": [
"signale.success(message);"
],
"timestamp": "1686127552793",
"code": "import signale from 'signale'\n\nfunction logMessage(message: string) {\n ️🔥\n}"
},
{
"completions": [
"throw new Error('Not implemented')",
"// TODO"
],
"timestamp": "1686127552793",
"code": "class TextDocument implements vscode.TextDocument {\n private text: string\n\n constructor(public uri: vscode.Uri, text: string) {\n this.text = text.replace(/\r\n/gm, '\n') // normalize end of line\n }\n\n private get lines(): string[] {\n return this.text.split('\n')\n }\n\n lineAt(position: number | vscode.Position): vscode.TextLine {\n ️🔥\n }\n}"
}
]

View File

@ -0,0 +1,9 @@
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.css' {
const cssModule: string
export default cssModule
}

View File

@ -0,0 +1,36 @@
{
"name": "@sourcegraph/completions-review-tool",
"private": true,
"sideEffects": false,
"scripts": {
"build": "remix build",
"dev": "npm-run-all build --parallel \"dev:*\"",
"dev:node": "cross-env NODE_ENV=development nodemon --require dotenv/config ./server.js --watch ./server.js",
"dev:remix": "remix watch",
"start": "cross-env NODE_ENV=production node ./server.js",
"typecheck": "tsc"
},
"dependencies": {
"@remix-run/css-bundle": "^1.17.0",
"@remix-run/express": "^1.17.0",
"@remix-run/node": "^1.17.0",
"@remix-run/react": "^1.17.0",
"@remix-run/server-runtime": "^1.17.0",
"isbot": "^3.6.8",
"morgan": "^1.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-table": "^7.8.0"
},
"devDependencies": {
"@remix-run/dev": "^1.17.0",
"@types/compression": "^1.7.2",
"@types/morgan": "^1.9.4",
"@types/react-table": "^7.7.14",
"dotenv": "^16.0.3",
"npm-run-all": "^4.1.5"
},
"engines": {
"node": ">=14"
}
}

View File

@ -0,0 +1,17 @@
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
ignoredRouteFiles: ['**/.*'],
appDirectory: 'src',
assetsBuildDirectory: 'public/build',
serverBuildPath: 'build/index.js',
publicPath: '/build/',
serverDependenciesToBundle: 'all',
serverModuleFormat: 'cjs',
future: {
v2_errorBoundary: true,
v2_headers: true,
v2_meta: true,
v2_normalizeFormMethod: true,
v2_routeConvention: true,
},
}

View File

@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

View File

@ -0,0 +1,64 @@
/* eslint-disable import/no-dynamic-require */
const path = require('path')
const { createRequestHandler } = require('@remix-run/express')
const { installGlobals } = require('@remix-run/node')
const compression = require('compression')
const express = require('express')
const morgan = require('morgan')
installGlobals()
const BUILD_DIR = path.join(process.cwd(), 'build')
const app = express()
app.use(compression())
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by')
// Remix fingerprints its assets so we can cache forever.
app.use('/build', express.static('public/build', { immutable: true, maxAge: '1y' }))
// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static('public', { maxAge: '1h' }))
app.use(morgan('tiny'))
app.all(
'*',
process.env.NODE_ENV === 'development'
? (req, res, next) => {
purgeRequireCache()
return createRequestHandler({
build: require(BUILD_DIR),
mode: process.env.NODE_ENV,
})(req, res, next)
}
: createRequestHandler({
build: require(BUILD_DIR),
mode: process.env.NODE_ENV,
})
)
const port = process.env.PORT || 3000
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Express server listening on port ${port}`)
})
function purgeRequireCache() {
// purge require cache on requests for "server side HMR" this won't let
// you have in-memory objects between requests in development,
// alternatively you can set up nodemon/pm2-dev to restart the server on
// file changes, but then you'll have to reconnect to databases/etc on each
// change. We prefer the DX of this, so we've included it for you by default
for (const key in require.cache) {
if (key.startsWith(BUILD_DIR)) {
delete require.cache[key]
}
}
}

View File

@ -0,0 +1,29 @@
.table-wrapper {
overflow: scroll;
flex-grow: 1;
width: 100vw;
}
.table {
/* */
}
.table thead tr > th {
background: #ccc;
position: sticky;
z-index: 2;
top: 0;
}
.table thead tr > :first-child {
z-index: 3;
left: 0;
top: 0;
}
.table tbody tr > :first-child {
background: #ddd;
position: sticky;
z-index: 1;
left: 0;
}

View File

@ -0,0 +1,22 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { startTransition, StrictMode } from 'react'
import { RemixBrowser } from '@remix-run/react'
import { hydrate } from 'react-dom'
// Throws on hydration 🫠
// import { hydrateRoot } from 'react-dom/client'
startTransition(() => {
hydrate(
<StrictMode>
<RemixBrowser />
</StrictMode>,
document
)
})

View File

@ -0,0 +1,117 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { AppLoadContext, EntryContext, Response } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import isbot from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { PassThrough } = require('node:stream')
const ABORT_DELAY = 5_000
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
return isbot(request.headers.get('user-agent'))
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext)
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
onAllReady() {
shellRendered = true
const body = new PassThrough()
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
},
}
)
setTimeout(abort, ABORT_DELAY)
})
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
onShellReady() {
shellRendered = true
const body = new PassThrough()
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
},
}
)
setTimeout(abort, ABORT_DELAY)
})
}

View File

@ -0,0 +1,38 @@
import fs from 'fs'
import path from 'path'
// Read completions from the 'data' directory
const dataDir = path.join(__dirname, '../', 'data')
const jsonFiles = fs.readdirSync(dataDir).filter(file => file.endsWith('.json'))
type CompletionData = {
code: string
completions: string[]
timestamp: string
}
const data: CompletionData[] = []
jsonFiles.forEach(file => {
const filePath = path.join(dataDir, file)
const fileContent = fs.readFileSync(filePath, 'utf-8')
const jsonData = JSON.parse(fileContent)
data.push(...jsonData)
})
type CodeToCompletions = Record<string, Omit<CompletionData, 'code'>[]>
const codeToCompletions: CodeToCompletions = {}
// Group data by `code`.
data.forEach(({ code, completions, timestamp }) => {
if (!codeToCompletions[code]) {
codeToCompletions[code] = []
}
codeToCompletions[code].push({ completions, timestamp })
})
export function getCompletions(): Promise<CodeToCompletions> {
return Promise.resolve(codeToCompletions)
}

View File

@ -0,0 +1,29 @@
import { cssBundleHref } from '@remix-run/css-bundle'
import type { LinksFunction } from '@remix-run/node'
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'
import styles from './styles/global.css'
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: styles },
...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : []),
]
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
)
}

View File

@ -0,0 +1,120 @@
/* eslint-disable react/jsx-key */
import { json } from '@remix-run/node'
import type { V2_MetaFunction } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { marked } from 'marked'
import { useTable } from 'react-table'
import { getCompletions } from '../models/completions.server'
import styles from '../components/CompletionsTable.module.css'
export const meta: V2_MetaFunction = () => {
return [{ title: 'Completions Review app' }, { name: 'description', content: 'Welcome to Completions Review app!' }]
}
export const loader = async () => {
return json({ completions: await getCompletions() })
}
interface Column {
Header: string
accessor: string
}
interface Row {
code: string
bgColor: string
[key: string]: string
}
export default function Index() {
const { completions } = useLoaderData<typeof loader>()
const columns: Column[] = [
{
Header: 'Code',
accessor: 'code',
},
]
// Dynamically generated columns based on the number of generated files.
const extraColumns = new Set<string>()
const formatData = (inputData: typeof completions) => {
const rows: Row[] = []
Object.entries(inputData).forEach(([code, entries]) => {
const row: Row = {
code: renderMarkdown(code),
bgColor: '#f7f7f7',
}
entries.forEach(({ timestamp, completions }) => {
const columnKey = `completion-${timestamp}`
extraColumns.add(columnKey)
// All completions are displayed in the same cell separated by <hr />.
row[columnKey] = completions.map(renderMarkdown).join('<hr />')
})
rows.push(row)
})
columns.push(...Array.from(extraColumns).map(columnKey => ({ Header: columnKey, accessor: columnKey })))
return rows
}
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable({
columns,
data: formatData(completions),
})
return (
<div className={styles['table-wrapper']}>
<table {...getTableProps()} className={styles.table}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map(column => (
<th {...column.getHeaderProps()}>{column.render('Header')}</th>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row)
return (
<tr {...row.getRowProps()} style={{ backgroundColor: row.original.bgColor }}>
{row.cells.map(cell => {
return (
<td
{...cell.getCellProps()}
dangerouslySetInnerHTML={{
__html: cell.value,
}}
/>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
function renderMarkdown(code: string) {
return marked(
`\`\`\`javascript
${code.replace(/\\/g, '\\\\')}
\`\`\``,
{ gfm: true }
)
}

View File

@ -0,0 +1,9 @@
html {
font-family: sfmono-regular, consolas, menlo, dejavu sans mono, monospace;
-ms-scroll-chaining: none;
overscroll-behavior: contain;
}
body {
margin: 0;
}

View File

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"module": "commonjs",
"sourceRoot": "src",
"rootDir": ".",
"outDir": "./out",
"baseUrl": "./src",
// Remix takes care of building everything in `remix build`.
// "noEmit": true,
},
"include": ["globals.d.ts", "remix.env.d.ts", "**/*.ts", "**/*.tsx", "*.js"],
}

View File

@ -118,6 +118,7 @@
"@pollyjs/adapter": "^5.0.0",
"@pollyjs/core": "^5.1.0",
"@pollyjs/persister-fs": "^5.0.0",
"@remix-run/server-runtime": "^1.17.0",
"@sentry/cli": "^1.74.4",
"@sentry/webpack-plugin": "^1.20.0",
"@slack/web-api": "^5.15.0",
@ -508,6 +509,11 @@
"node-gyp": "*"
}
},
"deasync": {
"dependencies": {
"node-gyp": "*"
}
},
"cpu-features": {
"dependencies": {
"node-gyp": "*"
@ -521,6 +527,7 @@
}
},
"resolutions": {
"browserify-zlib": "0.2.0",
"@types/webpack": "5",
"history": "4.5.1",
"cssnano": "4.1.10",

File diff suppressed because it is too large Load Diff