Skip to content

feat(seer): Create an SCM config component to streamline seer setup#110166

Merged
ryan953 merged 1 commit intomasterfrom
ryan953/seer-settings-scm
Mar 9, 2026
Merged

feat(seer): Create an SCM config component to streamline seer setup#110166
ryan953 merged 1 commit intomasterfrom
ryan953/seer-settings-scm

Conversation

@ryan953
Copy link
Member

@ryan953 ryan953 commented Mar 6, 2026

Add a new SCM Config tab under Seer settings that provides a unified
tree view for managing source code management integrations and repos.

The page displays a virtualized three-level tree (Provider → Integration
→ Repository) allowing users to browse all SCM providers and their
installed integrations, see which repos are connected, and add or remove
repos directly from the tree. Includes search filtering, provider
filtering (Seer-supported vs all), and repo status filtering (all,
connected, disconnected).

Key additions:

  • ScmIntegrationTree: virtualized tree component with expand/collapse
  • useScmIntegrationTreeData: data hook that fetches providers,
    integrations, connected repos (auto-paginated), and available repos
    per integration in parallel
  • ScmIntegrationTreeRow: renders provider, integration, repo, and
    empty-state rows with permission checks and confirmation dialogs
  • buildIntegrationTreeNodes: pure function to flatten the hierarchy
    into a filtered, virtualized list
  • New route at /settings/:org/seer/scm/ with tab navigation
  • Minor improvement to repositoryRow delete confirmation to include
    the repo name

Seer SCM Config — Source Code Manager Integration Tree

2026-03-09T18:57:23Z by Showboat 0.6.1

What this PR does

This branch adds a new Seer SCM Config settings page that lets organization admins connect their source code repositories to Seer (Sentry's AI engine).

Seer needs read access to source code to:

  • Perform code review
  • Analyze and fix issues with full context

Before this change, there was no dedicated UI to manage which repos are available to Seer. This PR introduces a filterable, virtualized tree that shows all SCM providers, their installed integrations, and the repos available under each — with one-click connect/disconnect toggles.

Files changed

File Purpose
scm.tsx New settings page with search + filter controls
scmIntegrationTree.tsx Virtualized tree component (react-virtual)
scmIntegrationTreeRow.tsx Row renderer for provider / integration / repo / empty-state nodes
scmIntegrationTreeNodes.ts Pure function that builds the flat node list from fetched data
useScmIntegrationTreeData.ts Data hook — fetches providers, integrations, connected repos, available repos in parallel
types.ts Discriminated union types for each node kind in the tree
settingsPageTabs.tsx Adds the "SCM" tab to the Seer settings sidebar
routes.tsx Registers the new /settings/:orgId/seer/scm/ route
repositoryRow.tsx Minor tweak (uses the shared addRepository action)

Architecture

Data fetching (useScmIntegrationTreeData)

Four parallel queries, all resolved before the tree renders:

  1. Providers/organizations/:org/config/integrations/ filtered to providers whose featureGate includes commits (i.e. SCM providers).
  2. Integrations/organizations/:org/integrations/ filtered to the SCM providers above.
  3. Connected repos/organizations/:org/repos/ fetched with auto-pagination (infinite query that chases hasNextPage).
  4. Available repos per integration/organizations/:org/integrations/:id/repos/ fired in parallel for each installed integration via useQueries.

The hook returns a stable connectedIdentifiers Set (keyed on repo.name, not externalSlug, because GitLab uses a numeric project ID as the slug which never matches the identifier field on IntegrationRepository).

Node model (types.ts + scmIntegrationTreeNodes.ts)

A flat array of TreeNode objects (a discriminated union) is built from the fetched data. The tree looks like:

ProviderNode  (GitHub)
  IntegrationNode  (my-org / GitHub)
    RepoNode  (my-org/frontend)
    RepoNode  (my-org/backend)
    NoMatchNode  (shown when filters leave 0 repos)
ProviderNode  (GitLab)
  ...

buildIntegrationTreeNodes applies three filters before emitting RepoNodes:

  • provider filterseer-supported (default) hides providers not in a supported allowlist; all shows everything
  • repo filterconnected / not-connected / all
  • search — case-insensitive substring match on repo.identifier

Virtualisation

The flat node array is passed to @tanstack/react-virtual's useVirtualizer so that only the visible rows are in the DOM. Each row is a fixed 56px height.

Row toggle logic

Connecting a repo calls addRepository (existing action creator), disconnecting calls hideRepository. Both optimistically update the connectedIdentifiers set in the react-query cache to give immediate feedback. The checkbox is disabled while the mutation is in-flight (togglingRepos state).

git diff master...HEAD --stat
 static/app/components/repositoryRow.tsx            |   3 +-
 static/app/router/routes.tsx                       |   5 +
 .../scmIntegrationTree/scmIntegrationTree.tsx      | 308 ++++++++++++++++++
 .../scmIntegrationTree/scmIntegrationTreeNodes.ts  | 116 +++++++
 .../scmIntegrationTree/scmIntegrationTreeRow.tsx   | 360 +++++++++++++++++++++
 .../useScmIntegrationTreeData.ts                   | 145 +++++++++
 .../seerAutomation/components/settingsPageTabs.tsx |   1 +
 static/gsApp/views/seerAutomation/scm.tsx          |  91 ++++++
 static/gsApp/views/seerAutomation/types.ts         |  55 ++++
 9 files changed, 1083 insertions(+), 1 deletion(-)
git diff master...HEAD -- static/gsApp/views/seerAutomation/types.ts
diff --git static/gsApp/views/seerAutomation/types.ts static/gsApp/views/seerAutomation/types.ts
new file mode 100644
index 00000000000..95a229168e4
--- /dev/null
+++ static/gsApp/views/seerAutomation/types.ts
@@ -0,0 +1,55 @@
+import type {
+  Integration,
+  IntegrationProvider,
+  IntegrationRepository,
+} from 'sentry/types/integrations';
+
+export type ProviderNode = {
+  integrationCount: number;
+  isExpanded: boolean;
+  provider: IntegrationProvider;
+  type: 'provider';
+};
+
+export type IntegrationNode = {
+  connectedRepoCount: number;
+  integration: Integration;
+  isExpanded: boolean;
+  isReposPending: boolean;
+  repoCount: number;
+  type: 'integration';
+};
+
+export type RepoNode = {
+  integration: Integration;
+  isConnected: boolean;
+  isToggling: boolean;
+  repo: IntegrationRepository;
+  type: 'repo';
+};
+
+export type NoMatchNode = {
+  integrationId: string;
+  repoFilter: RepoFilter;
+  search: string;
+  type: 'no-match';
+};
+
+export type TreeNode =
+  | ProviderNode
+  | IntegrationNode
+  | RepoNode
+  | NoMatchNode;
+
+// ---------------------------------------------------------------------------
+// Builder
+// ---------------------------------------------------------------------------
+
+export type RepoFilter = 'all' | 'connected' | 'not-connected';
+export type ProviderFilter = 'seer-supported' | 'all';

Key design decisions

1. Flat list + virtual scroll over recursive tree

A nested tree renders every node in the DOM, which breaks down with hundreds of repos per integration. The flat TreeNode[] array lets useVirtualizer only mount visible rows. Expand/collapse just re-runs buildIntegrationTreeNodes, which is O(n) and synchronous.

2. repo.name (not externalSlug) for connection matching

GitLab's externalSlug is a numeric project ID (e.g. 12345) while IntegrationRepository.identifier is always owner/repo. Using repo.name gives a consistent owner/repo key across GitHub, GitLab, and Bitbucket.

3. seer-supported provider filter as default

The default view hides obscure or legacy SCM providers that Seer doesn't actually use. Users can switch to All providers if they need to manage a non-standard integration.

4. URL-persisted filters via nuqs

Search term, provider filter, and repo filter are stored in the URL query string using nuqs (parseAsString, parseAsStringLiteral). This means filter state survives page refresh and can be linked.

5. Optimistic cache update on connect/disconnect

After calling addRepository / hideRepository, the tree immediately reflects the new connected state by mutating the react-query cache for the repos list. The toggle button is disabled while in-flight to prevent double-clicks.

Reviewer checklist

  • Data fetching: useScmIntegrationTreeData makes 4 parallel requests on mount — check network tab for any waterfall
  • Filter persistence: navigate to the page, set filters, refresh — filters should be restored from URL params
  • Connect a repo: click the toggle on an unconnected repo → success toast + row updates instantly
  • Disconnect a repo: click the toggle on a connected repo → confirm dialog → row updates
  • Search: type in the search box → repos filter in real time across all providers/integrations
  • No matches: with a filter active that returns 0 repos, verify the NoMatchNode message renders per-integration
  • Add config: for a provider with no installed integration, verify the "Install" CTA renders
  • Access control: non-admin members should see repos but the connect toggle should be absent/disabled

Known limitations

  • Repo list per integration is not paginated from the Seer SCM page (the /repos/ endpoint returns all repos for the integration, which could be large for orgs with many repos in a single integration)
  • The seer-supported allowlist is maintained as a frontend constant — new providers require a code change

SCR-20260308-lpep

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Mar 6, 2026
@ryan953 ryan953 force-pushed the ryan953/seer-settings-scm branch from 2e9ed29 to d1feb32 Compare March 8, 2026 19:37
@ryan953 ryan953 force-pushed the ryan953/seer-settings-scm branch from d1feb32 to 7562bc0 Compare March 8, 2026 19:55
@ryan953 ryan953 marked this pull request as ready for review March 8, 2026 19:58
@ryan953 ryan953 requested a review from a team as a code owner March 8, 2026 19:58
Add a new SCM Config tab under Seer settings that provides a unified
tree view for managing source code management integrations and repos.

The page displays a virtualized three-level tree (Provider → Integration
→ Repository) allowing users to browse all SCM providers and their
installed integrations, see which repos are connected, and add or remove
repos directly from the tree. Includes search filtering, provider
filtering (Seer-supported vs all), and repo status filtering (all,
connected, disconnected).

Key additions:
- ScmIntegrationTree: virtualized tree component with expand/collapse
- useScmIntegrationTreeData: data hook that fetches providers,
  integrations, connected repos (auto-paginated), and available repos
  per integration in parallel
- ScmIntegrationTreeRow: renders provider, integration, repo, and
  empty-state rows with permission checks and confirmation dialogs
- buildIntegrationTreeNodes: pure function to flatten the hierarchy
  into a filtered, virtualized list
- New route at /settings/:org/seer/scm/ with tab navigation
- Minor improvement to repositoryRow delete confirmation to include
  the repo name
return (
<Flex align="center" gap="sm">
<Text size="sm" variant="muted">
{messages[messageIndex]}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The loading message disappears after 30 seconds because the messageIndex goes out of bounds of the messages array.
Severity: MEDIUM

Suggested Fix

Use the modulo operator (%) to wrap the messageIndex around the length of the messages array. This will ensure the index always stays within valid bounds and the messages cycle indefinitely. For example: setMessageIndex(prev => (prev + 1) % messages.length).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
static/gsApp/views/seerAutomation/components/scmIntegrationTree/scmIntegrationTreeRow.tsx#L333

Potential issue: The `LoadingReposMessage` component uses a `useTimeout` hook to cycle
through an array of 6 messages. The timeout reschedules itself indefinitely,
incrementing the `messageIndex` every 5 seconds. After 30 seconds, the index becomes 6,
which is out of the array's bounds. This causes `messages[messageIndex]` to evaluate to
`undefined`. React renders this as an empty string, making the loading message disappear
from the UI, even though the data is still loading. This is likely to happen for
organizations with many repositories, where loading can exceed 30 seconds.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably unlikely it takes 30 seconds to load but you never know

icon={<IconAdd />}
disabled={!canAccess || node.isToggling}
onClick={() => onToggleRepo(node.repo, node.integration, false)}
aria-label={t('Add %s', <code>{node.repo.name}</code>)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The aria-label for the 'Add' button incorrectly receives a React element, causing it to render as "[object Object]" for screen readers.
Severity: MEDIUM

Suggested Fix

Pass the repository name as a string directly to the translation function instead of wrapping it in a <code> element. The aria-label should be t('Add %s', node.repo.name).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
static/gsApp/views/seerAutomation/components/scmIntegrationTree/scmIntegrationTreeRow.tsx#L299

Potential issue: The `aria-label` for the 'Add' button is constructed using `t('Add %s',
`{node.repo.name}`)`. The `t()` function's second argument is a React element
(`<code>`). When a React element is passed to a prop that expects a string, like
`aria-label`, React serializes it to the string `"[object Object]"`. This results in a
non-descriptive label for screen reader users, harming accessibility.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think probably legit?

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

const {start} = useTimeout({
timeMs: 5000,
onTimeout: () => {
setMessageIndex(prev => prev + 1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading message index exceeds array bounds after timeout

Medium Severity

In LoadingReposMessage, messageIndex increments unboundedly every 5 seconds via setMessageIndex(prev => prev + 1), but the messages array only has 6 elements (indices 0–5). After 30 seconds, messages[6] is undefined, causing the loading text to silently disappear while the spinner remains. The index needs to be clamped or wrapped (e.g., modulo the array length).

Additional Locations (1)

Fix in Cursor Fix in Web

icon={<IconAdd />}
disabled={!canAccess || node.isToggling}
onClick={() => onToggleRepo(node.repo, node.integration, false)}
aria-label={t('Add %s', <code>{node.repo.name}</code>)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSX in aria-label produces garbled accessibility text

Medium Severity

The aria-label at line 299 passes a JSX <code> element to t('Add %s', ...). The t() function returns a React node array when given React elements, but aria-label expects a string. This results in [object Object] as the accessibility label. Compare with line 283, which correctly passes the plain string node.repo.name.

Fix in Cursor Fix in Web

@ryan953 ryan953 requested a review from billyvg March 9, 2026 16:17
message={t(
'Are you sure you want to remove this repository? All associated commit data will be removed in addition to the repository.'
'Are you sure you want to remove %s? All associated commit data will be removed in addition to the repository.',
<code>{repository.name}</code>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work or do we need tct?


if (scmProviders.length === 0) {
return (
<Flex direction="column" align="center" gap="md" padding="xl" minHeight={200}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minHeight as prop here vs as inline style above for isPending and isError

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bots being bots

);
addSuccessMessage(t('Removed %s', repo.name));
} else {
const newRepo = await addRepository(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need error handling?

icon={<IconAdd />}
disabled={!canAccess || node.isToggling}
onClick={() => onToggleRepo(node.repo, node.integration, false)}
aria-label={t('Add %s', <code>{node.repo.name}</code>)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think probably legit?

return (
<Flex align="center" gap="sm">
<Text size="sm" variant="muted">
{messages[messageIndex]}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably unlikely it takes 30 seconds to load but you never know

}
}

// nodes.push({type: 'add-config', provider});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extra

@ryan953 ryan953 merged commit 2e6cdcf into master Mar 9, 2026
66 checks passed
@ryan953 ryan953 deleted the ryan953/seer-settings-scm branch March 9, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants