Merge branch 'main' into fix/external-service-error-handling

This commit is contained in:
Namit Chandwani 2024-07-12 22:12:00 +05:30 committed by GitHub
commit 17d11aa2bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 1505 additions and 1541 deletions

View File

@ -150,7 +150,9 @@ function highlightNodeHelper(
}
let newNode: Node
if (newNodes.length === 1) {
if (newNodes.length === 0) {
newNode = document.createTextNode('')
} else if (newNodes.length === 1) {
// If we only have one new node, no need to wrap it in a containing span
newNode = newNodes[0]
} else {

View File

@ -2,6 +2,7 @@ import gql from 'tagged-template-noop'
import { isErrorLike } from '@sourcegraph/common'
import { SearchVersion } from '../../../graphql-operations'
import type * as sourcegraph from '../api'
import { cache } from '../util'
@ -251,6 +252,7 @@ export class API {
const data = await queryGraphQL<Response>(buildSearchQuery(fileLocal), {
query,
version: SearchVersion.V3,
})
return data.search.results.results.filter(isDefined)
}
@ -322,8 +324,8 @@ function buildSearchQuery(fileLocal: boolean): string {
if (fileLocal) {
return gql`
query LegacyCodeIntelSearch2($query: String!) {
search(query: $query) {
query LegacyCodeIntelSearch2($query: String!, $version: SearchVersion!) {
search(query: $query, version: $version) {
...SearchResults
...FileLocal
}
@ -334,8 +336,8 @@ function buildSearchQuery(fileLocal: boolean): string {
}
return gql`
query LegacyCodeIntelSearch3($query: String!) {
search(query: $query) {
query LegacyCodeIntelSearch3($query: String!, $version: SearchVersion!) {
search(query: $query, version: $version) {
...SearchResults
}
}

View File

@ -194,7 +194,7 @@ copy_to_directory(
playwright_test_bin.playwright_test(
name = "e2e_test",
timeout = "short",
timeout = "long",
args = [
"test",
"--config $(location playwright.config.ts)",

View File

@ -2,5 +2,5 @@ const baseConfig = require('../../prettier.config.js')
module.exports = {
...baseConfig,
plugins: [...(baseConfig.plugins || []), 'prettier-plugin-svelte'],
overrides: [...(baseConfig.overrides || []), { files: '*.svelte', options: { parser: 'svelte' } }],
overrides: [...(baseConfig.overrides || []), { files: '*.svelte', options: { parser: 'svelte', htmlWhitespaceSensitivity: 'strict' } }],
}

View File

@ -73,12 +73,16 @@
on:mouseleave={hide}
on:focusin={show}
on:focusout={hide}
data-tooltip-root
>
<slot />
</div>
{#if (alwaysVisible || visible) && target && tooltip}
<div role="tooltip" {id} use:popover={{ reference: target, options }} use:portal>
data-tooltip-root><!--
--><slot /><!--
--></div
><!--
-->{#if (alwaysVisible || visible) && target && tooltip}<div
role="tooltip"
{id}
use:popover={{ reference: target, options }}
use:portal
>
<div class="content">{tooltip}</div>
<div data-arrow />
</div>

View File

@ -341,58 +341,58 @@ export const portal: Action<HTMLElement, { container?: HTMLElement | null } | un
/**
* An action that resizes an element with the provided grow and shrink callbacks until the target element no longer overflows.
*
* @param grow A callback to increase the size of the contained contents. Returns a boolean indicating more growth is possible.
* @param shrink A callback to reduce the size of the contained contents. Returns a boolean indicating more shrinking is possible.
* @param grow A callback to increase the size of the contained contents. Returns a boolean indicating whether growing was successful.
* @param shrink A callback to reduce the size of the contained contents. Returns a boolean indicating whether shrinking was successful.
* @returns An action that updates the overflow state of the element.
*/
export const sizeToFit: Action<HTMLElement, { grow: () => boolean; shrink: () => boolean }> = (
target,
{ grow, shrink }
) => {
async function resize(): Promise<void> {
if (target.scrollWidth > target.clientWidth) {
// Shrink until we fit
while (target.scrollWidth > target.clientWidth) {
if (!shrink()) {
return
}
await tick()
}
} else {
// Grow until we overflow, then shrink once
while (target.scrollWidth <= target.clientWidth && grow()) {
await tick()
}
await tick()
if (target.scrollWidth > target.clientWidth) {
shrink()
await tick()
}
}
}
// Resizing can (and probably will) trigger mutations, so do not trigger a
// new resize if there is a resize in progress.
let resizing = false
function resizeOnce() {
async function resize(): Promise<void> {
if (resizing) {
// Growing and shrinking can cause child nodes to be added
// or removed, triggering resize observer during resizing.
// If we're already resizing, we can safely ignore those events.
return
}
resizing = true
resize().then(() => {
resizing = false
})
// Grow until we overflow
while (target.scrollWidth <= target.clientWidth && grow()) {
await tick()
}
await tick()
// Then shrink until we fit
while (target.scrollWidth > target.clientWidth && shrink()) {
await tick()
}
await tick()
resizing = false
}
const resizeObserver = new ResizeObserver(resizeOnce)
const resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(target)
const mutationObserver = new MutationObserver(resizeOnce)
mutationObserver.observe(target, { attributes: true, childList: true, characterData: true, subtree: true })
function isElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE
}
// If any children change size, that could trigger an overflow, so check the size again
target.childNodes.forEach(child => isElement(child) && resizeObserver.observe(child))
const mutationObserver = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
mutation.addedNodes.forEach(node => isElement(node) && resizeObserver.observe(node))
mutation.removedNodes.forEach(node => isElement(node) && resizeObserver.unobserve(node))
}
})
mutationObserver.observe(target, { childList: true })
return {
update(params) {
grow = params.grow
shrink = params.shrink
resizeOnce()
resize()
},
destroy() {
resizeObserver.disconnect()

View File

@ -0,0 +1,40 @@
<script lang="ts" context="module">
import { Story } from '@storybook/addon-svelte-csf'
import Icon from '$lib/Icon.svelte'
import { pathHrefFactory } from '.'
import DisplayPath from './DisplayPath.svelte'
export const meta = {
component: DisplayPath,
}
</script>
<Story name="Default">
<DisplayPath path="my/displayed/path" />
</Story>
<Story name="Copy button">
<DisplayPath path="my/displayed/path" showCopyButton />
</Story>
<Story name="File Icon">
<DisplayPath path="my/displayed/path">
<Icon slot="file-icon" icon={ILucideEye} inline />
</DisplayPath>
</Story>
<Story name="Linkified">
<DisplayPath
path="my/displayed/path"
pathHref={pathHrefFactory({
repoName: 'myrepo',
revision: 'main',
fullPath: 'my/displayed/path',
fullPathType: 'blob',
})}
>
<Icon icon={ILucideEye} inline />
</DisplayPath>
</Story>

View File

@ -0,0 +1,129 @@
<!--
@component
DisplayPath is a path that is formatted for display.
Some features it provides:
- Styleable slashes (target data-slash) and path items (target data-path-item)
- Styleable spacing (target gap in data-path-container)
- An optional copy button which copies the full path (target data-copy-button)
- An optional icon before the last path element
- An optional callback to linkify a path item
- Zero additional whitespace in the DOM
- This means document.querySelector('[data-path-container]').textContent should always exactly equal the path
- Selecting the path and copying it manually still works, even with inline icons (which would normally add a space)
This component is designed to be styled by targeting data- attributes rather than by using slots
because it is disturbingly easy to break the whitespace and path copying guarantees this component
provides by introducing whitespace or inline block elements in a slot. That is also why this is
a somwhat "full-featured" component rather than being designed more around composition.
-->
<script lang="ts">
import CopyButton from '$lib/wildcard/CopyButton.svelte'
/**
* The path to be formatted
*/
export let path: string
/**
* Whether to show a "copy path" button. Can be styled by targeting `data-copy-button`
*/
export let showCopyButton = false
/**
* A callback to generate an href for the given path. If unset, path items
* will not be linkified. Will be called with the full path prefix for a
* path item. For example, for the path `tmp/test`, it will be called with
* `tmp` and `tmp/test`.
*
* For most cases, use the `pathHrefFactory` helper to create this callback.
*/
export let pathHref: ((path: string) => string) | undefined = undefined
$: parts = path.split('/').map((part, index, allParts) => ({ part, path: allParts.slice(0, index + 1).join('/') }))
</script>
<!--
NOTE: all the weird comments in here are being very careful to not
introduce any additional whitespace in the path container since that would
make the path invalid when copied from a selection
-->
<span data-path-container>
<slot name="prefix" /><!--
-->{#each parts as { part, path }, index}<!--
-->{@const last =
index === parts.length - 1}<!--
-->{#if index > 0}<!--
--><span data-slash>/</span
><!--
-->{/if}<!--
Wrap the anchor element with a span because otherwise it adds
spaces around it when copied
--><span
class:after={last}
data-path-item
>{#if pathHref}<a href={pathHref(path)}>{part}</a>{:else}{part}{/if}<!--
--></span
><!--
-->{#if last}<!--
--><span data-file-icon aria-hidden="true"
><slot name="file-icon" /></span
><!--
-->{/if}<!--
-->{/each}<!--
We include the copy button in this component because we want it to wrap along with the path
elements. Otherwise, an invisible button might wrap to its own line, which looks weird.
-->{#if showCopyButton}<!--
--><span
data-copy-button
class="after"><CopyButton value={path} label="Copy path to clipboard" /></span
><!--
-->{/if}
</span>
<style lang="scss">
[data-path-container] {
display: inline-flex;
align-items: center;
gap: 0.125em;
white-space: pre-wrap;
font-weight: 400;
font-size: var(--code-font-size);
font-family: var(--code-font-family);
// Global so data-slash can be slotted in with the prefix
// and styled consistently.
:global([data-slash]) {
color: var(--text-disabled);
display: inline;
}
}
[data-path-item] {
display: inline;
white-space: nowrap;
color: var(--text-body);
& > a {
color: inherit;
}
}
// HACK: The file icon is placed after the file name in the DOM so it
// doesn't add any spaces in the file path when copied. This visually
// reorders the last path element after the file icon.
.after {
order: 1;
}
[data-file-icon] {
user-select: none; // Avoids a trailing space on select + copy
&:empty {
display: none;
}
}
[data-copy-button] {
margin-left: 0.5rem;
user-select: none; // Avoids a trailing space on select + copy
}
</style>

View File

@ -0,0 +1,49 @@
<script lang="ts" context="module">
import { Story } from '@storybook/addon-svelte-csf'
import { sizeToFit } from '$lib/dom'
import Icon from '$lib/Icon.svelte'
import { pathHrefFactory } from '.'
import ShrinkablePath from './ShrinkablePath.svelte'
export const meta = {
component: ShrinkablePath,
}
const path = 'very/very/very/long/displayed/path'
let shrinkableDefault: ShrinkablePath
let shrinkableLinkified: ShrinkablePath
</script>
<Story name="Default">
<div use:sizeToFit={{ grow: () => shrinkableDefault.grow(), shrink: () => shrinkableDefault.shrink() }}>
<ShrinkablePath bind:this={shrinkableDefault} {path} />
</div>
</Story>
<Story name="Linkified">
<div use:sizeToFit={{ grow: () => shrinkableLinkified.grow(), shrink: () => shrinkableLinkified.shrink() }}>
<ShrinkablePath
bind:this={shrinkableLinkified}
{path}
pathHref={pathHrefFactory({
repoName: 'myrepo',
revision: 'main',
fullPath: path,
fullPathType: 'blob',
})}
>
<Icon icon={ILucideEye} inline />
</ShrinkablePath>
</div>
</Story>
<style lang="scss">
div {
width: 200px;
overflow: hidden;
resize: horizontal;
}
</style>

View File

@ -0,0 +1,77 @@
<!--
@component
ShrinkablePath is a DisplayPath that can collapse its path items
into a dropdown menu to save space. It does not do this automatically,
and is usually used alongside a helper like `sizeToFit`.
-->
<script lang="ts">
import { writable } from 'svelte/store'
import { getButtonClassName } from '$lib/wildcard/Button'
import DropdownMenu from '$lib/wildcard/menu/DropdownMenu.svelte'
import MenuLink from '$lib/wildcard/menu/MenuLink.svelte'
import MenuText from '$lib/wildcard/menu/MenuText.svelte'
import Icon from '../Icon.svelte'
import DisplayPath from './DisplayPath.svelte'
export let path: string
export let showCopyButton = false
export let pathHref: ((path: string) => string) | undefined = undefined
$: parts = path.split('/').map((part, index, allParts) => ({ part, path: allParts.slice(0, index + 1).join('/') }))
let collapsedPartCount = 0
$: collapsedParts = parts.slice(0, collapsedPartCount)
$: visibleParts = parts.slice(collapsedPartCount)
$: scopedPathHref = pathHref
? (path: string) => pathHref(collapsedParts.map(({ part }) => part).join('/') + path)
: undefined
// Returns whether shrinking was successful
export function shrink(): boolean {
// Never collapse the last element of the path
if (collapsedPartCount < parts.length - 1) {
collapsedPartCount++
return true
}
return false
}
// Returns whether growing was successful
export function grow(): boolean {
if (collapsedPartCount > 0) {
collapsedPartCount--
return true
}
return false
}
const breadcrumbMenuOpen = writable(false)
</script>
<DisplayPath path={visibleParts.map(({ part }) => part).join('/')} {showCopyButton} pathHref={scopedPathHref}>
<svelte:fragment slot="prefix">
{#if collapsedParts.length > 0}
<DropdownMenu
open={breadcrumbMenuOpen}
triggerButtonClass={getButtonClassName({ variant: 'icon', outline: true, size: 'sm' })}
aria-label="{$breadcrumbMenuOpen ? 'Close' : 'Open'} collapsed path elements"
>
<Icon slot="trigger" inline icon={ILucideEllipsis} aria-label="Collapsed path elements" />
{#each collapsedParts as { part, path }}
<svelte:component
this={pathHref ? MenuLink : MenuText}
href={pathHref ? pathHref(path) : undefined}
>
<Icon inline icon={ILucideFolder} aria-hidden="true" />
{part}
</svelte:component>
{/each}
</DropdownMenu>
<span data-slash>/</span>
{/if}
</svelte:fragment>
<slot name="file-icon" slot="file-icon" />
</DisplayPath>

View File

@ -0,0 +1,28 @@
import { resolveRoute } from '$app/paths'
import { encodeURIPathComponent } from '$lib/common'
const TREE_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/tree/[...path]'
const BLOB_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]'
export function pathHrefFactory({
repoName,
revision,
fullPath,
fullPathType,
}: {
repoName: string
revision: string | undefined
fullPath: string
fullPathType: 'blob' | 'tree'
}): (targetPath: string) => string {
return (targetPath: string) =>
resolveRoute(
// If we are targeting the last item in the path, respect the passed-in type.
// Otherwise, we know we are targeting a tree higher up in the path.
fullPath === targetPath && fullPathType === 'blob' ? BLOB_ROUTE_ID : TREE_ROUTE_ID,
{
repo: revision ? `${repoName}@${revision}` : repoName,
path: encodeURIPathComponent(targetPath),
}
)
}

View File

@ -1,105 +1,49 @@
<script lang="ts">
import { writable } from 'svelte/store'
import { resolveRoute } from '$app/paths'
import { encodeURIPathComponent } from '$lib/common'
import { sizeToFit } from '$lib/dom'
import Icon from '$lib/Icon.svelte'
import { DropdownMenu, MenuLink } from '$lib/wildcard'
import { pathHrefFactory } from '$lib/path'
import ShrinkablePath from '$lib/path/ShrinkablePath.svelte'
import { DropdownMenu } from '$lib/wildcard'
import { getButtonClassName } from '$lib/wildcard/Button'
import CopyButton from '$lib/wildcard/CopyButton.svelte'
const TREE_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/tree/[...path]'
const BLOB_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]'
export let repoName: string
export let revision: string | undefined
export let path: string
export let type: 'blob' | 'tree'
$: breadcrumbs = path.split('/').map((part, index, all): [string, string] => [
part,
resolveRoute(
// Only the last element in a path can be a blob
index < all.length - 1 || type === 'tree' ? TREE_ROUTE_ID : BLOB_ROUTE_ID,
{
repo: revision ? `${repoName}@${revision}` : repoName,
path: encodeURIPathComponent(all.slice(0, index + 1).join('/')),
}
),
])
// HACK: we use a flexbox for the path and an inline icon, but we still want the copied path to be usable.
// This event handler removes the newlines surrounding slashes from the copied text.
function stripSpaces(event: ClipboardEvent) {
const selection = document.getSelection() ?? ''
event.clipboardData?.setData('text/plain', selection.toString().replaceAll(/\n?\/\n?/g, '/'))
event.preventDefault()
}
const breadcrumbMenuOpen = writable(false)
$: compact = false
$: visibleBreadcrumbCount = breadcrumbs.length
$: collapsedBreadcrumbCount = breadcrumbs.length - visibleBreadcrumbCount
let compact = false
let shrinkablePath: ShrinkablePath
function grow(): boolean {
// Expand the breadcrumbs first, then the actions
if (visibleBreadcrumbCount < breadcrumbs.length) {
visibleBreadcrumbCount += 1
if (shrinkablePath.grow()) {
return true
}
if (compact) {
compact = false
return true
}
compact = false
return false
}
function shrink(): boolean {
// Collapse the actions first, then the breadcrumbs
if (!compact) {
compact = true
return visibleBreadcrumbCount > 1
return true
}
if (visibleBreadcrumbCount > 1) {
visibleBreadcrumbCount -= 1
}
return visibleBreadcrumbCount > 1
return shrinkablePath.shrink()
}
</script>
<div class="header" use:sizeToFit={{ grow, shrink }}>
<h2 on:copy={stripSpaces} data-testid="file-header-path">
{#if collapsedBreadcrumbCount > 0}
<DropdownMenu
open={breadcrumbMenuOpen}
triggerButtonClass={getButtonClassName({ variant: 'icon', outline: true, size: 'sm' })}
aria-label="{$breadcrumbMenuOpen ? 'Close' : 'Open'} collapsed path elements"
>
<svelte:fragment slot="trigger">
<Icon inline icon={ILucideEllipsis} aria-label="Collapsed path elements" />
</svelte:fragment>
{#each breadcrumbs.slice(0, collapsedBreadcrumbCount) as [name, path]}
<MenuLink href={path}>
<Icon inline icon={ILucideFolder} aria-label="Collapsed path elements" />
{name}
</MenuLink>
{/each}
</DropdownMenu>
<span class="slash">/</span>
{/if}
{#each breadcrumbs.slice(collapsedBreadcrumbCount) as [name, path], index}
{@const last = index === breadcrumbs.length - collapsedBreadcrumbCount - 1}
<span class:last>
{#if index > 0}
<span class="slash">/</span>
{/if}
{#if last}
<slot name="icon" />
{/if}
{#if path}
<a href={path}>{name}</a>
{:else}
{name}
{/if}
</span>
{/each}
<span class="copy-button"><CopyButton value={path} label="Copy path to clipboard" /></span>
<header use:sizeToFit={{ grow, shrink }}>
<h2 data-testid="file-header-path">
<ShrinkablePath
bind:this={shrinkablePath}
{path}
pathHref={pathHrefFactory({ repoName, revision, fullPath: path, fullPathType: type })}
showCopyButton
>
<slot name="file-icon" slot="file-icon" />
</ShrinkablePath>
</h2>
<div class="actions" class:compact>
<slot name="actions" />
@ -118,10 +62,10 @@
</div>
{/if}
</div>
</div>
</header>
<style lang="scss">
.header {
header {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
@ -135,47 +79,31 @@
}
h2 {
flex: 1;
display: flex;
flex-wrap: nowrap;
gap: 0.375em;
span {
display: flex;
gap: inherit;
white-space: nowrap;
}
font-weight: 400;
font-size: var(--code-font-size);
font-family: var(--code-font-family);
gap: 0.5rem;
align-items: center;
margin: 0;
a {
color: var(--text-body);
&:hover {
color: var(--text-title);
}
:global([data-path-container]) {
gap: 0.375em !important;
}
.slash {
color: var(--text-disabled);
}
// Grow to fill the rest of the header so the hoverable area for
// showing the copy button is large.
flex: 1;
.last {
color: var(--text-title);
:global([data-copy-button]) {
opacity: 0;
transition: opacity 0.2s;
}
.copy-button {
visibility: hidden;
}
&:hover .copy-button {
visibility: visible;
&:is(:hover, :focus-within) :global([data-copy-button]) {
opacity: 1;
}
}
.actions {
margin-left: auto;
display: flex;
justify-content: space-evenly;
gap: 1rem;

View File

@ -22,12 +22,12 @@
</script>
<script lang="ts">
import { resolveRoute } from '$app/paths'
import Avatar from '$lib/Avatar.svelte'
import { pluralize } from '$lib/common'
import { getGraphQLClient } from '$lib/graphql'
import Icon from '$lib/Icon.svelte'
import { displayRepoName } from '$lib/shared'
import { pathHrefFactory } from '$lib/path'
import DisplayPath from '$lib/path/DisplayPath.svelte'
import Timestamp from '$lib/Timestamp.svelte'
import { formatBytes } from '$lib/utils'
import Badge from '$lib/wildcard/Badge.svelte'
@ -40,43 +40,21 @@
export let revision: string
export let entry: FilePopoverFragment | DirPopoverFragment
const TREE_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/tree/[...path]'
function splitPath(filePath: string): [string[], string] {
function splitPath(filePath: string): [string, string] {
let parts = filePath.split('/')
return [parts.slice(0, parts.length - 1), parts[parts.length - 1]]
return [parts.slice(0, parts.length - 1).join('/'), parts[parts.length - 1]]
}
$: [dirNameEntries, baseName] = splitPath(entry.path)
$: dirNameBreadcrumbs = dirNameEntries.map((part, index, all): [string, string] => [
part,
resolveRoute(TREE_ROUTE_ID, {
repo: revision ? `${repoName}@${revision}` : repoName,
path: all.slice(0, index + 1).join('/'),
}),
])
$: [dirName, baseName] = splitPath(entry.path)
$: lastCommit = entry.history.nodes[0].commit
</script>
<div class="root section muted">
<div class="repo-and-path section mono">
<!--
Extra layer of divs to allow customizing the gap, but wrap before the slashes.
Ideally we'd be able to use `break-after: avoid;`, but that's not widely supported.
-->
{#each displayRepoName(repoName).split('/') as repoFragment, i}
<span>
{#if i > 0}<span>/</span>{/if}
<span>{repoFragment}</span>
</span>
{/each}
{#if dirNameBreadcrumbs.length}<span>·</span>{/if}
{#each dirNameBreadcrumbs as [name, href], i}
<span>
{#if i > 0}<span>/</span>{/if}
<span><a {href}>{name}</a></span>
</span>
{/each}
<div class="root">
<div class="path section">
<DisplayPath
path={dirName}
pathHref={pathHrefFactory({ repoName, revision, fullPath: dirName, fullPathType: 'tree' })}
/>
</div>
<div class="lang-and-file section">
@ -131,27 +109,7 @@
.section {
padding: 0.5rem 1rem;
}
.repo-and-path {
display: flex;
flex-wrap: wrap;
gap: 0.375em;
span {
display: flex;
flex-wrap: nowrap;
gap: inherit;
}
border-bottom: 1px solid var(--border-color);
font-size: var(--font-size-tiny);
a {
color: unset;
&:hover {
color: var(--text-body);
}
}
}
.lang-and-file {
@ -213,10 +171,6 @@
color: var(--text-title);
}
.muted {
color: var(--text-muted);
}
.body {
color: var(--text-body);
a {

View File

@ -25,6 +25,7 @@ export { TemporarySettingsStorage } from '@sourcegraph/shared/src/settings/tempo
export {
LATEST_VERSION,
TELEMETRY_FILTER_TYPES,
getRevision,
aggregateStreamingSearch,
emptyAggregateResults,
getFileMatchUrl,

View File

@ -21,22 +21,20 @@
</script>
<span class="copy-button">
<Tooltip {tooltip} placement="bottom">
<slot name="custom" {handleCopy}>
<Button on:click={handleCopy} variant="icon" size="sm" aria-label={label}>
<Icon inline icon={ILucideCopy} aria-hidden />
</Button>
</slot>
</Tooltip>
</span>
<Tooltip {tooltip} placement="bottom"
><slot name="custom" {handleCopy}
><Button on:click={handleCopy} variant="icon" size="sm" aria-label={label}
><Icon inline icon={ILucideCopy} aria-hidden /></Button
></slot
></Tooltip
></span
>
<style lang="scss">
.copy-button {
display: contents;
color: var(--text-muted);
&:hover {
color: var(--body-color);
--icon-color: var(--body-color);
}
}
</style>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { getContext } from './DropdownMenu.svelte'
type $$Props = HTMLAttributes<HTMLDivElement>
const { item } = getContext()
</script>
<div {...$$restProps} {...$item} use:item>
<slot />
</div>
<!-- styles are mostly defined in Menu.svelte. Unsets default styles for non-interactive menu item -->
<style lang="scss">
div[role='menuitem'] {
cursor: unset;
&:hover,
&:focus {
background-color: unset;
color: unset;
text-decoration: none;
}
}
</style>

View File

@ -19,7 +19,7 @@
</script>
<FileHeader type="blob" repoName={data.repoName} revision={data.revision} path={data.filePath}>
<svelte:fragment slot="icon">
<svelte:fragment slot="file-icon">
{#if $commit.value?.blob}
<FileIcon file={$commit.value.blob} inline />
{/if}

View File

@ -22,6 +22,7 @@
import FileIcon from '$lib/repo/FileIcon.svelte'
import { renderMermaid } from '$lib/repo/mermaid'
import OpenInEditor from '$lib/repo/open-in-editor/OpenInEditor.svelte'
import OpenCodyAction from '$lib/repo/OpenCodyAction.svelte'
import Permalink from '$lib/repo/Permalink.svelte'
import { createCodeIntelAPI, replaceRevisionInURL } from '$lib/shared'
import { isLightTheme, settings } from '$lib/stores'
@ -36,7 +37,6 @@
import { FileViewGitBlob, FileViewHighlightedFile } from './FileView.gql'
import FileViewModeSwitcher from './FileViewModeSwitcher.svelte'
import OpenInCodeHostAction from './OpenInCodeHostAction.svelte'
import OpenCodyAction from '$lib/repo/OpenCodyAction.svelte'
import { CodeViewMode, toCodeViewMode } from './util'
export let data: Extract<PageData, { type: 'FileView' }>
@ -166,14 +166,12 @@
{#if embedded}
<FileHeader type="blob" repoName={data.repoName} path={data.filePath} {revision}>
<FileIcon slot="icon" file={blob} inline />
<svelte:fragment slot="actions">
<slot name="actions" />
</svelte:fragment>
<FileIcon slot="file-icon" file={blob} inline />
<slot name="actions" slot="actions" />
</FileHeader>
{:else}
<FileHeader type="blob" repoName={data.repoName} path={data.filePath} {revision}>
<FileIcon slot="icon" file={blob} inline />
<FileIcon slot="file-icon" file={blob} inline />
<svelte:fragment slot="actions">
{#if !revisionOverride}
{#await data.externalServiceType then externalServiceType}

View File

@ -245,14 +245,14 @@ test.describe('file header', () => {
await expect(page.getByRole('link', { name: 'src' })).toBeVisible()
})
test('select and copy file path', async ({ page, context }) => {
test('textContent is exactly the path', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto(url)
await page.getByTestId('file-header-path').selectText()
await page.keyboard.press(`Meta+KeyC`)
await page.keyboard.press(`Control+KeyC`)
const clipboardText = await page.evaluate('navigator.clipboard.readText()')
expect(clipboardText, 'path should be copied to clipboard and not contain spaces').toBe('src/readme.md')
// We specifically check the textContent here because this is what is
// used to apply highlights. It must exactly equal the path (no additional
// whitespace) or the highlights will be incorrectly offset.
const pathContainer = page.locator('css=[data-path-container]').first()
await expect(pathContainer).toHaveText(/^src\/readme.md$/)
})
test('copy path button', async ({ page, context }) => {

View File

@ -250,7 +250,7 @@ test('history panel', async ({ page, sg }) => {
test('file popover', async ({ page, sg }, testInfo) => {
// Test needs more time to teardown
test.setTimeout(testInfo.timeout * 3000)
test.setTimeout(testInfo.timeout * 4)
await page.goto(`/${repoName}`)
@ -309,7 +309,7 @@ test('file popover', async ({ page, sg }, testInfo) => {
await expect(page.getByText('Last Changed')).toBeVisible()
// Click the parent dir in the popover and expect to navigate to that page
await page.locator('span').filter({ hasText: /^src$/ }).getByRole('link').click()
await page.locator('div').filter({ hasText: /^src$/ }).getByRole('link').click()
await page.waitForURL(/src$/)
})

View File

@ -87,6 +87,21 @@
entry => entry.visibility === 'user' || (entry.visibility === 'admin' && data.user?.siteAdmin)
)
$: visibleNavEntryCount = viewableNavEntries.length
function grow(): boolean {
if (visibleNavEntryCount < viewableNavEntries.length) {
visibleNavEntryCount++
return true
}
return false
}
function shrink(): boolean {
if (visibleNavEntryCount > 0) {
visibleNavEntryCount--
return true
}
return false
}
$: navEntriesToShow = viewableNavEntries.slice(0, visibleNavEntryCount)
$: overflowNavEntries = viewableNavEntries.slice(visibleNavEntryCount)
$: allMenuEntries = [...overflowNavEntries, ...menuEntries]
@ -122,7 +137,10 @@
key: 'ctrl+backspace',
mac: 'cmd+backspace',
},
ignoreInputFields: false,
// Ctrl/cmd+Backspace is used to delete whole words in inputs
// This would interfere e.g. with the fuzzy finder (but not the search input because
// CodeMirror handles this itself)
ignoreInputFields: true,
handler: () => {
goto(data.repoURL)
},
@ -135,19 +153,7 @@
</div>
</GlobalHeaderPortal>
<nav
aria-label="repository"
use:sizeToFit={{
grow() {
visibleNavEntryCount = Math.min(visibleNavEntryCount + 1, viewableNavEntries.length)
return visibleNavEntryCount < viewableNavEntries.length
},
shrink() {
visibleNavEntryCount = Math.max(visibleNavEntryCount - 1, 0)
return visibleNavEntryCount > 0
},
}}
>
<nav aria-label="repository" use:sizeToFit={{ grow, shrink }}>
<RepoMenu
repoName={data.repoName}
displayRepoName={data.displayRepoName}

View File

@ -1,13 +1,13 @@
<script lang="ts">
import { highlightRanges } from '$lib/dom'
import { getFileMatchUrl, type ContentMatch, type PathMatch, type SymbolMatch } from '$lib/shared'
import CopyButton from '$lib/wildcard/CopyButton.svelte'
import DisplayPath from '$lib/path/DisplayPath.svelte'
import { pathHrefFactory } from '$lib/path/index'
import { getRevision, type ContentMatch, type PathMatch, type SymbolMatch } from '$lib/shared'
import RepoRev from './RepoRev.svelte'
export let result: ContentMatch | PathMatch | SymbolMatch
$: fileURL = getFileMatchUrl(result)
$: rev = result.branches?.[0]
$: matches =
@ -16,34 +16,40 @@
: []
</script>
<RepoRev repoName={result.repository} {rev} />
<span class="interpunct">·</span>
<span class="root">
{#key result}
<a class="path" href={fileURL} use:highlightRanges={{ ranges: matches }}>
{result.path}
</a>
{/key}
<span data-visible-on-focus><CopyButton value={result.path} label="Copy path to clipboard" /></span>
</span>
<div class="root">
<RepoRev repoName={result.repository} {rev} />
<span class="interpunct">·</span>
<span class="path" use:highlightRanges={{ ranges: matches }}>
<DisplayPath
path={result.path}
pathHref={pathHrefFactory({
repoName: result.repository,
revision: getRevision(result.branches, result.commit),
fullPath: result.path,
fullPathType: 'blob',
})}
showCopyButton
/>
</span>
</div>
<style lang="scss">
.root {
font-family: var(--code-font-family);
font-size: var(--code-font-size);
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
a,
span {
vertical-align: middle;
:global([data-path-container]) {
flex-flow: wrap;
}
}
.interpunct {
margin: 0 0.5rem;
color: var(--text-disabled);
.path {
display: contents;
}
.path {
color: var(--text-body);
.interpunct {
color: var(--text-disabled);
}
</style>

View File

@ -1,4 +1,4 @@
<article data-testid="search-result">
<article data-show-copy-target data-testid="search-result">
<div class="header">
<div class="title">
<slot name="title" />
@ -18,15 +18,12 @@
<style lang="scss">
article {
:global([data-visible-on-focus]) {
visibility: hidden;
:global([data-copy-button]) {
opacity: 0;
transition: opacity 0.2s;
}
&:hover,
&:focus-within {
:global([data-visible-on-focus]) {
visibility: visible;
}
&:is(:hover, :focus-within) :global([data-copy-button]) {
opacity: 1;
}
}

View File

@ -131,11 +131,13 @@ test('copy path button appears and copies path', async ({ page, sg }) => {
)
await stream.close()
const copyPathButton = page.getByRole('button', { name: 'Copy path to clipboard' })
for (const match of [contentMatch, pathMatch, symbolMatch]) {
await page.getByRole('link', { name: match.path }).hover()
expect(copyPathButton).toBeVisible()
await page.getByText(match.path).hover()
const copyPathButton = page
.locator('article')
.filter({ hasText: match.path })
.getByLabel('Copy path to clipboard')
await expect(copyPathButton).toBeVisible()
await copyPathButton.click()
const clipboardText = await page.evaluate('navigator.clipboard.readText()')
expect(clipboardText).toBe(match.path)

View File

@ -104,7 +104,7 @@ function createUnifiedDiff(): string {
export function createContentMatch(): ContentMatch {
const repository = createRepoName()
const path = faker.system.filePath()
const path = faker.system.filePath().slice(1)
return {
type: 'content',
@ -160,7 +160,7 @@ export function createTeamMatch(): TeamMatch {
}
export function createPathMatch(): PathMatch {
const path = faker.system.filePath()
const path = faker.system.filePath().slice(1)
return {
type: 'path',
repository: createRepoName(),
@ -172,7 +172,7 @@ export function createPathMatch(): PathMatch {
}
}
export function createSymbolMatch(): SymbolMatch {
const path = faker.system.filePath()
const path = faker.system.filePath().slice(1)
return {
type: 'symbol',
repository: createRepoName(),

View File

@ -380,7 +380,6 @@ ts_project(
"src/components/externalServices/ExternalServicePage.tsx",
"src/components/externalServices/ExternalServiceSyncJobNode.tsx",
"src/components/externalServices/ExternalServiceSyncJobsList.tsx",
"src/components/externalServices/ExternalServiceWebhook.tsx",
"src/components/externalServices/ExternalServicesPage.tsx",
"src/components/externalServices/GerritIcon.tsx",
"src/components/externalServices/backend.ts",
@ -1524,6 +1523,7 @@ ts_project(
"src/site-admin/UserManagement/components/useUserListActions.tsx",
"src/site-admin/UserManagement/index.tsx",
"src/site-admin/UserManagement/queries.tsx",
"src/site-admin/WebhookConfirmDeleteModal.tsx",
"src/site-admin/WebhookCreateUpdatePage.tsx",
"src/site-admin/WebhookInfoLogPageHeader.tsx",
"src/site-admin/WebhookInformation.tsx",
@ -1597,7 +1597,6 @@ ts_project(
"src/site-admin/webhooks/PerformanceGauge.tsx",
"src/site-admin/webhooks/StatusCode.tsx",
"src/site-admin/webhooks/WebhookLogNode.tsx",
"src/site-admin/webhooks/WebhookLogPage.tsx",
"src/site-admin/webhooks/WebhookLogPageHeader.tsx",
"src/site-admin/webhooks/backend.ts",
"src/site-admin/webhooks/story/StyledPerformanceGauge.tsx",
@ -2225,7 +2224,6 @@ ts_project(
"src/components/externalServices/ExternalServiceEditPage.story.tsx",
"src/components/externalServices/ExternalServicePage.story.tsx",
"src/components/externalServices/ExternalServiceSyncJobNode.story.tsx",
"src/components/externalServices/ExternalServiceWebhook.story.tsx",
"src/components/externalServices/ExternalServicesPage.story.tsx",
"src/components/fuzzyFinder/FuzzyFinder.story.tsx",
"src/components/time/Duration.story.tsx",
@ -2422,7 +2420,6 @@ ts_project(
"src/site-admin/webhooks/PerformanceGauge.story.tsx",
"src/site-admin/webhooks/StatusCode.story.tsx",
"src/site-admin/webhooks/WebhookLogNode.story.tsx",
"src/site-admin/webhooks/WebhookLogPage.story.tsx",
"src/site-admin/webhooks/WebhookLogPageHeader.story.tsx",
"src/storm/pages/SearchPage/SearchPageContent.story.tsx",
"src/team/area/TeamChildTeamsPage.story.tsx",

View File

@ -1,11 +1,13 @@
import * as React from 'react'
import { mdiArrowLeftBoldBoxOutline } from '@mdi/js'
import { useLocation } from 'react-router-dom'
import { asError, type ErrorLike, isErrorLike, logger } from '@sourcegraph/common'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger'
import { Button, Link, LoadingSpinner, Alert, Text, Input, ErrorAlert, Form, Container } from '@sourcegraph/wildcard'
import { Button, Link, LoadingSpinner, Alert, Text, Input, ErrorAlert, Form, Container, Icon } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '../auth'
import { LoaderButton } from '../components/LoaderButton'
@ -152,10 +154,12 @@ class ResetPasswordCodeForm extends React.PureComponent<ResetPasswordCodeFormPro
}
public render(): JSX.Element | null {
const { email } = this.props
if (this.state.submitOrError === null) {
return (
<Alert variant="success">
Your password was reset. <Link to="/sign-in">Sign in with your new password</Link> to continue.
Your password was reset. <Link to={`/sign-in?email=${email}`}>Sign in with your new password</Link> to continue.
</Alert>
)
}
@ -164,6 +168,8 @@ class ResetPasswordCodeForm extends React.PureComponent<ResetPasswordCodeFormPro
<>
{isErrorLike(this.state.submitOrError) && <ErrorAlert error={this.state.submitOrError} />}
<Container className="w-100">
<Link to='/password-reset'><Icon className="mr-1" aria-hidden={true} svgPath={mdiArrowLeftBoldBoxOutline} />Raise request for a different account</Link>
<Text className="mt-1 text-center text-muted font-weight-bold mb-3">{email}</Text>
<Form data-testid="reset-password-page-form" onSubmit={this.handleSubmitResetPassword}>
<PasswordInput
name="password"

View File

@ -45,6 +45,7 @@ export const SignInPage: React.FunctionComponent<React.PropsWithChildren<SignInP
}, [props.telemetryRecorder])
const location = useLocation()
const email = new URLSearchParams(location.search).get('email')
const [error, setError] = useState<Error | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const isRequestAccessAllowed = checkRequestAccessAllowed(props.context)
@ -143,6 +144,7 @@ export const SignInPage: React.FunctionComponent<React.PropsWithChildren<SignInP
{builtInAuthProvider && (showMoreProviders || thirdPartyAuthProviders.length === 0) && (
<UsernamePasswordSignInForm
{...props}
email={email}
onAuthError={setError}
className={classNames({ 'mb-3': providers.length > 0 })}
/>

View File

@ -15,6 +15,7 @@ import { getReturnTo, PasswordInput } from './SignInSignUpCommon'
interface Props extends TelemetryV2Props {
onAuthError: (error: Error | null) => void
email: string | null
context: Pick<
SourcegraphContext,
'allowSignup' | 'authProviders' | 'sourcegraphDotComMode' | 'xhrHeaders' | 'resetPasswordEnabled'
@ -27,12 +28,15 @@ interface Props extends TelemetryV2Props {
*/
export const UsernamePasswordSignInForm: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
onAuthError,
email,
className,
context,
telemetryRecorder,
}) => {
const location = useLocation()
const [usernameOrEmail, setUsernameOrEmail] = useState('')
// To populate the username/email text-box with the user's email value on sign-in screen after successful password change request
const [usernameOrEmail, setUsernameOrEmail] = useState(email || '')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)

View File

@ -241,8 +241,8 @@ export const FETCH_HIGHLIGHTED_BLOB = gql`
`
export const CODE_INTEL_SEARCH_QUERY = gql`
query CodeIntelSearch2($query: String!) {
search(query: $query) {
query CodeIntelSearch2($query: String!, $version: SearchVersion!) {
search(query: $query, version: $version) {
__typename
results {
__typename

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import { CodyWebHistory, CodyWebChatProvider } from 'cody-web-experimental'
import { Navigate } from 'react-router-dom'
import { Badge, ButtonLink, PageHeader, Text } from '@sourcegraph/wildcard'
import { ButtonLink, PageHeader, ProductStatusBadge, Text } from '@sourcegraph/wildcard'
import { Page } from '../../../components/Page'
import { PageTitle } from '../../../components/PageTitle'
@ -95,9 +95,7 @@ const CodyPageHeader: FC<CodyPageHeaderProps> = props => {
<PageHeader.Breadcrumb icon={CodyColorIcon}>
<div className="d-inline-flex align-items-center">
Cody Chat
<Badge variant="info" className="ml-2">
Experimental
</Badge>
<ProductStatusBadge status="beta" className="ml-2" />
</div>
</PageHeader.Breadcrumb>
</PageHeader.Heading>

View File

@ -4,7 +4,7 @@ import { mdiClose } from '@mdi/js'
import { CodyLogo } from '@sourcegraph/cody-ui'
import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
import { Alert, Badge, Button, H4, Icon, LoadingSpinner } from '@sourcegraph/wildcard'
import { Alert, Button, H4, Icon, LoadingSpinner, ProductStatusBadge } from '@sourcegraph/wildcard'
import styles from './NewCodySidebar.module.scss'
@ -32,7 +32,7 @@ export const NewCodySidebar: FC<NewCodySidebarProps> = props => {
<CodyLogo />
Cody
<div className="ml-2">
<Badge variant="info">Experimental</Badge>
<ProductStatusBadge status="beta" />
</div>
</div>
<Button variant="icon" aria-label="Close" onClick={onClose}>

View File

@ -20,7 +20,6 @@ import {
import { getBreadCrumbs } from './breadCrumbs'
import { ExternalServiceForm } from './ExternalServiceForm'
import { resolveExternalServiceCategory } from './externalServices'
import { ExternalServiceWebhook } from './ExternalServiceWebhook'
interface Props extends TelemetryProps, TelemetryV2Props {
externalServicesFromFile: boolean
@ -150,7 +149,6 @@ export const ExternalServiceEditPage: FC<Props> = ({
telemetryRecorder={telemetryRecorder}
/>
)}
<ExternalServiceWebhook externalService={externalService} className="mt-3" />
</Container>
)
}

View File

@ -32,7 +32,6 @@ import { getBreadCrumbs } from './breadCrumbs'
import { ExternalServiceInformation } from './ExternalServiceInformation'
import { resolveExternalServiceCategory } from './externalServices'
import { ExternalServiceSyncJobsList } from './ExternalServiceSyncJobsList'
import { ExternalServiceWebhook } from './ExternalServiceWebhook'
import styles from './ExternalServicePage.module.scss'
@ -137,22 +136,13 @@ export const ExternalServicePage: FC<Props> = props => {
const renderExternalService = (externalService: ExternalServiceFieldsWithConfig): JSX.Element => {
let externalServiceAvailabilityStatus
if (loading) {
externalServiceAvailabilityStatus = (
<Alert className="mt-2" variant="waiting">
Checking code host connection status...
</Alert>
)
externalServiceAvailabilityStatus = <Alert variant="waiting">Checking code host connection status...</Alert>
} else if (!error) {
if (checkConnectionNode?.__typename === 'ExternalServiceAvailable') {
externalServiceAvailabilityStatus = (
<Alert className="mt-2" variant="success">
Code host is reachable.
</Alert>
)
externalServiceAvailabilityStatus = <Alert variant="success">Code host is reachable.</Alert>
} else if (checkConnectionNode?.__typename === 'ExternalServiceUnavailable') {
externalServiceAvailabilityStatus = (
<ErrorAlert
className="mt-2"
prefix="Error during code host connection check"
error={checkConnectionNode.suspectedReason}
/>
@ -160,16 +150,12 @@ export const ExternalServicePage: FC<Props> = props => {
}
} else {
externalServiceAvailabilityStatus = (
<ErrorAlert
className="mt-2"
prefix="Unexpected error during code host connection check"
error={error.message}
/>
<ErrorAlert prefix="Unexpected error during code host connection check" error={error.message} />
)
}
const externalServiceCategory = resolveExternalServiceCategory(externalService)
return (
<Container className="mb-3">
<>
<PageHeader
path={path}
byline={
@ -248,64 +234,69 @@ export const ExternalServicePage: FC<Props> = props => {
</div>
}
/>
{isErrorLike(isDeleting) && <ErrorAlert className="mt-2" error={isDeleting} />}
{externalServiceAvailabilityStatus}
<H2>Information</H2>
{externalService.unrestricted && (
<Alert className="mt-2" variant="warning">
<H3>All repositories will be unrestricted</H3>
This code host connection does not have authorization configured. Any repositories added by this
code host will be accessible by all users on the instance, even if another code host connection
with authorization syncs the same repository. See{' '}
<Link to="/help/admin/permissions#getting-started">the documentation</Link> for instructions on
configuring authorization.
</Alert>
)}
{externalServiceCategory && (
<ExternalServiceInformation
displayName={externalService.displayName}
codeHostID={externalService.id}
rateLimiterState={externalService.rateLimiterState}
reposNumber={numberOfRepos === 0 ? externalService.repoCount : numberOfRepos}
syncInProgress={syncInProgress}
gitHubApp={ghApp}
{...externalServiceCategory}
<Container className="mb-3">
{isErrorLike(isDeleting) && <ErrorAlert error={isDeleting} />}
{externalServiceAvailabilityStatus}
<H2>Information</H2>
{externalService.unrestricted && (
<Alert variant="warning">
<H3>All repositories will be unrestricted</H3>
This code host connection does not have authorization configured. Any repositories added by
this code host will be accessible by all users on the instance, even if another code host
connection with authorization syncs the same repository. See{' '}
<Link to="/help/admin/permissions#getting-started">the documentation</Link> for instructions
on configuring authorization.
</Alert>
)}
{externalServiceCategory && (
<ExternalServiceInformation
displayName={externalService.displayName}
codeHostID={externalService.id}
rateLimiterState={externalService.rateLimiterState}
reposNumber={numberOfRepos === 0 ? externalService.repoCount : numberOfRepos}
syncInProgress={syncInProgress}
gitHubApp={ghApp}
{...externalServiceCategory}
/>
)}
<H2>Configuration</H2>
{externalServiceCategory && (
<DynamicallyImportedMonacoSettingsEditor
value={externalService.config}
jsonSchema={externalServiceCategory.jsonSchema}
canEdit={false}
loading={fetchLoading}
height={350}
readOnly={true}
isLightTheme={isLightTheme}
className="test-external-service-editor"
telemetryService={telemetryService}
telemetryRecorder={telemetryRecorder}
/>
)}
</Container>
<div className="d-flex mb-2 align-items-baseline justify-content-between">
<H2 className="mb-0">Recent sync jobs</H2>
<LoaderButton
label="Trigger manual sync"
alwaysShowLabel={true}
variant="secondary"
onClick={triggerSync}
loading={syncExternalServiceLoading}
disabled={syncExternalServiceLoading}
/>
)}
<H2>Configuration</H2>
{externalServiceCategory && (
<DynamicallyImportedMonacoSettingsEditor
value={externalService.config}
jsonSchema={externalServiceCategory.jsonSchema}
canEdit={false}
loading={fetchLoading}
height={350}
readOnly={true}
isLightTheme={isLightTheme}
className="test-external-service-editor"
telemetryService={telemetryService}
telemetryRecorder={telemetryRecorder}
</div>
<Container className="mb-3">
{syncExternalServiceError && <ErrorAlert error={syncExternalServiceError} />}
<ExternalServiceSyncJobsList
queryExternalServiceSyncJobs={queryExternalServiceSyncJobs}
updateSyncInProgress={updateSyncInProgress}
updateNumberOfRepos={updateNumberOfRepos}
externalServiceID={externalService.id}
updates={syncJobUpdates}
/>
)}
<ExternalServiceWebhook externalService={externalService} className="mt-3" />
<LoaderButton
label="Trigger manual sync"
className="mt-3 mb-2 float-right"
alwaysShowLabel={true}
variant="secondary"
onClick={triggerSync}
loading={syncExternalServiceLoading}
disabled={syncExternalServiceLoading}
/>
{syncExternalServiceError && <ErrorAlert error={syncExternalServiceError} />}
<ExternalServiceSyncJobsList
queryExternalServiceSyncJobs={queryExternalServiceSyncJobs}
updateSyncInProgress={updateSyncInProgress}
updateNumberOfRepos={updateNumberOfRepos}
externalServiceID={externalService.id}
updates={syncJobUpdates}
/>
</Container>
</Container>
</>
)
}

View File

@ -3,8 +3,6 @@ import React, { useCallback } from 'react'
import type { Subject } from 'rxjs'
import { repeat, tap } from 'rxjs/operators'
import { H2 } from '@sourcegraph/wildcard'
import {
type ExternalServiceSyncJobConnectionFields,
type ExternalServiceSyncJobListFields,
@ -56,25 +54,22 @@ export const ExternalServiceSyncJobsList: React.FunctionComponent<ExternalServic
)
return (
<>
<H2 className="mt-3">Recent sync jobs</H2>
<FilteredConnection<
ExternalServiceSyncJobListFields,
Omit<ExternalServiceSyncJobNodeProps, 'node'>,
{},
ExternalServiceSyncJobConnectionFields
>
className="mb-0 mt-1"
noun="sync job"
listClassName="list-group-flush"
pluralNoun="sync jobs"
queryConnection={queryConnection}
nodeComponent={ExternalServiceSyncJobNode}
nodeComponentProps={{ onUpdate: updates }}
hideSearch={true}
noSummaryIfAllNodesVisible={true}
updates={updates}
/>
</>
<FilteredConnection<
ExternalServiceSyncJobListFields,
Omit<ExternalServiceSyncJobNodeProps, 'node'>,
{},
ExternalServiceSyncJobConnectionFields
>
className="mb-0"
noun="sync job"
listClassName="list-group-flush"
pluralNoun="sync jobs"
queryConnection={queryConnection}
nodeComponent={ExternalServiceSyncJobNode}
nodeComponentProps={{ onUpdate: updates }}
hideSearch={true}
noSummaryIfAllNodesVisible={true}
updates={updates}
/>
)
}

View File

@ -1,40 +0,0 @@
import type { Decorator, Meta, StoryFn } from '@storybook/react'
import { ExternalServiceKind } from '../../graphql-operations'
import { WebStory } from '../WebStory'
import { ExternalServiceWebhook } from './ExternalServiceWebhook'
const decorator: Decorator = story => <WebStory>{() => <div className="p-3 container">{story()}</div>}</WebStory>
const config: Meta = {
title: 'web/External services/ExternalServiceWebhook',
decorators: [decorator],
}
export default config
export const BitbucketServer: StoryFn = () => (
<ExternalServiceWebhook
externalService={{ webhookURL: 'http://test.test/webhook', kind: ExternalServiceKind.BITBUCKETSERVER }}
/>
)
export const GitHub: StoryFn = () => (
<ExternalServiceWebhook
externalService={{ webhookURL: 'http://test.test/webhook', kind: ExternalServiceKind.GITHUB }}
/>
)
export const GitLab: StoryFn = () => (
<ExternalServiceWebhook
externalService={{ webhookURL: 'http://test.test/webhook', kind: ExternalServiceKind.GITLAB }}
/>
)
GitLab.parameters = {
chromatic: {
// Visually the same as GitHub
disable: true,
},
}

View File

@ -1,102 +0,0 @@
import React from 'react'
import { Alert, Link, H3, Text, H4 } from '@sourcegraph/wildcard'
import { type ExternalServiceFields, ExternalServiceKind } from '../../graphql-operations'
import { CopyableText } from '../CopyableText'
interface Props {
externalService: Pick<ExternalServiceFields, 'kind' | 'webhookURL'>
className?: string
}
export const ExternalServiceWebhook: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
externalService: { kind, webhookURL },
className,
}) => {
if (!webhookURL) {
return <></>
}
let description = <Text />
switch (kind) {
case ExternalServiceKind.BITBUCKETSERVER: {
description = (
<Text>
<Link
to="/help/admin/code_hosts/bitbucket_server#webhooks"
target="_blank"
rel="noopener noreferrer"
>
Webhooks
</Link>{' '}
will be created automatically on the configured Bitbucket Server instance. In case you don't provide
an admin token,{' '}
<Link
to="/help/admin/code_hosts/bitbucket_server#manual-configuration"
target="_blank"
rel="noopener noreferrer"
>
follow the docs on how to set up webhooks manually
</Link>
.
<br />
To set up another webhook manually, use the following URL:
</Text>
)
break
}
case ExternalServiceKind.GITHUB: {
description = commonDescription('github')
break
}
case ExternalServiceKind.GITLAB: {
description = commonDescription('gitlab')
break
}
}
return (
<Alert variant="info" className={className}>
<H3>Batch changes webhooks</H3>
<H4>
Adding webhooks via code host connections has been{' '}
<Link
to="/help/admin/config/webhooks/incoming#deprecation-notice"
target="_blank"
rel="noopener noreferrer"
>
deprecated.
</Link>
</H4>
{description}
<CopyableText className="mb-2" text={webhookURL} size={webhookURL.length} />
<Text className="mb-0">
Note that only{' '}
<Link to="/help/batch_changes" target="_blank" rel="noopener noreferrer">
batch changes
</Link>{' '}
make use of this webhook. To enable webhooks to trigger repository updates on Sourcegraph,{' '}
<Link to="/help/admin/repo/webhooks" target="_blank" rel="noopener noreferrer">
see the docs on how to use them
</Link>
.
</Text>
</Alert>
)
}
function commonDescription(url: string): JSX.Element {
return (
<Text>
Point{' '}
<Link to={`/help/admin/code_hosts/${url}#webhooks`} target="_blank" rel="noopener noreferrer">
webhooks
</Link>{' '}
for this code host connection at the following URL:
</Text>
)
}

View File

@ -75,7 +75,6 @@ export const externalServiceFragment = gql`
username
url
}
webhookURL
hasConnectionCheck
unrestricted
}
@ -237,7 +236,6 @@ export const LIST_EXTERNAL_SERVICE_FRAGMENT = gql`
username
url
}
webhookURL
hasConnectionCheck
syncJobs(first: 1) {
...ExternalServiceSyncJobConnectionFields

View File

@ -16,6 +16,7 @@ import { CODE_INTEL_SEARCH_QUERY, LOCAL_CODE_INTEL_QUERY } from '../../codeintel
import type { SettingsGetter } from '../../codeintel/settings'
import { isDefined } from '../../codeintel/util/helpers'
import type { CodeIntelSearch2Variables } from '../../graphql-operations'
import { SearchVersion } from '../../graphql-operations'
import { syntaxHighlight } from '../../repo/blob/codemirror/highlight'
import { getBlobEditView } from '../../repo/blob/use-blob-store'
@ -367,6 +368,7 @@ async function executeSearchQuery(terms: string[]): Promise<SearchResult[]> {
query: getDocumentNode(CODE_INTEL_SEARCH_QUERY),
variables: {
query: terms.join(' '),
version: SearchVersion.V3,
},
})

View File

@ -14,6 +14,7 @@ import {
type LangStatsInsightContentResult,
type LangStatsInsightContentVariables,
SearchPatternType,
SearchVersion,
} from '../../../../../graphql-operations'
import type { CategoricalChartContent } from '../../backend/code-insights-backend-types'
@ -162,8 +163,8 @@ function quoteIfNeeded(value: string): string {
}
export const GET_LANG_STATS_GQL = gql`
query LangStatsInsightContent($query: String!) {
search(query: $query) {
query LangStatsInsightContent($query: String!, $version: SearchVersion!) {
search(query: $query, version: $version) {
results {
limitHit
}
@ -180,6 +181,7 @@ export const GET_LANG_STATS_GQL = gql`
function fetchLangStatsInsight(query: string): Observable<LangStatsInsightContentResult> {
return requestGraphQL<LangStatsInsightContentResult, LangStatsInsightContentVariables>(GET_LANG_STATS_GQL, {
query,
version: SearchVersion.V3,
}).pipe(map(dataOrThrowErrors))
}

View File

@ -10,6 +10,7 @@ import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { WebStory } from '../../../../../../components/WebStory'
import type { LangStatsInsightContentResult } from '../../../../../../graphql-operations'
import { SearchVersion } from '../../../../../../graphql-operations'
import { GET_LANG_STATS_GQL } from '../../../../core/hooks/live-preview-insight'
import { useCodeInsightsLicenseState } from '../../../../stores'
@ -31,7 +32,7 @@ export default defaultStory
const LANG_STATS_MOCK: MockedResponse<LangStatsInsightContentResult> = {
request: {
query: getDocumentNode(GET_LANG_STATS_GQL),
variables: {},
variables: { version: SearchVersion.V3 },
},
result: {
data: {

View File

@ -1,7 +1,6 @@
import { type Observable, of } from 'rxjs'
import { map } from 'rxjs/operators'
import { createAggregateError } from '@sourcegraph/common'
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
import { queryGraphQL, requestGraphQL } from '../backend/graphql'
@ -16,35 +15,9 @@ import type {
UpdateSavedSearchVariables,
Scalars,
SavedSearchFields,
ReposByQueryResult,
SavedSearchResult,
} from '../graphql-operations'
export function fetchReposByQuery(query: string): Observable<{ name: string; url: string }[]> {
return queryGraphQL<ReposByQueryResult>(
gql`
query ReposByQuery($query: String!) {
search(query: $query) {
results {
repositories {
name
url
}
}
}
}
`,
{ query }
).pipe(
map(({ data, errors }) => {
if (!data?.search?.results?.repositories) {
throw createAggregateError(errors)
}
return data.search.results.repositories
})
)
}
const savedSearchFragment = gql`
fragment SavedSearchFields on SavedSearch {
id

View File

@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GitRefType, SearchPatternType } from '@sourcegraph/shared/src/graphql-operations'
import { SearchMode, SearchQueryStateStoreProvider } from '@sourcegraph/shared/src/search'
import type { AggregateStreamingSearchResults, Skipped } from '@sourcegraph/shared/src/search/stream'
import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream'
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
@ -109,7 +110,7 @@ describe('StreamingSearchResults', () => {
expect(receivedQuery).toEqual('r:golang/oauth2 test f:travis')
expect(receivedOptions).toEqual({
version: 'V3',
version: LATEST_VERSION,
patternType: SearchPatternType.regexp,
caseSensitive: true,
searchMode: SearchMode.SmartSearch,

View File

@ -1,6 +1,7 @@
import { noop } from 'lodash'
import { describe, expect, test } from 'vitest'
import { LATEST_VERSION } from '@sourcegraph/shared/src/search/stream'
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
@ -32,7 +33,7 @@ const COMMON_PROPS: Omit<SearchResultsInfoBarProps, 'enableCodeMonitoring'> = {
sidebarCollapsed: false,
isSourcegraphDotCom: true,
options: {
version: 'V3',
version: LATEST_VERSION,
patternType: SearchPatternType.standard,
caseSensitive: false,
trace: undefined,

View File

@ -8,10 +8,10 @@ import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { EXTERNAL_SERVICES } from '../components/externalServices/backend'
import { WebStory } from '../components/WebStory'
import { WebhookExternalServiceFields } from '../graphql-operations'
import { createExternalService } from './fixtures'
import { WEBHOOK_EXTERNAL_SERVICES } from './backend'
import { SiteAdminWebhookCreatePage } from './SiteAdminWebhookCreatePage'
const decorator: Decorator = Story => <Story />
@ -27,14 +27,14 @@ export const WebhookCreatePage: StoryFn = () => {
const mocks = new WildcardMockLink([
{
request: {
query: getDocumentNode(EXTERNAL_SERVICES),
variables: { first: null, after: null, repo: null },
query: getDocumentNode(WEBHOOK_EXTERNAL_SERVICES),
variables: {},
},
result: {
data: {
externalServices: {
__typename: 'ExternalServiceConnection',
totalCount: 17,
totalCount: 6,
pageInfo: {
endCursor: null,
hasNextPage: false,
@ -57,10 +57,12 @@ export const WebhookCreatePage: StoryFn = () => {
<WebStory>
{() => (
<MockedTestProvider link={mocks}>
<SiteAdminWebhookCreatePage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
<div className="container p-4">
<SiteAdminWebhookCreatePage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
</div>
</MockedTestProvider>
)}
</WebStory>
@ -73,8 +75,8 @@ export const WebhookCreatePageWithError: StoryFn = () => {
const mockedResponse: MockedResponse[] = [
{
request: {
query: getDocumentNode(EXTERNAL_SERVICES),
variables: { first: null, after: null, repo: null },
query: getDocumentNode(WEBHOOK_EXTERNAL_SERVICES),
variables: {},
},
error: new Error('oops'),
},
@ -83,10 +85,12 @@ export const WebhookCreatePageWithError: StoryFn = () => {
<WebStory>
{() => (
<MockedTestProvider mocks={mockedResponse}>
<SiteAdminWebhookCreatePage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
<div className="container p-4">
<SiteAdminWebhookCreatePage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
</div>
</MockedTestProvider>
)}
</WebStory>
@ -94,3 +98,13 @@ export const WebhookCreatePageWithError: StoryFn = () => {
}
WebhookCreatePageWithError.storyName = 'Error during external services fetch'
function createExternalService(kind: ExternalServiceKind, url: string): WebhookExternalServiceFields {
return {
__typename: 'ExternalService',
id: `service-${url}`,
kind,
displayName: `${kind}-123`,
url,
}
}

View File

@ -4,7 +4,7 @@ import { mdiWebhook } from '@mdi/js'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Container, PageHeader } from '@sourcegraph/wildcard'
import { PageHeader } from '@sourcegraph/wildcard'
import { PageTitle } from '../components/PageTitle'
@ -22,7 +22,7 @@ export const SiteAdminWebhookCreatePage: FC<SiteAdminWebhookCreatePageProps> = (
}, [telemetryService, telemetryRecorder])
return (
<Container>
<>
<PageTitle title="Create incoming webhook" />
<PageHeader
path={[
@ -35,6 +35,6 @@ export const SiteAdminWebhookCreatePage: FC<SiteAdminWebhookCreatePageProps> = (
className="mb-3"
/>
<WebhookCreateUpdatePage />
</Container>
</>
)
}

View File

@ -54,12 +54,13 @@ export const SiteAdminWebhookPageStory: StoryFn = args => {
after: null,
onlyErrors: false,
onlyUnmatched: false,
webhookID: '',
webhookID: '1',
},
},
result: {
data: {
webhookLogs: {
__typename: 'WebhookLogConnection',
nodes: WEBHOOK_MOCK_DATA,
pageInfo: { hasNextPage: false },
totalCount: 20,
@ -82,6 +83,7 @@ export const SiteAdminWebhookPageStory: StoryFn = args => {
result: {
data: {
webhookLogs: {
__typename: 'WebhookLogConnection',
nodes: ERRORED_WEBHOOK_MOCK_DATA,
pageInfo: { hasNextPage: false },
totalCount: 20,
@ -116,10 +118,12 @@ export const SiteAdminWebhookPageStory: StoryFn = args => {
<Route
path="/site-admin/webhooks/incoming/:id"
element={
<SiteAdminWebhookPage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
<div className="container p-4">
<SiteAdminWebhookPage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
</div>
}
/>
</Routes>
@ -155,6 +159,7 @@ export const SiteAdminWebhookPageWithoutLogsStory: StoryFn = args => {
result: {
data: {
webhookLogs: {
__typename: 'WebhookLogConnection',
nodes: [],
pageInfo: { hasNextPage: false },
totalCount: 0,

View File

@ -1,10 +1,8 @@
import { type FC, useEffect, useState } from 'react'
import { type FC, useEffect, useState, useCallback } from 'react'
import { mdiWebhook, mdiDelete, mdiPencil } from '@mdi/js'
import { noop } from 'lodash'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation } from '@sourcegraph/http-client'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import {
@ -14,10 +12,12 @@ import {
H2,
H5,
Link,
LoadingSpinner,
PageHeader,
ErrorAlert,
Icon,
Alert,
Text,
Code,
} from '@sourcegraph/wildcard'
import { CreatedByAndUpdatedByInfoByline } from '../components/Byline/CreatedByAndUpdatedByInfoByline'
@ -31,9 +31,10 @@ import {
SummaryContainer,
} from '../components/FilteredConnection/ui'
import { PageTitle } from '../components/PageTitle'
import type { DeleteWebhookResult, DeleteWebhookVariables, WebhookFields } from '../graphql-operations'
import { ExternalServiceKind, type WebhookFields } from '../graphql-operations'
import { DELETE_WEBHOOK, useWebhookLogsConnection, useWebhookQuery } from './backend'
import { useWebhookLogsConnection, useWebhookQuery } from './backend'
import { WebhookConfirmDeleteModal } from './WebhookConfirmDeleteModal'
import { WebhookInfoLogPageHeader } from './WebhookInfoLogPageHeader'
import { WebhookInformation } from './WebhookInformation'
import { WebhookLogNode } from './webhooks/WebhookLogNode'
@ -49,23 +50,30 @@ export const SiteAdminWebhookPage: FC<WebhookPageProps> = props => {
const navigate = useNavigate()
const [onlyErrors, setOnlyErrors] = useState(false)
const { loading, hasNextPage, fetchMore, connection, error } = useWebhookLogsConnection(id, 20, onlyErrors)
const { loading: webhookLoading, data: webhookData } = useWebhookQuery(id)
const {
loading,
hasNextPage,
fetchMore,
connection,
error: webhookLogsError,
} = useWebhookLogsConnection(id, 20, onlyErrors)
const { loading: webhookLoading, data: webhookData, error: webhookError } = useWebhookQuery(id)
useEffect(() => {
telemetryService.logPageView('SiteAdminWebhook')
telemetryRecorder.recordEvent('admin.webhook', 'view')
}, [telemetryService, telemetryRecorder])
const [deleteWebhook, { error: deleteError, loading: isDeleting }] = useMutation<
DeleteWebhookResult,
DeleteWebhookVariables
>(DELETE_WEBHOOK, { variables: { hookID: id }, onCompleted: () => navigate('/site-admin/webhooks/incoming') })
const [showDeleteModal, setShowDeleteModal] = useState(false)
const deleteWebhook = useCallback(() => {
setShowDeleteModal(true)
}, [])
return (
<Container>
<>
<PageTitle title="Incoming webhooks" />
{webhookLoading && !webhookData && <ConnectionLoading />}
{!webhookLoading && !webhookData && webhookError && <ErrorAlert error={webhookError} />}
{webhookData?.node && webhookData.node.__typename === 'Webhook' && (
<PageHeader
path={[
@ -91,76 +99,77 @@ export const SiteAdminWebhookPage: FC<WebhookPageProps> = props => {
variant="secondary"
display="inline"
>
<Icon aria-hidden={true} svgPath={mdiPencil} />
{' Edit'}
<Icon aria-hidden={true} svgPath={mdiPencil} /> Edit
</ButtonLink>
<Button
aria-label="Delete"
className="test-delete-webhook"
variant="danger"
disabled={isDeleting}
onClick={event => {
event.preventDefault()
if (
!window.confirm(
'Delete this webhook? Any external webhooks configured to point at this endpoint will no longer be received.'
)
) {
return
}
deleteWebhook().catch(
// noop here is used because creation error is handled directly when useMutation is called
noop
)
}}
disabled={showDeleteModal}
onClick={deleteWebhook}
>
{isDeleting && <LoadingSpinner />}
<Icon aria-hidden={true} svgPath={mdiDelete} />
{' Delete'}
<Icon aria-hidden={true} svgPath={mdiDelete} /> Delete
</Button>
</div>
}
/>
)}
<Container className="mb-3">
<H2>Information</H2>
{webhookData?.node && webhookData.node.__typename === 'Webhook' && (
<WebhookInformation webhook={webhookData.node as WebhookFields} />
)}
{deleteError && <ErrorAlert className="mt-2" prefix="Error during webhook deletion" error={deleteError} />}
<H2>Logs</H2>
<WebhookInfoLogPageHeader webhookID={id} onlyErrors={onlyErrors} onSetOnlyErrors={setOnlyErrors} />
<ConnectionContainer className="mt-5">
{webhookLogsError && <ConnectionError errors={[webhookLogsError.message]} />}
{loading && !connection && <ConnectionLoading />}
<ConnectionList aria-label="WebhookLogs" className={styles.logs}>
<SiteAdminWebhookPageHeader timeLabel="Received at" />
{connection?.nodes?.map(node => (
<WebhookLogNode doNotShowExternalService={true} key={node.id} node={node} />
))}
</ConnectionList>
{connection && (
<SummaryContainer className="mt-2">
<ConnectionSummary
noSummaryIfAllNodesVisible={false}
first={connection.totalCount ?? 0}
centered={true}
connection={connection}
noun="webhook log"
pluralNoun="webhook logs"
hasNextPage={hasNextPage}
emptyElement={<EmptyList onlyErrors={onlyErrors} />}
/>
{hasNextPage && <ShowMoreButton centered={true} onClick={fetchMore} />}
</SummaryContainer>
)}
</ConnectionContainer>
</Container>
<H2>Information</H2>
{webhookData?.node && webhookData.node.__typename === 'Webhook' && (
<WebhookInformation webhook={webhookData.node as WebhookFields} />
<>
<H2>Setup instructions</H2>
<Container>
<WebhookSetupInstructions webhook={webhookData.node} />
</Container>
</>
)}
<H2>Logs</H2>
<WebhookInfoLogPageHeader webhookID={id} onlyErrors={onlyErrors} onSetOnlyErrors={setOnlyErrors} />
<ConnectionContainer className="mt-5">
{error && <ConnectionError errors={[error.message]} />}
{loading && !connection && <ConnectionLoading />}
<ConnectionList aria-label="WebhookLogs" className={styles.logs}>
<SiteAdminWebhookPageHeader timeLabel="Received at" />
{connection?.nodes?.map(node => (
<WebhookLogNode doNotShowExternalService={true} key={node.id} node={node} />
))}
</ConnectionList>
{connection && (
<SummaryContainer className="mt-2">
<ConnectionSummary
noSummaryIfAllNodesVisible={false}
first={connection.totalCount ?? 0}
centered={true}
connection={connection}
noun="webhook log"
pluralNoun="webhook logs"
hasNextPage={hasNextPage}
emptyElement={<EmptyList onlyErrors={onlyErrors} />}
/>
{hasNextPage && <ShowMoreButton centered={true} onClick={fetchMore} />}
</SummaryContainer>
)}
</ConnectionContainer>
</Container>
{showDeleteModal && webhookData?.node && webhookData.node.__typename === 'Webhook' && (
<WebhookConfirmDeleteModal
webhook={webhookData.node}
onCancel={() => setShowDeleteModal(false)}
afterDelete={() => navigate('/site-admin/webhooks/incoming')}
telemetryRecorder={telemetryRecorder}
/>
)}
</>
)
}
@ -195,3 +204,170 @@ const EmptyList: FC<{ onlyErrors: boolean }> = ({ onlyErrors }) => (
)}
</div>
)
interface WebhookSetupInstructionsProps {
webhook: WebhookFields
}
const WebhookSetupInstructions: React.FunctionComponent<WebhookSetupInstructionsProps> = ({ webhook }) => {
if (webhook.codeHostKind === ExternalServiceKind.GITHUB) {
return (
<>
<Text>
To set up a GitHub webhook, follow the instructions below, or see more in the{' '}
<Link to="/help/admin/config/webhooks/incoming#github">GitHub webooks documentation</Link>.
</Text>
<Alert variant="info">
Note: For GitHub App integrations, webhooks are created automatically. You do not need to create
them manually.
</Alert>
<Text className="mb-0">
<ol className="mb-0">
<li>
Copy the webhook URL <strong>{webhook.url}</strong>
</li>
<li>
On GitHub, go to the settings page of your organization. From there, click{' '}
<strong>Settings</strong>, then
<strong>Webhooks</strong>, then <strong>Add webhook</strong>.
</li>
<li>
Fill in the webhook form:
<ul>
<li>Payload URL: the URL you just copied above.</li>
<li>
Content type: this must be set to <strong>application/json</strong>.
</li>
<li>Secret: the secret token you can find above.</li>
<li>Active: ensure this is enabled.</li>
<li>
Which events: select <strong>Let me select individual events</strong>, and then
enable:
<table className="table ml-3">
<thead>
<tr>
<th className="px-2">Repo updates</th>
<th className="px-2">Batch Changes</th>
<th className="px-2">Repo permissions</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<ul>
<li>
<Code>push</Code>
</li>
</ul>
</td>
<td>
<ul>
<li>
<Code>Issue comments</Code>
</li>
<li>
<Code>Pull requests</Code>
</li>
<li>
<Code>Pull request reviews</Code>
</li>
<li>
<Code>Pull request review comments</Code>
</li>
<li>
<Code>Check runs</Code>
</li>
<li>
<Code>Check suites</Code>
</li>
<li>
<Code>Statuses</Code>
</li>
</ul>
</td>
<td>
<ul>
<li>
<Code>Collaborator add, remove, or changed</Code>
</li>
<li>
<Code>Memberships</Code>
</li>
<li>
<Code>Organizations</Code>
</li>
<li>
<Code>Repositories</Code>
</li>
<li>
<Code>Teams</Code>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</li>
</ul>
</li>
<li>
Click <strong>Add webhook</strong>.
</li>
<li>Confirm that the new webhook is listed.</li>
<li>You should see an initial ping event sent from GitHub in the webhook logs above.</li>
</ol>
</Text>
</>
)
}
if (webhook.codeHostKind === ExternalServiceKind.GITLAB) {
return (
<>
<Text className="mb-0">
To set up a GitLab webhook, follow the instructions in the{' '}
<Link to="/help/admin/config/webhooks/incoming#gitlab">GitLab integration documentation</Link>.
</Text>
</>
)
}
if (webhook.codeHostKind === ExternalServiceKind.BITBUCKETSERVER) {
return (
<>
<Text className="mb-0">
To set up a Bitbucket Server webhook, follow the instructions in the{' '}
<Link to="/help/admin/config/webhooks/incoming#bitbucket-server">
Bitbucket Server integration documentation
</Link>
.
</Text>
</>
)
}
if (webhook.codeHostKind === ExternalServiceKind.BITBUCKETCLOUD) {
return (
<>
<Text className="mb-0">
To set up a Bitbucket Cloud webhook, follow the instructions in the{' '}
<Link to="/help/admin/config/webhooks/incoming#bitbucket-cloud">
Bitbucket Cloud integration documentation
</Link>
.
</Text>
</>
)
}
if (webhook.codeHostKind === ExternalServiceKind.AZUREDEVOPS) {
return (
<>
<Text className="mb-0">
To set up an Azure DevOps webhook, follow the instructions in the{' '}
<Link to="/help/admin/config/webhooks/incoming#azure-devops">
Azure DevOps integration documentation
</Link>
.
</Text>
</>
)
}
return null
}

View File

@ -8,11 +8,11 @@ import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { EXTERNAL_SERVICES } from '../components/externalServices/backend'
import { WebStory } from '../components/WebStory'
import { WebhookExternalServiceFields } from '../graphql-operations'
import { WEBHOOK_BY_ID } from './backend'
import { createExternalService, createWebhookMock } from './fixtures'
import { WEBHOOK_BY_ID, WEBHOOK_EXTERNAL_SERVICES } from './backend'
import { createWebhookMock } from './fixtures'
import { SiteAdminWebhookUpdatePage } from './SiteAdminWebhookUpdatePage'
const decorator: Decorator = Story => <Story />
@ -32,14 +32,14 @@ export const WebhookUpdatePage: StoryFn = () => (
new WildcardMockLink([
{
request: {
query: getDocumentNode(EXTERNAL_SERVICES),
variables: { first: null, after: null, repo: null },
query: getDocumentNode(WEBHOOK_EXTERNAL_SERVICES),
variables: {},
},
result: {
data: {
externalServices: {
__typename: 'ExternalServiceConnection',
totalCount: 17,
totalCount: 6,
pageInfo: {
endCursor: null,
hasNextPage: false,
@ -90,10 +90,12 @@ export const WebhookUpdatePage: StoryFn = () => (
<Route
path="/site-admin/webhooks/incoming/:id"
element={
<SiteAdminWebhookUpdatePage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
<div className="container p-4">
<SiteAdminWebhookUpdatePage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
</div>
}
/>
</Routes>
@ -103,3 +105,13 @@ export const WebhookUpdatePage: StoryFn = () => (
)
WebhookUpdatePage.storyName = 'Update webhook'
function createExternalService(kind: ExternalServiceKind, url: string): WebhookExternalServiceFields {
return {
__typename: 'ExternalService',
id: `service-${url}`,
kind,
displayName: `${kind}-123`,
url,
}
}

View File

@ -5,7 +5,7 @@ import { useParams } from 'react-router-dom'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { Container, PageHeader } from '@sourcegraph/wildcard'
import { PageHeader } from '@sourcegraph/wildcard'
import { CreatedByAndUpdatedByInfoByline } from '../components/Byline/CreatedByAndUpdatedByInfoByline'
import { ConnectionLoading } from '../components/FilteredConnection/ui'
@ -30,8 +30,9 @@ export const SiteAdminWebhookUpdatePage: FC<SiteAdminWebhookUpdatePageProps> = (
const { loading, data } = useWebhookQuery(id)
const webhook = data?.node && data.node.__typename === 'Webhook' ? data.node : undefined
return (
<Container>
<>
<PageTitle title="Edit incoming webhook" />
{loading && !data && <ConnectionLoading />}
{webhook && (
@ -56,6 +57,6 @@ export const SiteAdminWebhookUpdatePage: FC<SiteAdminWebhookUpdatePageProps> = (
<WebhookCreateUpdatePage existingWebhook={webhook} />
</>
)}
</Container>
</>
)
}

View File

@ -56,8 +56,8 @@ export const NoWebhooksFound: StoryFn = () => (
webhooks: {
nodes: [],
},
webhookLogs: {
totalCount: 0,
errorsOnly: {
nodes: [],
},
},
},
@ -66,10 +66,12 @@ export const NoWebhooksFound: StoryFn = () => (
])
}
>
<SiteAdminWebhooksPage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
<div className="container p-4">
<SiteAdminWebhooksPage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
</div>
</MockedTestProvider>
)}
</WebStory>
@ -163,8 +165,8 @@ export const FiveWebhooksFound: StoryFn = () => (
},
],
},
webhookLogs: {
totalCount: 5,
errorsOnly: {
nodes: [],
},
},
},
@ -173,10 +175,12 @@ export const FiveWebhooksFound: StoryFn = () => (
])
}
>
<SiteAdminWebhooksPage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
<div className="container p-4">
<SiteAdminWebhooksPage
telemetryService={NOOP_TELEMETRY_SERVICE}
telemetryRecorder={noOpTelemetryRecorder}
/>
</div>
</MockedTestProvider>
)}
</WebStory>

View File

@ -1,6 +1,7 @@
import React, { useEffect } from 'react'
import { mdiWebhook, mdiMapSearch, mdiPlus } from '@mdi/js'
import classNames from 'classnames'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
@ -34,7 +35,7 @@ export const SiteAdminWebhooksPage: React.FunctionComponent<React.PropsWithChild
telemetryRecorder.recordEvent('admin.webhooks', 'view')
}, [telemetryService, telemetryRecorder])
const { loading, hasNextPage, fetchMore, connection, error } = useWebhooksConnection()
const { loading, hasNextPage, fetchMore, connection, refetchAll: refetchList, error } = useWebhooksConnection()
const headerTotals = useWebhookPageHeader()
return (
<div className="site-admin-webhooks-page">
@ -42,7 +43,7 @@ export const SiteAdminWebhooksPage: React.FunctionComponent<React.PropsWithChild
<PageHeader
path={[{ icon: mdiWebhook }, { to: '/site-admin/webhooks/incoming', text: 'Incoming webhooks' }]}
headingElement="h2"
description="All configured incoming webhooks"
description="Use incoming webhooks to notify Sourcegraph of code changes or changeset events."
className="mb-3"
actions={
<ButtonLink
@ -57,7 +58,7 @@ export const SiteAdminWebhooksPage: React.FunctionComponent<React.PropsWithChild
<Container>
{!headerTotals.loading && (
<div className={styles.grid}>
<div className={classNames(styles.grid, 'mb-3')}>
<PerformanceGauge
count={headerTotals.totalErrors}
countClassName={headerTotals.totalErrors > 0 ? 'text-danger' : ''}
@ -74,13 +75,13 @@ export const SiteAdminWebhooksPage: React.FunctionComponent<React.PropsWithChild
{error && <ConnectionError errors={[error.message]} />}
{loading && !connection && <ConnectionLoading />}
<ConnectionList as="ul" className="list-group" aria-label="Webhooks">
{connection?.nodes?.map(node => (
{connection?.nodes?.map((node, index) => (
<WebhookNode
key={node.id}
name={node.name}
id={node.id}
codeHostKind={node.codeHostKind}
codeHostURN={node.codeHostURN}
webhook={node}
afterDelete={refetchList}
first={index === 0}
telemetryRecorder={telemetryRecorder}
/>
))}
</ConnectionList>

View File

@ -39,6 +39,9 @@ export const SiteConfigurationChangeList: FC = () => {
>({
query: SITE_CONFIGURATION_CHANGE_CONNECTION_QUERY,
variables: {},
options: {
fetchPolicy: 'network-only',
},
getConnection: ({ data }) => data?.site?.configuration?.history || undefined,
})

View File

@ -0,0 +1,69 @@
import React, { useCallback } from 'react'
import { logger } from '@sourcegraph/common'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { Button, H3, Modal, ErrorAlert } from '@sourcegraph/wildcard'
import { LoaderButton } from '../components/LoaderButton'
import { WebhookFields } from '../graphql-operations'
import { useDeleteWebhook } from './backend'
export interface WebhookConfirmDeleteModalProps extends TelemetryV2Props {
webhook: WebhookFields
onCancel: () => void
afterDelete: () => void
}
export const WebhookConfirmDeleteModal: React.FunctionComponent<
React.PropsWithChildren<WebhookConfirmDeleteModalProps>
> = ({ webhook, onCancel, afterDelete, telemetryRecorder }) => {
const labelId = 'deleteWebhook'
const [deleteWebhook, { loading, error }] = useDeleteWebhook()
const onDelete = useCallback<React.MouseEventHandler>(
async event => {
event.preventDefault()
try {
await deleteWebhook({ variables: { id: webhook.id } })
telemetryRecorder.recordEvent('webhook', 'delete')
afterDelete()
} catch (error) {
// Non-request error. API errors will be available under `error` above.
logger.error(error)
telemetryRecorder.recordEvent('webhook', 'deleteFail')
}
},
[deleteWebhook, webhook.id, telemetryRecorder, afterDelete]
)
return (
<Modal onDismiss={onCancel} aria-labelledby={labelId}>
<H3 id={labelId}>Delete webhook {webhook.name}?</H3>
<strong className="d-block text-danger my-3">
Removing webhooks is irreversible and all incoming webhooks will be rejected.
</strong>
{error && <ErrorAlert error={error} />}
<div className="d-flex justify-content-end pt-1">
<Button disabled={loading} className="mr-2" onClick={onCancel} outline={true} variant="secondary">
Cancel
</Button>
<LoaderButton
disabled={loading}
onClick={onDelete}
variant="danger"
loading={loading}
alwaysShowLabel={true}
label="Delete webhook"
/>
</div>
</Modal>
)
}

View File

@ -1,16 +1,3 @@
.grid {
display: grid;
grid-template-columns: max-content max-content;
column-gap: 1rem;
row-gap: 0.5rem;
}
.first {
grid-column: 1 / 2;
min-width: 16rem;
}
.second {
grid-column: 2 / 3;
min-width: 16rem;
.form {
max-width: 20rem;
}

View File

@ -1,29 +1,26 @@
import React, { type FC, useCallback, useMemo, useState } from 'react'
import classNames from 'classnames'
import { parse as parseJSONC } from 'jsonc-parser'
import { noop } from 'lodash'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@sourcegraph/http-client'
import { Alert, Button, ButtonLink, ErrorAlert, Form, H2, Input, Select } from '@sourcegraph/wildcard'
import { Alert, Button, ButtonLink, Container, ErrorAlert, Form, Input, Select } from '@sourcegraph/wildcard'
import { EXTERNAL_SERVICES } from '../components/externalServices/backend'
import { defaultExternalServices } from '../components/externalServices/externalServices'
import { ConnectionLoading } from '../components/FilteredConnection/ui'
import {
type CreateWebhookResult,
type CreateWebhookVariables,
ExternalServiceKind,
type ExternalServicesResult,
type ExternalServicesVariables,
type WebhookExternalServicesResult,
type WebhookExternalServicesVariables,
type UpdateWebhookResult,
type UpdateWebhookVariables,
type WebhookFields,
} from '../graphql-operations'
import { generateSecret } from '../util/security'
import { CREATE_WEBHOOK_QUERY, UPDATE_WEBHOOK_QUERY } from './backend'
import { CREATE_WEBHOOK_QUERY, UPDATE_WEBHOOK_QUERY, WEBHOOK_EXTERNAL_SERVICES } from './backend'
import styles from './WebhookCreateUpdatePage.module.scss'
@ -57,48 +54,47 @@ export const WebhookCreateUpdatePage: FC<WebhookCreateUpdatePageProps> = ({ exis
}
const [webhook, setWebhook] = useState<Webhook>(initialWebhook)
const [kindsToUrls, setKindsToUrls] = useState<Map<ExternalServiceKind, string[]>>(new Map())
const [kindsToUrls, setKindsToUrls] = useState<Map<ExternalServiceKind, Set<string>>>(new Map())
const { loading, data, error } = useQuery<ExternalServicesResult, ExternalServicesVariables>(EXTERNAL_SERVICES, {
variables: {
first: null,
after: null,
repo: null,
},
})
const { loading, data, error } = useQuery<WebhookExternalServicesResult, WebhookExternalServicesVariables>(
WEBHOOK_EXTERNAL_SERVICES,
{}
)
useMemo(() => {
if (data?.externalServices && data?.externalServices?.__typename === 'ExternalServiceConnection') {
const kindToUrlMap = new Map<ExternalServiceKind, string[]>()
if (!data) {
return
}
for (const extSvc of data.externalServices.nodes) {
if (!supportedExternalServiceKind(extSvc.kind)) {
continue
}
const conf = parseJSONC(extSvc.config)
if (conf.url) {
kindToUrlMap.set(extSvc.kind, (kindToUrlMap.get(extSvc.kind) || []).concat([conf.url]))
}
const kindToUrlMap = new Map<ExternalServiceKind, Set<string>>()
for (const extSvc of data.externalServices.nodes) {
if (!supportedExternalServiceKind(extSvc.kind)) {
continue
}
if (!kindToUrlMap.has(extSvc.kind)) {
kindToUrlMap.set(extSvc.kind, new Set())
}
kindToUrlMap.get(extSvc.kind)!.add(extSvc.url)
}
// If there are no external services, then the warning is shown and webhook creation is blocked.
// At this point we can only have external services with existing URL which existence is enforced
// by the code host configuration schema.
if (kindToUrlMap.size !== 0) {
setKindsToUrls(kindToUrlMap)
setKindsToUrls(kindToUrlMap)
// only fill the initial values for webhook creation
if (!update) {
const [currentKind] = kindToUrlMap.keys()
const [currentUrls] = kindToUrlMap.values()
// we always generate a secret once and assign it to the webhook. Bitbucket Cloud special case
// is handled is an Input and during GraphQL query creation.
setWebhook(webhook => ({
...webhook,
secret: generateSecret(),
codeHostURN: currentUrls[0],
codeHostKind: currentKind,
}))
}
// If there are no external services, then the warning is shown and webhook creation is blocked.
// At this point we can only have external services with existing URL which existence is enforced
// by the code host configuration schema.
if (kindToUrlMap.size !== 0) {
// only fill the initial values for webhook creation
if (!update) {
const [currentKind] = kindToUrlMap.keys()
const [currentUrls] = kindToUrlMap.values()
// we always generate a secret once and assign it to the webhook. Bitbucket Cloud special case
// is handled is an Input and during GraphQL query creation.
setWebhook(webhook => ({
...webhook,
secret: generateSecret(),
codeHostURN: Array.from(currentUrls)[0],
codeHostKind: currentKind,
}))
}
}
}, [data, update])
@ -109,12 +105,12 @@ export const WebhookCreateUpdatePage: FC<WebhookCreateUpdatePageProps> = ({ exis
const selectedUrns = kindsToUrls.get(selected)
// This cannot happen, because the form is not rendered when there are no created external services
// which support webhooks (and effectively have URLs in their code host configurations).
if (!selectedUrns) {
if (!selectedUrns || selectedUrns.size === 0) {
throw new Error(
`${defaultExternalServices[selected].title} code host connection has no URL. Please check related code host configuration.`
)
}
const selectedUrn = selectedUrns[0]
const selectedUrn = Array.from(selectedUrns)[0]
setWebhook(webhook => ({
...webhook,
codeHostKind: selected,
@ -141,147 +137,145 @@ export const WebhookCreateUpdatePage: FC<WebhookCreateUpdatePageProps> = ({ exis
UpdateWebhookResult,
UpdateWebhookVariables
>(UPDATE_WEBHOOK_QUERY, {
variables: buildUpdateWebhookVariables(webhook, existingWebhook?.id),
onCompleted: data => navigate(`/site-admin/webhooks/incoming/${data.updateWebhook.id}`),
})
if (loading) {
return <ConnectionLoading />
}
if (error) {
return <ErrorAlert error={error} />
}
if (!data) {
// Should not happen.
return null
}
if (kindsToUrls.size === 0) {
return (
<Alert variant="warning" className="mt-2">
Please add a code host connection in order to create a webhook.
</Alert>
)
}
return (
<>
{error && <ErrorAlert error={error} />}
{loading && <ConnectionLoading />}
{!loading &&
!error &&
(kindsToUrls.size === 0 ? (
<Alert variant="warning" className="mt-2">
Please add a code host connection in order to create a webhook.
</Alert>
) : (
<div>
<H2>Information</H2>
<Form
onSubmit={event => {
event.preventDefault()
createWebhook({ variables: convertWebhookToCreateWebhookVariables(webhook) }).catch(
// noop here is used because creation error is handled directly when useMutation is called
noop
)
}}
>
<div className={styles.grid}>
<Input
className={classNames(styles.first, 'flex-1 mb-0')}
label={<span className="small">Webhook name</span>}
pattern="^[a-zA-Z0-9_'\-\/\.\s]+$"
required={true}
defaultValue={update ? webhook.name : ''}
onChange={event => {
onNameChange(event.target.value)
}}
maxLength={100}
/>
<Select
id="code-host-type-select"
className={classNames(styles.first, 'flex-1 mb-0')}
label={<span className="small">Code host type</span>}
required={true}
defaultValue={webhook.codeHostKind?.toString()}
onChange={onCodeHostTypeChange}
disabled={loading}
>
{Array.from(kindsToUrls.keys()).map(kind => (
<option value={kind} key={kind}>
{defaultExternalServices[kind].title}
</option>
))}
</Select>
<Select
id="code-host-urn-select"
className={classNames(styles.second, 'flex-1 mb-0')}
label={<span className="small">Code host URN</span>}
required={true}
defaultValue={webhook.codeHostURN}
onChange={onCodeHostUrnChange}
disabled={loading || !webhook.codeHostKind}
>
{webhook.codeHostKind &&
kindsToUrls.get(webhook.codeHostKind)?.map(urn => (
<option value={urn} key={urn}>
{urn}
</option>
))}
</Select>
<Input
className={classNames(styles.first, 'flex-1 mb-0')}
message={
webhook.codeHostKind && !codeHostSupportsSecrets(webhook.codeHostKind) ? (
<small>Code Host doesn't support secrets.</small>
) : (
<small>Randomly generated. Alter as required.</small>
)
}
label={<span className="small">Secret</span>}
disabled={
webhook.codeHostKind !== null && !codeHostSupportsSecrets(webhook.codeHostKind)
}
pattern="^[a-zA-Z0-9]+$"
onChange={event => {
onSecretChange(event.target.value)
}}
value={
webhook.codeHostKind && !codeHostSupportsSecrets(webhook.codeHostKind)
? ''
: webhook.secret || ''
}
maxLength={100}
/>
</div>
{update ? (
<div className="d-flex flex-shrink-0 mt-2">
<div>
<Button
onClick={event => {
event.preventDefault()
updateWebhook().catch(
// noop here is used because update error is handled directly when useMutation is called
noop
)
}}
variant="primary"
disabled={updateLoading || webhook.name.trim() === ''}
>
Update
</Button>
</div>
<div className="ml-1">
<ButtonLink
to={`/site-admin/webhooks/incoming/${existingWebhook.id}`}
variant="secondary"
>
Cancel
</ButtonLink>
</div>
</div>
<Form
onSubmit={event => {
event.preventDefault()
if (update) {
updateWebhook({
variables: buildUpdateWebhookVariables(webhook, existingWebhook?.id),
}).catch(
// noop here is used because update error is handled directly when useMutation is called
noop
)
return
}
createWebhook({ variables: convertWebhookToCreateWebhookVariables(webhook) }).catch(
// noop here is used because creation error is handled directly when useMutation is called
noop
)
}}
>
<Container className="mb-2">
<div className={styles.form}>
<Input
label="Webhook name"
pattern="^[a-zA-Z0-9_'\-\/\.\s]+$"
required={true}
defaultValue={update ? webhook.name : ''}
onChange={event => {
onNameChange(event.target.value)
}}
maxLength={100}
/>
<Select
id="code-host-type-select"
label="Code host type"
required={true}
defaultValue={webhook.codeHostKind?.toString()}
onChange={onCodeHostTypeChange}
disabled={loading}
>
{Array.from(kindsToUrls.keys()).map(kind => (
<option value={kind} key={kind}>
{defaultExternalServices[kind].title}
</option>
))}
</Select>
<Select
id="code-host-urn-select"
label="Code host URN"
required={true}
defaultValue={webhook.codeHostURN}
onChange={onCodeHostUrnChange}
disabled={loading || !webhook.codeHostKind}
>
{webhook.codeHostKind &&
kindsToUrls.has(webhook.codeHostKind) &&
Array.from(kindsToUrls.get(webhook.codeHostKind)!).map(urn => (
<option value={urn} key={urn}>
{urn}
</option>
))}
</Select>
<Input
className="mb-0"
message={
webhook.codeHostKind && !codeHostSupportsSecrets(webhook.codeHostKind) ? (
<>Code Host doesn't support secrets.</>
) : (
<Button
className="mt-2"
type="submit"
variant="primary"
disabled={creationLoading || webhook.name.trim() === ''}
>
Create
</Button>
)}
{(createWebhookError || updateWebhookError) && (
<ErrorAlert
className="mt-2"
prefix={`Error during ${createWebhookError ? 'creating' : 'updating'} of webhook`}
error={createWebhookError || updateWebhookError}
/>
)}
</Form>
</div>
))}
</>
<>Randomly generated. Alter as required.</>
)
}
label="Secret"
disabled={webhook.codeHostKind !== null && !codeHostSupportsSecrets(webhook.codeHostKind)}
// TODO: Is this pattern too prohibitive? It doesn't even allow `-`.
pattern="^[a-zA-Z0-9]+$"
onChange={event => {
onSecretChange(event.target.value)
}}
value={
webhook.codeHostKind && !codeHostSupportsSecrets(webhook.codeHostKind)
? ''
: webhook.secret || ''
}
maxLength={100}
/>
</div>
</Container>
<div className="d-flex flex-shrink-0 mb-3">
{update ? (
<>
<Button
type="submit"
variant="primary"
disabled={updateLoading || webhook.name.trim() === ''}
className="mr-1"
>
Update
</Button>
<ButtonLink to={`/site-admin/webhooks/incoming/${existingWebhook.id}`} variant="secondary">
Cancel
</ButtonLink>
</>
) : (
<Button type="submit" variant="primary" disabled={creationLoading || webhook.name.trim() === ''}>
Create
</Button>
)}
</div>
{(createWebhookError || updateWebhookError) && (
<ErrorAlert
className="mb-3"
prefix={`Failed to ${createWebhookError ? 'create' : 'update'} webhook`}
error={createWebhookError || updateWebhookError}
/>
)}
</Form>
)
}

View File

@ -11,7 +11,7 @@ import { WebStory } from '../components/WebStory'
import type { WebhookByIDLogPageHeaderResult } from '../graphql-operations'
import { WebhookInfoLogPageHeader } from './WebhookInfoLogPageHeader'
import { type SelectedExternalService, WEBHOOK_BY_ID_LOG_PAGE_HEADER } from './webhooks/backend'
import { WEBHOOK_BY_ID_LOG_PAGE_HEADER } from './webhooks/backend'
const decorator: Decorator = story => (
<Container>
@ -36,7 +36,6 @@ export default config
// WebhookInfoLogPageHeader.
const WebhookInfoLogPageHeaderContainer: React.FunctionComponent<
React.PropsWithChildren<{
initialExternalService?: SelectedExternalService
initialOnlyErrors?: boolean
}>
> = ({ initialOnlyErrors }) => {

View File

@ -29,17 +29,17 @@ function createWebhook(): WebhookFields {
name: 'webhook with name',
secret: 'secret-secret',
updatedAt: formatRFC3339(addMinutes(TIMESTAMP_MOCK, 5)),
url: 'sg.com/.api/webhooks/1aa2b42c-a14c-4aaa-b756-70c82e94d3e7',
url: 'https://sg.com/.api/webhooks/1aa2b42c-a14c-4aaa-b756-70c82e94d3e7',
uuid: '1aa2b42c-a14c-4aaa-b756-70c82e94d3e7',
codeHostKind: ExternalServiceKind.GITHUB,
codeHostURN: 'github.com/repo1',
codeHostURN: 'https://github.com/',
createdBy: {
username: 'alice',
url: 'users/alice',
},
updatedBy: {
username: 'alice',
url: 'users/alice',
username: 'bob',
url: 'users/bob',
},
}
}

View File

@ -2,7 +2,10 @@ import type { FC } from 'react'
import classNames from 'classnames'
import { Icon } from '@sourcegraph/wildcard'
import { CopyableText } from '../components/CopyableText'
import { defaultExternalServices } from '../components/externalServices/externalServices'
import type { WebhookFields } from '../graphql-operations'
import styles from './WebhookInformation.module.scss'
@ -14,12 +17,19 @@ export interface WebhookInformationProps {
export const WebhookInformation: FC<WebhookInformationProps> = props => {
const { webhook } = props
const IconComponent = defaultExternalServices[webhook.codeHostKind].icon
const codeHostKindName = defaultExternalServices[webhook.codeHostKind].defaultDisplayName
return (
<table className={classNames(styles.table, 'table')}>
<tbody>
<tr>
<th className={styles.tableHeader}>Code host</th>
<td>{webhook.codeHostKind}</td>
<td>
<Icon inline={true} as={IconComponent} aria-label="Code host logo" className="mr-1" />
{codeHostKindName}
</td>
</tr>
<tr>
<th className={styles.tableHeader}>URN</th>
@ -34,7 +44,13 @@ export const WebhookInformation: FC<WebhookInformationProps> = props => {
<tr>
<th className={styles.tableHeader}>Secret</th>
<td className={styles.contentCell}>
<CopyableText text={webhook.secret ?? ''} secret={true} />
{webhook.secret === null ? (
<span className="text-muted">
<em>No secret</em>
</span>
) : (
<CopyableText text={webhook.secret} secret={true} />
)}
</td>
</tr>
</tbody>

View File

@ -1,41 +1,65 @@
import React from 'react'
import React, { useCallback } from 'react'
import { H3, Icon, Link, Text } from '@sourcegraph/wildcard'
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { Button, ButtonLink, H3, Icon, Text } from '@sourcegraph/wildcard'
import { defaultExternalServices } from '../components/externalServices/externalServices'
import type { ExternalServiceKind } from '../graphql-operations'
import type { WebhookFields } from '../graphql-operations'
import { WebhookConfirmDeleteModal } from './WebhookConfirmDeleteModal'
import styles from './WebhookNode.module.scss'
export interface WebhookProps {
id: string
name: string
codeHostKind: ExternalServiceKind
codeHostURN: string
export interface WebhookProps extends TelemetryV2Props {
webhook: WebhookFields
first: boolean
afterDelete: () => void
}
export const WebhookNode: React.FunctionComponent<React.PropsWithChildren<WebhookProps>> = ({
id,
name,
codeHostKind,
codeHostURN,
webhook,
first,
afterDelete,
telemetryRecorder,
}) => {
const IconComponent = defaultExternalServices[codeHostKind].icon
const IconComponent = defaultExternalServices[webhook.codeHostKind].icon
const [showDeleteModal, setShowDeleteModal] = React.useState(false)
const deleteWebhook = useCallback(() => {
setShowDeleteModal(true)
}, [])
return (
<>
<span className={styles.nodeSeparator} />
<div className="pl-1">
<H3 className="pr-2">
{' '}
<Link to={`/site-admin/webhooks/incoming/${id}`}>{name}</Link>
{showDeleteModal && (
<WebhookConfirmDeleteModal
webhook={webhook}
onCancel={() => setShowDeleteModal(false)}
afterDelete={afterDelete}
telemetryRecorder={telemetryRecorder}
/>
)}
{!first && <span className={styles.nodeSeparator} />}
<div className="d-flex align-items-center justify-content-between">
<div className="pl-1">
<H3>{webhook.name}</H3>
<Text className="mb-0">
<small>
<Icon inline={true} as={IconComponent} aria-label="Code host logo" className="mr-2" />
{codeHostURN}
</small>
<Icon inline={true} as={IconComponent} aria-label="Code host logo" className="mr-1" />
{webhook.codeHostURN}
</Text>
</H3>
</div>
<div>
<ButtonLink
variant="secondary"
to={`/site-admin/webhooks/incoming/${webhook.id}`}
className="mr-2"
disabled={showDeleteModal}
>
Edit
</ButtonLink>
<Button variant="danger" onClick={deleteWebhook} disabled={showDeleteModal}>
Delete
</Button>
</div>
</div>
</>
)

View File

@ -1,10 +1,16 @@
import type { QueryResult } from '@apollo/client'
import type { MutationTuple, QueryResult } from '@apollo/client'
import { parse as parseJSONC } from 'jsonc-parser'
import { lastValueFrom, type Observable } from 'rxjs'
import { map, tap } from 'rxjs/operators'
import { resetAllMemoizationCaches } from '@sourcegraph/common'
import { createInvalidGraphQLMutationResponseError, dataOrThrowErrors, gql, useQuery } from '@sourcegraph/http-client'
import {
createInvalidGraphQLMutationResponseError,
dataOrThrowErrors,
gql,
useMutation,
useQuery,
} from '@sourcegraph/http-client'
import type { Settings } from '@sourcegraph/shared/src/settings/settings'
import { mutateGraphQL, queryGraphQL, requestGraphQL } from '../backend/graphql'
@ -57,6 +63,8 @@ import type {
WebhookPageHeaderVariables,
WebhooksListResult,
WebhooksListVariables,
DeleteWebhookResult,
DeleteWebhookVariables,
} from '../graphql-operations'
import { accessTokenFragment } from '../settings/tokens/AccessTokenNode'
@ -877,14 +885,6 @@ export const WEBHOOK_BY_ID = gql`
}
`
export const DELETE_WEBHOOK = gql`
mutation DeleteWebhook($hookID: ID!) {
deleteWebhook(id: $hookID) {
alwaysNil
}
}
`
export const WEBHOOK_PAGE_HEADER = gql`
query WebhookPageHeader {
webhooks {
@ -1083,3 +1083,32 @@ export const useGitserversConnection = (): UseShowMorePaginationResult<Gitserver
return gitservers
},
})
export const WEBHOOK_EXTERNAL_SERVICES = gql`
query WebhookExternalServices {
externalServices {
nodes {
...WebhookExternalServiceFields
}
}
}
fragment WebhookExternalServiceFields on ExternalService {
id
kind
displayName
url
}
`
const DELETE_WEBHOOK = gql`
mutation DeleteWebhook($id: ID!) {
deleteWebhook(id: $id) {
alwaysNil
}
}
`
export function useDeleteWebhook(): MutationTuple<DeleteWebhookResult, DeleteWebhookVariables> {
return useMutation<DeleteWebhookResult, DeleteWebhookVariables>(DELETE_WEBHOOK)
}

View File

@ -37,7 +37,6 @@ export function createExternalService(kind: ExternalServiceKind, url: string): L
lastReplenishment: '2021-03-15T19:39:11Z',
infinite: false,
},
webhookURL: null,
hasConnectionCheck: true,
unrestricted: false,
syncJobs: {

View File

@ -15,7 +15,7 @@ import {
} from './story/fixtures'
import { WebhookLogNode } from './WebhookLogNode'
import gridStyles from './WebhookLogPage.module.scss'
import gridStyles from '../SiteAdminWebhookPage.module.scss'
const decorator: Decorator = story => (
<Container>

View File

@ -1,13 +0,0 @@
@import 'wildcard/src/global-styles/breakpoints';
.logs {
display: grid;
grid-template-columns: [caret] min-content [status] min-content [service] minmax(min-content, 1fr) [timestamp] min-content;
align-items: center;
column-gap: 2rem;
row-gap: 1rem;
@media (--sm-breakpoint-down) {
grid-template-columns: 1fr;
}
}

View File

@ -1,157 +0,0 @@
import type { Decorator, Meta, StoryFn } from '@storybook/react'
import { addMinutes, formatRFC3339 } from 'date-fns'
import { of } from 'rxjs'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { WebStory } from '../../components/WebStory'
import type { WebhookLogFields, WebhookLogsVariables } from '../../graphql-operations'
import type { queryWebhookLogs, SelectedExternalService } from './backend'
import { BODY_JSON, BODY_PLAIN, buildHeaderMock, HEADERS_JSON, HEADERS_PLAIN } from './story/fixtures'
import { WebhookLogPage } from './WebhookLogPage'
const decorator: Decorator = story => <div className="p-3 container">{story()}</div>
const config: Meta = {
title: 'web/site-admin/webhooks/WebhookLogPage',
parameters: {
chromatic: {
viewports: [320, 576, 978, 1440],
},
},
decorators: [decorator],
argTypes: {
externalServiceCount: {
name: 'external service count',
control: { type: 'number' },
},
erroredWebhookCount: {
name: 'errored webhook count',
control: { type: 'number' },
},
},
args: {
externalServiceCount: 2,
erroredWebhookCount: 2,
},
}
export default config
const buildQueryWebhookLogs: (logs: WebhookLogFields[]) => typeof queryWebhookLogs =
logs =>
(
{ first, after }: Pick<WebhookLogsVariables, 'first' | 'after'>,
externalService: SelectedExternalService,
onlyErrors: boolean
) => {
const filtered = logs.filter(log => {
if (onlyErrors && log.statusCode < 400) {
return false
}
if (externalService === 'unmatched' && log.externalService) {
return false
}
if (
externalService !== 'all' &&
externalService !== 'unmatched' &&
externalService !== log.externalService?.displayName
) {
return false
}
return true
})
first = first ?? 20
const afterNumber = after?.length ? +after : 0
const page = filtered.slice(afterNumber, afterNumber + first)
const cursor = afterNumber + first
return of({
nodes: page,
pageInfo: {
hasNextPage: logs.length > cursor,
endCursor: cursor.toString(),
},
totalCount: logs.length,
})
}
const buildWebhookLogs = (count: number, externalServiceCount: number): WebhookLogFields[] => {
const logs: WebhookLogFields[] = []
const time = new Date(2021, 10, 8, 16, 40, 30)
for (let index = 0; index < count; index++) {
const externalServiceID = index % (externalServiceCount + 1)
const statusCode =
index % 3 === 0
? 200 + Math.floor(index / 3)
: index % 3 === 1
? 400 + Math.floor(index / 3)
: 500 + Math.floor(index / 3)
logs.push({
id: index.toString(),
receivedAt: formatRFC3339(addMinutes(time, index)),
externalService:
externalServiceID === externalServiceCount
? null
: {
displayName: `External service ${externalServiceID}`,
},
statusCode,
request: {
headers: HEADERS_JSON,
body: BODY_JSON,
method: 'POST',
url: '/my/url',
version: 'HTTP/1.1',
},
response: {
headers: HEADERS_PLAIN,
body: BODY_PLAIN,
},
})
}
return logs
}
export const NoLogs: StoryFn = args => (
<WebStory>
{props => (
<MockedTestProvider mocks={buildHeaderMock(args.externalServiceCount, args.erroredWebhookCount)}>
<WebhookLogPage {...props} queryWebhookLogs={buildQueryWebhookLogs([])} />
</MockedTestProvider>
)}
</WebStory>
)
NoLogs.storyName = 'no logs'
export const OnePageOfLogs: StoryFn = args => (
<WebStory>
{props => (
<MockedTestProvider mocks={buildHeaderMock(args.externalServiceCount, args.erroredWebhookCount)}>
<WebhookLogPage {...props} queryWebhookLogs={buildQueryWebhookLogs(buildWebhookLogs(20, 2))} />
</MockedTestProvider>
)}
</WebStory>
)
OnePageOfLogs.storyName = 'one page of logs'
export const TwoPagesOfLogs: StoryFn = args => (
<WebStory>
{props => (
<MockedTestProvider mocks={buildHeaderMock(args.externalServiceCount, args.erroredWebhookCount)}>
<WebhookLogPage {...props} queryWebhookLogs={buildQueryWebhookLogs(buildWebhookLogs(40, 2))} />
</MockedTestProvider>
)}
</WebStory>
)
TwoPagesOfLogs.storyName = 'two pages of logs'

View File

@ -1,84 +0,0 @@
import React, { useCallback, useState } from 'react'
import classNames from 'classnames'
import { Alert, Container, PageHeader, H5, Link } from '@sourcegraph/wildcard'
import { FilteredConnection, type FilteredConnectionQueryArguments } from '../../components/FilteredConnection'
import { PageTitle } from '../../components/PageTitle'
import { queryWebhookLogs as _queryWebhookLogs, type SelectedExternalService } from './backend'
import { WebhookLogNode } from './WebhookLogNode'
import { WebhookLogPageHeader } from './WebhookLogPageHeader'
import styles from './WebhookLogPage.module.scss'
export interface Props {
queryWebhookLogs?: typeof _queryWebhookLogs
webhookID?: string
}
export const WebhookLogPage: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
queryWebhookLogs = _queryWebhookLogs,
webhookID,
}) => {
const [onlyErrors, setOnlyErrors] = useState(false)
const [externalService, setExternalService] = useState<SelectedExternalService>('all')
const query = useCallback(
({ first, after }: FilteredConnectionQueryArguments) =>
queryWebhookLogs(
{
first: first ?? null,
after: after ?? null,
},
externalService,
onlyErrors,
webhookID
),
[externalService, onlyErrors, queryWebhookLogs, webhookID]
)
return (
<>
<PageTitle title="Incoming webhook logs" />
<PageHeader
headingElement="h2"
path={[{ text: 'Incoming webhook logs' }]}
description="Use these logs of received webhooks to debug integrations"
className="mb-3"
/>
<Alert variant="warning">
This webhooks page has been deprecated, please see our{' '}
<Link to="/site-admin/webhooks/incoming">new webhooks page</Link>.
</Alert>
<Container>
<WebhookLogPageHeader
onlyErrors={onlyErrors}
onSetOnlyErrors={setOnlyErrors}
externalService={externalService}
onSelectExternalService={setExternalService}
/>
<FilteredConnection
queryConnection={query}
nodeComponent={WebhookLogNode}
noun="webhook log"
pluralNoun="webhook logs"
hideSearch={true}
headComponent={Header}
listClassName={classNames('mt-3', styles.logs)}
emptyElement={<div className="m-4 w-100 text-center">No webhook logs found</div>}
/>
</Container>
</>
)
}
const Header: React.FunctionComponent<React.PropsWithChildren<{}>> = () => (
<>
<span className="d-none d-md-block" />
<H5 className="d-none d-md-block text-uppercase text-center text-nowrap">Status code</H5>
<H5 className="d-none d-md-block text-uppercase text-nowrap">External service</H5>
<H5 className="d-none d-md-block text-uppercase text-center text-nowrap">Received at</H5>
</>
)

View File

@ -41,22 +41,6 @@
}
}
.select-service {
> div {
// The .form-group container on <Select /> brings in a 1em bottom
// margin, so we need to override that.
margin-bottom: 0;
}
grid-column: 3 / 4;
align-self: end;
@media (--sm-breakpoint-down) {
grid-column: 1 / 2;
grid-row: 3 / 4;
}
}
.error-button {
grid-column: 4 / 5;
align-self: end;

View File

@ -7,7 +7,6 @@ import { Container } from '@sourcegraph/wildcard'
import { WebStory } from '../../components/WebStory'
import type { SelectedExternalService } from './backend'
import { buildHeaderMock } from './story/fixtures'
import { WebhookLogPageHeader } from './WebhookLogPageHeader'
@ -43,21 +42,12 @@ export default config
// WebhookLogPageHeader.
const WebhookLogPageHeaderContainer: React.FunctionComponent<
React.PropsWithChildren<{
initialExternalService?: SelectedExternalService
initialOnlyErrors?: boolean
}>
> = ({ initialExternalService, initialOnlyErrors }) => {
> = ({ initialOnlyErrors }) => {
const [onlyErrors, setOnlyErrors] = useState(initialOnlyErrors === true)
const [externalService, setExternalService] = useState(initialExternalService ?? 'all')
return (
<WebhookLogPageHeader
externalService={externalService}
onlyErrors={onlyErrors}
onSelectExternalService={setExternalService}
onSetOnlyErrors={setOnlyErrors}
/>
)
return <WebhookLogPageHeader onlyErrors={onlyErrors} onSetOnlyErrors={setOnlyErrors} />
}
export const AllZeroes: StoryFn = args => (
@ -141,48 +131,3 @@ OnlyErrorsTurnedOn.args = {
}
OnlyErrorsTurnedOn.storyName = 'only errors turned on'
export const SpecificExternalServiceSelected: StoryFn = args => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(args.externalServiceCount, args.erroredWebhookCount)}>
<WebhookLogPageHeaderContainer initialExternalService={args.initialExternalService.toString()} />
</MockedTestProvider>
)}
</WebStory>
)
SpecificExternalServiceSelected.argTypes = {
initialExternalService: {
control: { type: 'number', min: 0, max: 19 },
},
externalServiceCount: {},
erroredWebhookCount: {},
}
SpecificExternalServiceSelected.args = {
initialExternalService: 2,
externalServiceCount: 20,
erroredWebhookCount: 500,
}
SpecificExternalServiceSelected.storyName = 'specific external service selected'
export const UnmatchedExternalServiceSelected: StoryFn = args => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(args.externalServiceCount, args.erroredWebhookCount)}>
<WebhookLogPageHeaderContainer initialExternalService="unmatched" />
</MockedTestProvider>
)}
</WebStory>
)
UnmatchedExternalServiceSelected.argTypes = {
externalServiceCount: {},
erroredWebhookCount: {},
}
UnmatchedExternalServiceSelected.args = {
externalServiceCount: 20,
erroredWebhookCount: 500,
}
UnmatchedExternalServiceSelected.storyName = 'unmatched external service selected'

View File

@ -4,36 +4,25 @@ import { mdiAlertCircle } from '@mdi/js'
import classNames from 'classnames'
import { useQuery } from '@sourcegraph/http-client'
import { Button, Select, Icon } from '@sourcegraph/wildcard'
import { Button, Icon } from '@sourcegraph/wildcard'
import type { WebhookLogPageHeaderResult } from '../../graphql-operations'
import { type SelectedExternalService, WEBHOOK_LOG_PAGE_HEADER } from './backend'
import { WEBHOOK_LOG_PAGE_HEADER } from './backend'
import { PerformanceGauge } from './PerformanceGauge'
import styles from './WebhookLogPageHeader.module.scss'
export interface Props {
externalService: SelectedExternalService
onlyErrors: boolean
onSelectExternalService: (externalService: SelectedExternalService) => void
onSetOnlyErrors: (onlyErrors: boolean) => void
}
export const WebhookLogPageHeader: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
externalService,
onlyErrors,
onSelectExternalService: onExternalServiceSelected,
onSetOnlyErrors: onSetErrors,
}) => {
const onErrorToggle = useCallback(() => onSetErrors(!onlyErrors), [onlyErrors, onSetErrors])
const onSelect = useCallback(
(value: string) => {
onExternalServiceSelected(value)
},
[onExternalServiceSelected]
)
const { data } = useQuery<WebhookLogPageHeaderResult>(WEBHOOK_LOG_PAGE_HEADER, {})
const errorCount = data?.webhookLogs.totalCount ?? 0
@ -50,26 +39,6 @@ export const WebhookLogPageHeader: React.FunctionComponent<React.PropsWithChildr
<div className={styles.services}>
<PerformanceGauge count={data?.externalServices.totalCount} label="external service" />
</div>
<div className={styles.selectService}>
<Select
aria-label="External service"
className="mb-0"
onChange={({ target: { value } }) => onSelect(value)}
value={externalService}
>
<option key="all" value="all">
All webhooks
</option>
<option key="unmatched" value="unmatched">
Unmatched webhooks
</option>
{data?.externalServices.nodes.map(({ displayName, id }) => (
<option key={id} value={id}>
{displayName}
</option>
))}
</Select>
</div>
<div className={styles.errorButton}>
<Button variant="danger" onClick={onErrorToggle} outline={!onlyErrors}>
<Icon

View File

@ -1,21 +1,4 @@
import type { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
import { requestGraphQL } from '../../backend/graphql'
import type {
Scalars,
ServiceWebhookLogsResult,
ServiceWebhookLogsVariables,
WebhookLogConnectionFields,
WebhookLogsByWebhookIDResult,
WebhookLogsByWebhookIDVariables,
WebhookLogsResult,
WebhookLogsVariables,
} from '../../graphql-operations'
export type SelectedExternalService = 'unmatched' | 'all' | Scalars['ID']
import { gql } from '@sourcegraph/http-client'
export const WEBHOOK_LOG_REQUEST_FIELDS_FRAGMENT = gql`
fragment WebhookLogRequestFields on WebhookLogRequest {
@ -60,101 +43,6 @@ const WEBHOOK_LOG_FIELDS_FRAGMENT = gql`
}
`
const WEBHOOK_LOG_CONNECTION_FIELDS_FRAGMENT = gql`
${WEBHOOK_LOG_FIELDS_FRAGMENT}
fragment WebhookLogConnectionFields on WebhookLogConnection {
nodes {
...WebhookLogFields
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
`
export const queryWebhookLogs = (
{ first, after }: Pick<WebhookLogsVariables, 'first' | 'after'>,
externalService: SelectedExternalService,
onlyErrors: boolean,
webhookID?: string
): Observable<WebhookLogConnectionFields> => {
// If webhook ID is provided, then we search for this webhook's logs
if (webhookID) {
return requestGraphQL<WebhookLogsByWebhookIDResult, WebhookLogsByWebhookIDVariables>(WEBHOOK_LOGS_BY_ID, {
first: first ?? 20,
after: null,
onlyErrors: false,
onlyUnmatched: false,
webhookID,
}).pipe(
map(dataOrThrowErrors),
map((result: WebhookLogsResult) => result.webhookLogs)
)
}
if (externalService === 'all' || externalService === 'unmatched') {
return requestGraphQL<WebhookLogsResult, WebhookLogsVariables>(
gql`
query WebhookLogs($first: Int, $after: String, $onlyErrors: Boolean!, $onlyUnmatched: Boolean!) {
webhookLogs(
first: $first
after: $after
onlyErrors: $onlyErrors
onlyUnmatched: $onlyUnmatched
legacyOnly: true
) {
...WebhookLogConnectionFields
}
}
${WEBHOOK_LOG_CONNECTION_FIELDS_FRAGMENT}
`,
{
first,
after,
onlyErrors,
onlyUnmatched: externalService === 'unmatched',
}
).pipe(
map(dataOrThrowErrors),
map((result: WebhookLogsResult) => result.webhookLogs)
)
}
return requestGraphQL<ServiceWebhookLogsResult, ServiceWebhookLogsVariables>(
gql`
query ServiceWebhookLogs($first: Int, $after: String, $id: ID!, $onlyErrors: Boolean!) {
node(id: $id) {
... on ExternalService {
__typename
webhookLogs(first: $first, after: $after, onlyErrors: $onlyErrors) {
...WebhookLogConnectionFields
}
}
}
}
${WEBHOOK_LOG_CONNECTION_FIELDS_FRAGMENT}
`,
{
first: first ?? null,
after: after ?? null,
onlyErrors,
id: externalService,
}
).pipe(
map(dataOrThrowErrors),
map(result => {
if (result.node?.__typename === 'ExternalService') {
return result.node.webhookLogs
}
throw new Error('unexpected non ExternalService node')
})
)
}
export const WEBHOOK_LOG_PAGE_HEADER = gql`
query WebhookLogPageHeader {
externalServices {
@ -192,16 +80,20 @@ export const WEBHOOK_LOGS_BY_ID = gql`
onlyUnmatched: $onlyUnmatched
webhookID: $webhookID
) {
nodes {
...WebhookLogFields
}
pageInfo {
hasNextPage
endCursor
}
totalCount
...ListWebhookLogs
}
}
fragment ListWebhookLogs on WebhookLogConnection {
nodes {
...WebhookLogFields
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
`
export const WEBHOOK_BY_ID_LOG_PAGE_HEADER = gql`

View File

@ -34,7 +34,7 @@ func ResetPasswordURL(ctx context.Context, db database.DB, logger log.Logger, us
return &ru, nil
}
resetURL, err := backend.MakePasswordResetURL(ctx, db, user.ID)
resetURL, err := backend.MakePasswordResetURL(ctx, db, user.ID, email)
if err != nil {
msg := "failed to generate reset URL"
logger.Error(msg, log.Error(err))

View File

@ -64,7 +64,8 @@ func CountGoImporters(ctx context.Context, cli httpcli.Doer, repo api.RepoName)
q.Query = countGoImportersGraphQLQuery
q.Variables = map[string]any{
"query": countGoImportersSearchQuery(repo),
"query": countGoImportersSearchQuery(repo),
"version": "V3",
}
body, err := json.Marshal(q)
@ -144,6 +145,6 @@ func countGoImportersSearchQuery(repo api.RepoName) string {
}
const countGoImportersGraphQLQuery = `
query CountGoImporters($query: String!) {
search(query: $query) { results { matchCount } }
query CountGoImporters($query: String!, $version: SearchVersion!) {
search(query: $query, version: $version) { results { matchCount } }
}`

View File

@ -13,11 +13,11 @@ func MakeRandomHardToGuessPassword() string {
return randstring.NewLen(36)
}
var MockMakePasswordResetURL func(ctx context.Context, userID int32) (*url.URL, error)
var MockMakePasswordResetURL func(ctx context.Context, userID int32, email string) (*url.URL, error)
func MakePasswordResetURL(ctx context.Context, db database.DB, userID int32) (*url.URL, error) {
func MakePasswordResetURL(ctx context.Context, db database.DB, userID int32, email string) (*url.URL, error) {
if MockMakePasswordResetURL != nil {
return MockMakePasswordResetURL(ctx, userID)
return MockMakePasswordResetURL(ctx, userID, email)
}
resetCode, err := db.Users().RenewPasswordResetCode(ctx, userID)
if err != nil {
@ -26,5 +26,9 @@ func MakePasswordResetURL(ctx context.Context, db database.DB, userID int32) (*u
query := url.Values{}
query.Set("userID", strconv.Itoa(int(userID)))
query.Set("code", resetCode)
// This field will be used by the frontend for displaying the email on the password entry page
query.Set("email", email)
return &url.URL{Path: "/password-reset", RawQuery: query.Encode()}, nil
}

View File

@ -126,6 +126,20 @@ func (r *externalServiceResolver) Kind() string {
return r.externalService.Kind
}
func (r *externalServiceResolver) URL(ctx context.Context) (string, error) {
// 🚨 SECURITY: check whether user is site-admin
if err := auth.CheckCurrentUserIsSiteAdmin(ctx, r.db); err != nil {
return "", err
}
config, err := r.externalService.Config.Decrypt(ctx)
if err != nil {
return "", errors.Wrap(err, "decrypting external service config")
}
return extsvc.UniqueCodeHostIdentifier(r.externalService.Kind, config)
}
func (r *externalServiceResolver) DisplayName() string {
return r.externalService.DisplayName
}

View File

@ -3281,6 +3281,12 @@ type ExternalService implements Node {
"""
kind: ExternalServiceKind!
"""
The normalized URL of the external service. For external services that have no
URL, this will be an alternative unique identifier.
"""
url: String!
"""
The display name of the external service.
"""
@ -3311,11 +3317,6 @@ type ExternalService implements Node {
"""
repoCount: Int!
"""
An optional URL that will be populated when webhooks have been configured for the external service.
"""
webhookURL: String
"""
This is an optional field that's populated when we ran into errors on the
backend side when trying to create/update an ExternalService, but the
@ -3352,43 +3353,6 @@ type ExternalService implements Node {
"""
lastUpdater: User
"""
Returns recently received webhooks on this external service.
Only site admins may access this field.
DEPRECATED: Webhook logs linked directly to an external service will be removed. See https://sourcegraph.com/docs/admin/config/webhooks/incoming#deprecation-notice
"""
webhookLogs(
"""
Returns the first n webhook logs.
"""
first: Int
"""
Opaque pagination cursor.
"""
after: String
"""
Only include webhook logs that resulted in errors.
"""
onlyErrors: Boolean
"""
Only include webhook logs on or after this time.
"""
since: DateTime
"""
Only include webhook logs on or before this time.
"""
until: DateTime
): WebhookLogConnection!
@deprecated(
reason: "Webhook logs linked directly to an external service will be removed. See https://sourcegraph.com/docs/admin/config/webhooks/incoming#deprecation-notice"
)
"""
The list of recent sync jobs for this external service.
"""

View File

@ -194,10 +194,10 @@ func TestSearchResolver_DynamicFilters(t *testing.T) {
fileMatch("/foo.go"),
},
expectedDynamicFilterStrsRegexp: map[string]int{
`repo:^testRepo$`: 2,
`-file:_test\.go$`: 1,
`lang:go`: 2,
`type:path`: 2,
`repo:^testRepo$`: 2,
`-file:_test\.\w+$`: 1,
`lang:go`: 2,
`type:path`: 2,
},
},

View File

@ -81,7 +81,7 @@ func TestCreateUser(t *testing.T) {
}
func TestCreateUserResetPasswordURL(t *testing.T) {
backend.MockMakePasswordResetURL = func(_ context.Context, _ int32) (*url.URL, error) {
backend.MockMakePasswordResetURL = func(_ context.Context, _ int32, _ string) (*url.URL, error) {
return url.Parse("/reset-url?code=foobar")
}
userpasswd.MockResetPasswordEnabled = func() bool { return true }

View File

@ -92,7 +92,7 @@ func (r *schemaResolver) RandomizeUserPassword(ctx context.Context, args *struct
// This method modifies the DB, which is somewhat counterintuitive for a "value" type from an
// implementation POV. Its behavior is justified because it is convenient and intuitive from the
// POV of the API consumer.
resetURL, err := backend.MakePasswordResetURL(ctx, r.db, userID)
resetURL, err := backend.MakePasswordResetURL(ctx, r.db, userID, "")
if err != nil {
return nil, err
}

View File

@ -212,7 +212,7 @@ func TestRandomizeUserPassword(t *testing.T) {
`,
ExpectedResult: `{
"randomizeUserPassword": {
"resetPasswordURL": "http://example.com/password-reset?code=code&userID=42"
"resetPasswordURL": "http://example.com/password-reset?code=code&email=&userID=42"
}
}`,
Variables: map[string]any{"user": userIDBase64},
@ -257,7 +257,7 @@ func TestRandomizeUserPassword(t *testing.T) {
`,
ExpectedResult: `{
"randomizeUserPassword": {
"resetPasswordURL": "http://example.com/password-reset?code=code&userID=42"
"resetPasswordURL": "http://example.com/password-reset?code=code&email=&userID=42"
}
}`,
Variables: map[string]any{"user": userIDBase64},

View File

@ -232,7 +232,7 @@ func NewBatchComputeImplementer(ctx context.Context, logger log.Logger, db datab
log15.Debug("compute", "search", searchQuery)
patternType := "regexp"
job, err := gql.NewBatchSearchImplementer(ctx, logger, db, &gql.SearchArgs{Query: searchQuery, PatternType: &patternType})
job, err := gql.NewBatchSearchImplementer(ctx, logger, db, &gql.SearchArgs{Query: searchQuery, PatternType: &patternType, Version: "V3"})
if err != nil {
return nil, err
}

View File

@ -73,7 +73,7 @@ func NewComputeStream(ctx context.Context, logger log.Logger, db database.DB, se
searchClient := client.New(logger, db, gitserver.NewClient("http.compute.search"))
inputs, err := searchClient.Plan(
ctx,
"",
"V3",
&patternType,
searchQuery,
search.Precise,

View File

@ -196,6 +196,17 @@ var sg = &cli.App{
} else {
cmd.Context = secrets.WithContext(cmd.Context, secretsStore)
}
// Initialize context
cmd.Context, err = usershell.Context(cmd.Context)
if err != nil {
std.Out.WriteWarningf("Unable to infer user shell context: %s", err)
}
cmd.Context = background.Context(cmd.Context, verbose)
// We need to register the wait in the interrupt handlers, because if interrupted
// the .After on cli.App won't run. This makes sure that both the happy and sad paths
// are waiting for background tasks to finish.
interrupt.Register(func() { background.Wait(cmd.Context, std.Out) })
// Set up analytics and hooks for each command - do this as the first context
// setup
@ -213,7 +224,7 @@ var sg = &cli.App{
msg = "The problem occured while trying to get a secret via Google. Below is the error:\n"
msg += fmt.Sprintf("\n```%v```\n", err)
msg += "\nPossible fixes:\n"
msg += "- You should be in the `gcp-engineers@sourcegraph.com` group. Ask #ask-it-tech-ops or #discuss-dev-infra to check that\n"
msg += "- You should be in the `gcp-engineering@sourcegraph.com` group. Ask #ask-it-tech-ops or #discuss-dev-infra to check that\n"
msg += "- Ensure you're currently authenticated with your sourcegraph.com account by running `gcloud auth list`\n"
msg += "- Ensure you're authenticated with gcloud by running `gcloud auth application-default login`\n"
} else {
@ -228,20 +239,11 @@ var sg = &cli.App{
// Add analytics to each command
addAnalyticsHooks([]string{"sg"}, cmd.App.Commands)
// Start the analytics publisher
analytics.BackgroundEventPublisher(cmd.Context)
interrupt.Register(analytics.StopBackgroundEventPublisher)
}
// Initialize context after analytics are set up
cmd.Context, err = usershell.Context(cmd.Context)
if err != nil {
std.Out.WriteWarningf("Unable to infer user shell context: %s", err)
}
cmd.Context = background.Context(cmd.Context, verbose)
interrupt.Register(func() { background.Wait(cmd.Context, std.Out) })
// start the analytics publisher
analytics.BackgroundEventPublisher(cmd.Context)
interrupt.Register(analytics.StopBackgroundEventPublisher)
// Configure logger, for commands that use components that use loggers
if _, set := os.LookupEnv(log.EnvDevelopment); !set {
os.Setenv(log.EnvDevelopment, "true")

View File

@ -75,7 +75,7 @@ func HandleResetPasswordInit(logger log.Logger, db database.DB) http.HandlerFunc
return
}
resetURL, err := backend.MakePasswordResetURL(ctx, db, usr.ID)
resetURL, err := backend.MakePasswordResetURL(ctx, db, usr.ID, formData.Email)
if err == database.ErrPasswordResetRateLimit {
httpLogError(logger.Warn, w, "Too many password reset requests. Try again in a few minutes.", http.StatusTooManyRequests, log.Error(err))
return

View File

@ -17,7 +17,7 @@ import (
// If the primary user's email is not verified, a special version of the reset link is
// emailed that also verifies the email.
func HandleSetPasswordEmail(ctx context.Context, db database.DB, id int32, username, email string, emailVerified bool) (string, error) {
resetURL, err := backend.MakePasswordResetURL(ctx, db, id)
resetURL, err := backend.MakePasswordResetURL(ctx, db, id, email)
if err == database.ErrPasswordResetRateLimit {
return "", err
} else if err != nil {

View File

@ -22,10 +22,12 @@ func TestHandleSetPasswordEmail(t *testing.T) {
defer func() { backend.MockMakePasswordResetURL = nil }()
backend.MockMakePasswordResetURL = func(context.Context, int32) (*url.URL, error) {
backend.MockMakePasswordResetURL = func(context.Context, int32, string) (*url.URL, error) {
query := url.Values{}
query.Set("userID", "1")
query.Set("code", "foo")
query.Set("email", "a@example.com")
return &url.URL{Path: "/password-reset", RawQuery: query.Encode()}, nil
}
@ -44,7 +46,7 @@ func TestHandleSetPasswordEmail(t *testing.T) {
id: 1,
emailVerified: true,
ctx: ctx,
wantURL: "http://example.com/password-reset?code=foo&userID=1",
wantURL: "http://example.com/password-reset?code=foo&email=a%40example.com&userID=1",
wantErr: false,
email: "a@example.com",
},
@ -53,7 +55,7 @@ func TestHandleSetPasswordEmail(t *testing.T) {
id: 1,
emailVerified: false,
ctx: ctx,
wantURL: "http://example.com/password-reset?code=foo&userID=1",
wantURL: "http://example.com/password-reset?code=foo&email=a%40example.com&userID=1",
wantEmailURL: "http://example.com/password-reset?code=foo&userID=1&email=a%40example.com&emailVerifyCode=",
wantErr: false,
email: "a@example.com",

View File

@ -45,7 +45,7 @@ func (s *client) search(ctx context.Context, query, queryName string) (*metrics,
return s.doGraphQL(ctx, graphQLRequest{
QueryName: queryName,
GraphQLQuery: graphQLSearchQuery,
GraphQLVariables: map[string]string{"query": query},
GraphQLVariables: map[string]string{"query": query, "version": "V3"},
MetricsFromBody: func(body io.Reader) (*metrics, error) {
var respDec struct {
Data struct {

View File

@ -86,8 +86,8 @@ fragment SearchResultsAlertFields on SearchResults {
}
}
query ($query: String!) {
search(query: $query) {
query ($query: String!, $version: SearchVersion!) {
search(query: $query, version: $version) {
results {
results {
__typename

View File

@ -27,12 +27,12 @@ var defaultIndexers = map[string]string{
// To update, run `DOCKER_USER=... DOCKER_PASS=... ./update-shas.sh`
var defaultIndexerSHAs = map[string]string{
"sourcegraph/scip-go": "sha256:56414010d8917d6952c051dd5fcc0901fdf5c12031d352cc0b26778f040dddcc",
"sourcegraph/scip-go": "sha256:bf89e619382fed5efa21a51806d7c6f22d19b6dea458a58554c0af89120f4ca3",
"sourcegraph/scip-rust": "sha256:adf0047fc3050ba4f7be71302b42c74b49901f38fb40916d94ac5fc9181ac078",
"sourcegraph/scip-java": "sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f",
"sourcegraph/scip-java": "sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea",
"sourcegraph/scip-python": "sha256:e3c13f0cadca78098439c541d19a72c21672a3263e22aa706760d941581e068d",
"sourcegraph/scip-typescript": "sha256:3df8b36a2ad4e073415bfbeaedf38b3cfff3e697614c8f578299f470d140c2c8",
"sourcegraph/scip-ruby": "sha256:ef53e5f1450330ddb4a3edce963b7e10d900d44ff1e7de4960680289ac25f319",
"sourcegraph/scip-ruby": "sha256:75a54c6032c977ae4960c5651f6095bb017bf55b7a4b86e608c6235443c38cbb",
"sourcegraph/scip-dotnet": "sha256:1d8a590edfb3834020fceedacac6608811dd31fcba9092426140093876d8d52e",
}

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: ""
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: ""
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: ""
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: ""
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: my-module
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index
@ -11,7 +11,7 @@
- steps: []
local_steps: []
root: our-module
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: ""
indexer: sourcegraph/scip-java@sha256:a2b3828145cd38758a43363f06d786f9e620c97979a9291463c6544f7f17c68f
indexer: sourcegraph/scip-java@sha256:02b5dd2a14b3d0cd776b561ba68660676b4172677fd1fff59cb5d9a0b5351cea
indexer_args:
- scip-java
- index

View File

@ -8,7 +8,7 @@
echo "No netrc config set, continuing"
fi
root: ""
indexer: sourcegraph/scip-go@sha256:56414010d8917d6952c051dd5fcc0901fdf5c12031d352cc0b26778f040dddcc
indexer: sourcegraph/scip-go@sha256:bf89e619382fed5efa21a51806d7c6f22d19b6dea458a58554c0af89120f4ca3
indexer_args:
- GO111MODULE=off
- scip-go

View File

@ -1,6 +1,6 @@
- steps:
- root: foo/bar
image: sourcegraph/scip-go@sha256:56414010d8917d6952c051dd5fcc0901fdf5c12031d352cc0b26778f040dddcc
image: sourcegraph/scip-go@sha256:bf89e619382fed5efa21a51806d7c6f22d19b6dea458a58554c0af89120f4ca3
commands:
- |
if [ "$NETRC_DATA" ]; then
@ -19,7 +19,7 @@
echo "No netrc config set, continuing"
fi
root: foo/bar
indexer: sourcegraph/scip-go@sha256:56414010d8917d6952c051dd5fcc0901fdf5c12031d352cc0b26778f040dddcc
indexer: sourcegraph/scip-go@sha256:bf89e619382fed5efa21a51806d7c6f22d19b6dea458a58554c0af89120f4ca3
indexer_args:
- scip-go
- --no-animation
@ -33,7 +33,7 @@
- NETRC_DATA
- steps:
- root: foo/baz
image: sourcegraph/scip-go@sha256:56414010d8917d6952c051dd5fcc0901fdf5c12031d352cc0b26778f040dddcc
image: sourcegraph/scip-go@sha256:bf89e619382fed5efa21a51806d7c6f22d19b6dea458a58554c0af89120f4ca3
commands:
- |
if [ "$NETRC_DATA" ]; then
@ -52,7 +52,7 @@
echo "No netrc config set, continuing"
fi
root: foo/baz
indexer: sourcegraph/scip-go@sha256:56414010d8917d6952c051dd5fcc0901fdf5c12031d352cc0b26778f040dddcc
indexer: sourcegraph/scip-go@sha256:bf89e619382fed5efa21a51806d7c6f22d19b6dea458a58554c0af89120f4ca3
indexer_args:
- scip-go
- --no-animation

View File

@ -1,7 +1,7 @@
- steps: []
local_steps: []
root: a
indexer: sourcegraph/scip-ruby@sha256:ef53e5f1450330ddb4a3edce963b7e10d900d44ff1e7de4960680289ac25f319
indexer: sourcegraph/scip-ruby@sha256:75a54c6032c977ae4960c5651f6095bb017bf55b7a4b86e608c6235443c38cbb
indexer_args:
- scip-ruby-autoindex
outfile: index.scip
@ -9,7 +9,7 @@
- steps: []
local_steps: []
root: b
indexer: sourcegraph/scip-ruby@sha256:ef53e5f1450330ddb4a3edce963b7e10d900d44ff1e7de4960680289ac25f319
indexer: sourcegraph/scip-ruby@sha256:75a54c6032c977ae4960c5651f6095bb017bf55b7a4b86e608c6235443c38cbb
indexer_args:
- scip-ruby-autoindex
outfile: index.scip
@ -17,7 +17,7 @@
- steps: []
local_steps: []
root: c
indexer: sourcegraph/scip-ruby@sha256:ef53e5f1450330ddb4a3edce963b7e10d900d44ff1e7de4960680289ac25f319
indexer: sourcegraph/scip-ruby@sha256:75a54c6032c977ae4960c5651f6095bb017bf55b7a4b86e608c6235443c38cbb
indexer_args:
- scip-ruby-autoindex
outfile: index.scip
@ -25,7 +25,7 @@
- steps: []
local_steps: []
root: d
indexer: sourcegraph/scip-ruby@sha256:ef53e5f1450330ddb4a3edce963b7e10d900d44ff1e7de4960680289ac25f319
indexer: sourcegraph/scip-ruby@sha256:75a54c6032c977ae4960c5651f6095bb017bf55b7a4b86e608c6235443c38cbb
indexer_args:
- scip-ruby-autoindex
outfile: index.scip
@ -33,7 +33,7 @@
- steps: []
local_steps: []
root: e
indexer: sourcegraph/scip-ruby@sha256:ef53e5f1450330ddb4a3edce963b7e10d900d44ff1e7de4960680289ac25f319
indexer: sourcegraph/scip-ruby@sha256:75a54c6032c977ae4960c5651f6095bb017bf55b7a4b86e608c6235443c38cbb
indexer_args:
- scip-ruby-autoindex
outfile: index.scip

View File

@ -33,7 +33,7 @@ type insightsSearchClient struct {
func (r *insightsSearchClient) Search(ctx context.Context, query string, patternType *string, sender streaming.Sender) (*search.Alert, error) {
inputs, err := r.searchClient.Plan(
ctx,
"",
"V3",
patternType,
query,
search.Precise,

View File

@ -15,13 +15,11 @@ import (
const maxPayloadSize = 10 * 1024 * 1024 // 10mb
// TODO(stefan): Remove NewRequest in favor of NewRequestWithVersion.
// NewRequest returns an http.Request against the streaming API for query.
// NewRequest returns an http.Request against the Stream API. Use
// NewRequestWithVersion if you want to specify the version of the search syntax
// or the default patternType.
func NewRequest(baseURL string, query string) (*http.Request, error) {
// We don't set version or pattern type and rely on the defaults of the route
// handler.
return NewRequestWithVersion(baseURL, query, "", nil)
return NewRequestWithVersion(baseURL, query, "V3", nil)
}
// NewRequestWithVersion returns an http.Request against the streaming API for

View File

@ -10,6 +10,7 @@ import (
"golang.org/x/text/language"
"github.com/grafana/regexp"
"github.com/sourcegraph/sourcegraph/internal/api"
"github.com/sourcegraph/sourcegraph/internal/gitserver/gitdomain"
"github.com/sourcegraph/sourcegraph/internal/lazyregexp"
@ -35,37 +36,46 @@ var commonFileFilters = []struct {
label string
regexp *lazyregexp.Regexp
regexFilter string
globFilter string
}{
{
label: "Exclude Go tests",
regexp: lazyregexp.New(`_test\.go$`),
regexFilter: `-file:_test\.go$`,
globFilter: `-file:**_test.go`,
label: "Exclude _test.*",
regexp: lazyregexp.New(`_tests?\.\w+$`),
regexFilter: `-file:_test\.\w+$`,
},
{
label: "Exclude Go vendor",
label: "Exclude .test.*",
regexp: lazyregexp.New(`\.tests?\.\w+$`),
regexFilter: `-file:\.test\.\w+$`,
},
{
label: "Exclude Ruby tests",
regexp: lazyregexp.New(`_spec\.rb$`),
regexFilter: `-file:_spec\.rb$`,
},
{
label: "Exclude vendor",
regexp: lazyregexp.New(`(^|/)vendor/`),
regexFilter: `-file:(^|/)vendor/`,
globFilter: `-file:vendor/** -file:**/vendor/**`,
},
{
label: "Exclude third party",
regexp: lazyregexp.New(`(^|/)third[_\-]?party/`),
regexFilter: `-file:(^|/)third[_\-]?party/`,
},
{
label: "Exclude node_modules",
regexp: lazyregexp.New(`(^|/)node_modules/`),
regexFilter: `-file:(^|/)node_modules/`,
globFilter: `-file:node_modules/** -file:**/node_modules/**`,
},
{
label: "Exclude minified JavaScript",
regexp: lazyregexp.New(`\.min\.js$`),
regexFilter: `-file:\.min\.js$`,
globFilter: `-file:**.min.js`,
},
{
label: "Exclude JavaScript maps",
regexp: lazyregexp.New(`\.js\.map$`),
regexFilter: `-file:\.js\.map$`,
globFilter: `-file:**.js.map`,
},
}

Some files were not shown because too many files have changed in this diff Show More