mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 14:51:44 +00:00
Merge branch 'main' into fix/external-service-error-handling
This commit is contained in:
commit
17d11aa2bc
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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' } }],
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
40
client/web-sveltekit/src/lib/path/DisplayPath.stories.svelte
Normal file
40
client/web-sveltekit/src/lib/path/DisplayPath.stories.svelte
Normal 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>
|
||||
129
client/web-sveltekit/src/lib/path/DisplayPath.svelte
Normal file
129
client/web-sveltekit/src/lib/path/DisplayPath.svelte
Normal 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>
|
||||
@ -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>
|
||||
77
client/web-sveltekit/src/lib/path/ShrinkablePath.svelte
Normal file
77
client/web-sveltekit/src/lib/path/ShrinkablePath.svelte
Normal 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>
|
||||
28
client/web-sveltekit/src/lib/path/index.ts
Normal file
28
client/web-sveltekit/src/lib/path/index.ts
Normal 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),
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -25,6 +25,7 @@ export { TemporarySettingsStorage } from '@sourcegraph/shared/src/settings/tempo
|
||||
export {
|
||||
LATEST_VERSION,
|
||||
TELEMETRY_FILTER_TYPES,
|
||||
getRevision,
|
||||
aggregateStreamingSearch,
|
||||
emptyAggregateResults,
|
||||
getFileMatchUrl,
|
||||
|
||||
@ -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>
|
||||
|
||||
26
client/web-sveltekit/src/lib/wildcard/menu/MenuText.svelte
Normal file
26
client/web-sveltekit/src/lib/wildcard/menu/MenuText.svelte
Normal 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>
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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$/)
|
||||
})
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 })}
|
||||
/>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
69
client/web/src/site-admin/WebhookConfirmDeleteModal.tsx
Normal file
69
client/web/src/site-admin/WebhookConfirmDeleteModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
@ -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;
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 } }
|
||||
}`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user