feat: add pluggable issue adapters and workspace - #6315
Conversation
Every wire shape the issue workspace needs: listings with the same cursor and host bucketing the change-request listings use, a detail carrying the change requests that reference an issue, activity split off from the detail, and one input per write. The actor, label, cursor and project-error shapes both features spell the same way now live in `sourceControl.ts`, with the pull-request names kept as aliases so nothing that imports them has to move.
The actor avatar and label, the dotted meta line, the host-markdown body splitting, the project scope resolver and the composer hand-off merge rules are all about a host rather than about a change request, and the issue surfaces need every one of them. They move to components/sourceControl and keep their pull-request names as aliases, so no existing import site or test moves with them.
One interface per host, and a service that knows only the registry: project discovery, per-host viewer resolution, listings merged across repositories with cursors that neither repeat nor drop rows sharing an instant, stale-while-revalidate caches, and epoch invalidation. Every write is refused twice before a host is asked — once against what the host can do at all, once against what this viewer may ask for — so a request that never went past the page is refused by the host's own answer rather than by the client's claim. A repository whose tracker is switched off is reported as that one repository failing, not as a dead host: Bitbucket turns issues off per repository, and one of them must not blank the rest.
Both hosts do everything the port asks for: list, read, comment, file, rewrite, close and reopen, labels, assignees, candidates and the change requests that reference an issue. Two things they do not share are declared rather than papered over. GitHub records why an issue was closed and GitLab does not, so only GitHub offers a reason. GitHub's search counts a pull request as an issue, so every listing and search carries `is:issue` — without it a change request would arrive as an issue. Bodies and titles travel over stdin, never argv.
Both hosts reach less than the other two, and say so. Bitbucket has no labels, no candidate lists and reports no change request against an issue; a repository with the tracker switched off answers 404, which becomes that repository's own error rather than the host's. Azure DevOps has work items instead of issues: `az boards` reaches a query, one item and a field write, so listing, reading, closing and reopening are offered and nothing else is. Its query runs at the organization and never forwards a project, so the project is resolved from the checkout and named in the WIQL — `@project` would answer for every project in the organization.
One handler per issue method on the WebSocket group, sharing a single server-lifetime service so every client reads the same caches and one client's mutation invalidates them for all of them. Reads take the read scope and writes the operate scope, with refreshing counted as reading: a read-only client pressing refresh must not be told it may not look again. The environment advertises `issues`, so a client never probes a server that predates this.
Reads shell out to a host's CLI, so they are held briefly and refreshed explicitly; writes run serially per environment, because two CLI calls against one issue are order-sensitive. The right panel gains an `issue` surface carrying its reference in its id, so several issues stay open as peer tabs. The issues page's own shared panel is not persisted, for the same reason the pull requests page's is not: a restart should open the list, not last session's tabs.
The row, the filters, the ghosts and the empty states, plus the pure logic the page runs on: involvement grouping, local narrowing while a host is still answering, relevance ranking and the bounded snapshot a reload starts from instead of skeletons. A label wears the colour its host chose, with the ink picked by contrast rather than fixed — half of GitHub's own palette is pale enough that white disappears on it.
Summary and timeline beside each other, with close, reopen, comment, rename, body, labels and assignees offered only where the host and the viewer's own access agree — nothing renders a control that would fail. Handing an issue to an agent is the point of the panel. Solve, Ask, Explain and Add to composer write into a thread's composer draft, into the thread the panel is open beside where there is one and into a new one otherwise, and only ever replace their own prior contribution. Everything the issue carries is framed as untrusted data: a body on a public tracker is written by strangers.
One page listing issues across every project in the environment, with the filters in the URL, search asked of the hosts themselves, infinite scroll from the cursors they hand back, and the detail beside the list as panel tabs. An issue link an agent wrote opens in that panel rather than in a browser, matched to a project by host and repository so a lookalike domain cannot resolve to one.
An issue opens as a right-panel tab in the chat view, with its live state on the tab. Solving, asking or attaching from there writes into that thread's own composer instead of starting a new thread — reading an issue and acting on it stay one conversation.
A pull request now lists the issues it closes and the ones it only cites, and an issue lists the change requests against it. Pressing either opens the other beside it. GitHub reports both directions; GitLab reports the closing links only, and the other two report none, so their sections are absent rather than empty — an empty one would claim a change closes nothing, which a host with no notion of the link cannot know.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Reviewed the new Effect service modules under apps/server/src/issue/ (CLI/API wrappers, providers, registry, IssueService), the touched pull-request services, and the contracts/client-runtime additions against the Effect service conventions.
Service shape, layer composition, dependency acquisition (yield* Foo.Foo), namespace imports, Foo["Service"] references, and the runtime boundaries in server.ts/ws.ts all follow the conventions. Two error-modelling findings in the new Azure DevOps issue modules are noted inline.
Posted via Macroscope — Effect Service Conventions
A label was painted in the host's colour at full strength with black or white ink over it, which reads as a solid block the row has to fight. It now wears a wash of that colour with an edge a shade stronger and the name in the colour pulled towards the page's own ink, mixed in CSS so one set of numbers is right in both themes.
Every event row carried a filled avatar disc, and a marker that size masks the rail behind it — so the line read as short dashes between blobs rather than as one thread, which is not how the same rail reads on a pull request. Events now wear the issue glyph the way a pull request's lifecycle rows wear theirs, and a face is left to say what it says there: that a run of comments has people in it.
Pressing a related pull request on the issues page navigated the whole page to the pull requests list, and pressing a linked issue did the same in reverse. Following a link between the two threw away everything the reader had open to show them one row. Both pages now open the other kind in their own right panel, beside what is already there, the way a second issue or a second pull request already opens. The URL keeps naming only the kind that page reads back, so a peer tab cannot be reopened as the wrong thing.
A host reports a link only where somebody used a closing keyword or cross-referenced the issue, so a pull request whose body says "part of pingdotgg#12" showed no linked issue at all — which is what our own pingdotgg#6315 does. The title and body are now read for references too, outside code spans and fences, and each one is resolved against the host before it is shown: a number in a body is not proof an issue exists. What resolves is listed as cited, never as closing — only the host can say what merging will close — and the host's own links always win. Bounded at ten, and a failed resolve leaves the host's own links standing rather than failing the read.
Pressing New issue dropped straight into an empty box, which is not what filing one on a host is like: a repository asks for particular things, and says so through its templates. The dialog now offers what this repository offers — its templates with their descriptions, the contact links it configured, and a blank issue last where it allows one — and opens the form already filled in from whichever was chosen. A repository with nothing to offer goes straight to the blank form, as it does on the host. Read from the host rather than from the checkout, since a branch's templates are not the ones a reader is filing against. GitHub and GitLab report them; Bitbucket and Azure DevOps have none to report and declare so.
The panel already held issue tabs, but the surface chooser offered no way to open one — an issue could only arrive from a link somebody else had written. There is now an Issue card beside Pull request, opening a small picker of that project's issues with the search the list page uses. What it opens is the same surface kind as before, so solving, asking or attaching from it still writes into the thread it sits beside rather than starting a new one.
A change request with no linked issue said only that it mentions none, and an issue with no change request said the same — both true, and neither any use to somebody who knows the link exists and has not been written down. Both sections now offer to hand the question over: read the change, read the open issues, and record what actually matches the way the host records it, by editing a description to carry `Closes pingdotgg#12`. The task says to link nothing it is unsure of and that an empty answer is a valid one, and everything quoted from the host travels as untrusted data.
A repository that uses issue forms asks particular questions — a dropdown for the area, boxes to tick before submitting, one box per part of a report — and we showed one empty body box instead, which is nothing like filing the issue on the host. The templates are now read as what they are. A form's fields arrive typed and render as the controls they describe, with their own descriptions, placeholders and required marks, and the body sent is exactly the markdown the host itself would have assembled — headings, `_No response_` for an empty optional, fenced blocks where a field declares a language, and every box listed whether ticked or not. Left out deliberately: the markdown toolbar, since this repo has no toolbar primitive to reuse and hand-rolling one is a worse trade than Write and Preview tabs.
The Issue surface opened a dialog over the app and put whatever was picked in a tab of its own, which is neither a panel nor one place to read from. The panel is now the browser: the project's issues with their search, and pressing one turns that same tab into the issue, with a way back to the list. Its tab says which of the two it is showing. An issue opened from a link or from a change request still arrives as its own tab, and hand-offs still land in the thread the panel sits beside.
# Conflicts: # apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx # apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx # apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx # apps/web/src/components/pullRequest/pullRequestPresentation.tsx # apps/web/src/components/settings/settingsSearch.ts # apps/web/src/routeTree.gen.ts
This reverts commit 5b4cbf1.
| variant="outline" | ||
| className="w-full" | ||
| disabled={loadingMoreComments} | ||
| onClick={onLoadMoreComments} |
There was a problem hiding this comment.
🟡 Medium issue/IssueSummaryTab.tsx:449
Clicking Load older comments fetches comments that are prepended to detail.comments but remain outside the fixed recentComments slice, so the UI appears unchanged until the user separately clicks Show earlier comments. Expand shown when requesting an older page so the newly loaded comments are rendered immediately.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/issue/IssueSummaryTab.tsx around line 449:
Clicking `Load older comments` fetches comments that are prepended to `detail.comments` but remain outside the fixed `recentComments` slice, so the UI appears unchanged until the user separately clicks `Show earlier comments`. Expand `shown` when requesting an older page so the newly loaded comments are rendered immediately.
| onRefresh: () => void; | ||
| }) { | ||
| // Keyed by the pull request, so opening another one starts at the end of its conversation | ||
| // rather than wherever the last one had been read back to. | ||
| const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); | ||
| const aiMatches = useWorkItemMatches({ |
There was a problem hiding this comment.
🟡 Medium pullRequest/PullRequestSummaryTab.tsx:342
Switching between environments or providers with the same project, repository, PR number, and updatedAt keeps displaying the previous host's related and duplicate matches, so users can open or hand off the wrong issue. useWorkItemMatches receives environmentId and detail.provider here, but its cache identity excludes them; include both values in that identity or remount/reset the hook when they change.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx around line 342:
Switching between environments or providers with the same project, repository, PR number, and `updatedAt` keeps displaying the previous host's related and duplicate matches, so users can open or hand off the wrong issue. `useWorkItemMatches` receives `environmentId` and `detail.provider` here, but its cache identity excludes them; include both values in that identity or remount/reset the hook when they change.
| [issueEnvironmentId, selectEntry, selectingWorkItems, toggleWorkItem], | ||
| ); | ||
|
|
||
| const [creating, setCreating] = useState(false); |
There was a problem hiding this comment.
🟠 High routes/_chat.issues.tsx:1115
Users cannot open IssueCreateDialog from the Issues workspace, so the issue-creation flow is inaccessible. creating starts as false and is only passed through onOpenChange={setCreating}; no control ever calls setCreating(true). Add a create action wired to setCreating(true).
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/routes/_chat.issues.tsx around line 1115:
Users cannot open `IssueCreateDialog` from the Issues workspace, so the issue-creation flow is inaccessible. `creating` starts as `false` and is only passed through `onOpenChange={setCreating}`; no control ever calls `setCreating(true)`. Add a create action wired to `setCreating(true)`.
| const scope = | ||
| target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`; | ||
| const provider = target.provider === undefined ? "" : `${encodeURIComponent(target.provider)}:`; | ||
| return `issue:${scope}${provider}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`; |
There was a problem hiding this comment.
🟡 Medium src/rightPanelStore.ts:259
issueSurfaceId returns the same ID, issue:github:p:r:1, for targets where environmentId is "github" and where provider is "github". Because upsertSurface deduplicates by id, opening the second target reuses the first tab and displays the wrong issue/server. Encode the optional fields with distinct markers or otherwise preserve their positions so these targets cannot collide.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/rightPanelStore.ts around line 259:
`issueSurfaceId` returns the same ID, `issue:github:p:r:1`, for targets where `environmentId` is `"github"` and where `provider` is `"github"`. Because `upsertSurface` deduplicates by `id`, opening the second target reuses the first tab and displays the wrong issue/server. Encode the optional fields with distinct markers or otherwise preserve their positions so these targets cannot collide.
| @@ -584,10 +584,10 @@ const make = Effect.gen(function* () { | |||
| applyServerSettingsPatch(current, patch), | |||
| ); | |||
| const next = yield* normalizeServerSettings(nextPersisted); | |||
| const materialized = yield* materializeProviderEnvironmentSecrets(next); | |||
There was a problem hiding this comment.
🟠 High src/serverSettings.ts:587
A failed updateSettings can still change provider credentials: persistProviderEnvironmentSecrets mutates the secret store before materializeProviderEnvironmentSecrets(next) runs, so a secretStore.get failure exits without committing next while the old cached settings subsequently read the newly written secret. Materialize before persisting secrets, or make persistence and commit transactional with rollback.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 587:
A failed `updateSettings` can still change provider credentials: `persistProviderEnvironmentSecrets` mutates the secret store before `materializeProviderEnvironmentSecrets(next)` runs, so a `secretStore.get` failure exits without committing `next` while the old cached settings subsequently read the newly written secret. Materialize before persisting secrets, or make persistence and commit transactional with rollback.
There was a problem hiding this comment.
Reviewed the new Effect services in apps/server/src/issue and their call sites against the service conventions. Earlier findings (the GraphQL budget module global, the Azure detail/manufactured-cause cases, the rate-limit pass-through) are resolved at this head. Three items remain, all in the new Linear code.
Posted via Macroscope — Effect Service Conventions
| ), | ||
| ); | ||
| yield* syncLegacyBindings(connection).pipe( | ||
| Effect.catchTag("LinearApiError", () => Effect.void), |
There was a problem hiding this comment.
Effect.catchTag is not used in this codebase's conventions — statically known tagged failures should be recovered with Effect.catchTags, including when only one tag is handled.
| Effect.catchTag("LinearApiError", () => Effect.void), | |
| Effect.catchTags({ LinearApiError: () => Effect.void }), |
Posted via Macroscope — Effect Service Conventions
| export class LinearApiError extends Data.TaggedError("LinearApiError")<{ | ||
| readonly reason: "unauthenticated" | "failed"; | ||
| readonly detail: string; | ||
| readonly cause?: unknown; | ||
| }> {} |
There was a problem hiding this comment.
This service failure is a Data.TaggedError whose only payload is a free-text detail, with no message derived from structural attributes — every sibling client added in this PR (BitbucketIssueReadError, AzureDevOpsIssueReadError, GitLabIssueCli's errors) uses Schema.TaggedErrorClass with structural fields and a derived message/detail.
Consider modelling it the same way: Schema.TaggedErrorClass<LinearApiError>()("LinearApiError", { operation: Schema.String, reason: Schema.Literals(["unauthenticated", "failed"]), cause: Schema.optional(Schema.Defect()) }) with get detail()/override get message() computed from operation and reason, so the construction sites pass context rather than a prose sentence.
Posted via Macroscope — Effect Service Conventions
| new LinearApiError({ | ||
| reason: isAuthError(message) ? "unauthenticated" : "failed", | ||
| detail: message, | ||
| }), |
There was a problem hiding this comment.
detail is set to the raw first errors[].message from Linear's GraphQL body, and that string travels on unchanged into IssueProviderError.detail (LinearIssueProvider.fail) and then into IssueOperationError.message, so arbitrary host wire text becomes the caller-visible message. Consider deriving detail from stable attributes and keeping the exact response value only as cause.
| new LinearApiError({ | |
| reason: isAuthError(message) ? "unauthenticated" : "failed", | |
| detail: message, | |
| }), | |
| new LinearApiError({ | |
| reason: isAuthError(message) ? "unauthenticated" : "failed", | |
| detail: `Linear ${operation} was refused.`, | |
| cause: errors, | |
| }), |
Posted via Macroscope — Effect Service Conventions
There was a problem hiding this comment.
Three duplicated-ownership findings in the new shared source-control layer. The five items from the previous run are all addressed (topbar-scroll-fade now matches the pull requests page, ListSearchInput uses InputGroup, EntityPicker uses Input size="compact", the issue header trigger renders Button, and IssueListFilters uses LIST_MENU_TRIGGER_CLASS_NAME).
The remaining findings are the same class of problem: this PR adds a shared component for a treatment, but leaves the pull request original in place, so one treatment now has two owners that will drift.
Posted via Macroscope — UI Consistency
| export function SummarySection({ |
There was a problem hiding this comment.
SummarySection reproduces PullRequestSummaryTab's local Section heading (border-t border-border/60 pr-4, the trigger's px-4 py-3 text-sm font-medium, the rotating chevron, the count span, actions only while open), but that Section is retained, so the same section chrome has two owners.
It is also not equivalent: the pull request version pins the heading (sticky top-0 z-10 … bg-background) and anchors scrollTop on collapse via sectionCollapseAnchorScrollTop, so on the issue panel a section collapses out from under the reader and its heading scrolls away. Suggest giving SummarySection the sticky heading plus an optional collapse-anchor hook (a ref/onOpenChange the caller can use) and rendering it from PullRequestSummaryTab, so both panels get the same behaviour from one definition.
Posted via Macroscope — UI Consistency
| /** | ||
| * The search, folded to an icon until asked for. Opening moves focus into the input — the | ||
| * whole point of pressing it is to type. It stays open while it holds a query, so an active | ||
| * search is never invisible; empty and blurred, it folds back. | ||
| */ | ||
| function ExpandableSearch({ |
There was a problem hiding this comment.
ExpandableSearch is a byte-for-byte copy of the one in _chat.pull-requests.tsx apart from the aria-label, and it is behavioural rather than visual: focus-on-open, the focusToken focus/select path, the blur-folds-when-empty rule and the onFocusWithin handover. CompactFilterMenu above (the trigger's inline-flex h-7 … rounded-md px-1.5 … and the min-w-40 popup) and the Mod+F keydown handler are copied from there too.
Since this PR already centralised the list chrome under components/sourceControl/, suggest moving these there as well (e.g. ListTitlebarSearch plus the compact trigger treatment) and keeping only the labels and the provider-specific menu action at each call site — otherwise the two pages' condensed titlebars keyboard-behave differently the first time either copy is touched.
Posted via Macroscope — UI Consistency
| * One row of an issue or pull request list. An issue and a change request are read the same way — | ||
| * a glyph, a title, a line of facts under it, and what happened last on the right — so the frame is | ||
| * written once here and each list fills the slots with what its own entries carry. |
There was a problem hiding this comment.
This says the row frame is "written once here", but PullRequestRow still carries its own copy of it — same grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg px-3 py-2, same focus-visible:ring-1 focus-visible:ring-ring, same [contain-intrinsic-block-size:54px] [content-visibility:auto], same selected ? "bg-accent" : "hover:bg-accent/60", same title span and trailing column. So the two lists' row geometry, focus ring and hit area now have two independent definitions, and they have already diverged: this meta line adds overflow-hidden and the pull request one does not.
Suggest migrating PullRequestRow onto ListRow, passing its review verdict, checks popover and environment label through meta and its diff stat through trailing; the only thing missing is a slot for the context menu on #number, which is cheaper to add here than to keep a second frame.
Posted via Macroscope — UI Consistency
Adds an Issues workspace backed by provider adapters and one provider-neutral T3 issue model.
Why
Issue tracking should not be tied to source control. A project can use GitHub today and Jira or Linear later without teaching the UI a new data shape.
Architecture
Provider-specific API details stay inside adapters. The service handles project lookup, capabilities, pagination, errors, and normalized results. The UI renders only T3 issue contracts.
Current adapters:
Jira and Linear are intentionally not included. A future adapter can implement the same interface and be selected by project settings without changing issue rendering.
User-facing changes
API and rate-limit safety
Compatibility
Existing GitHub, GitLab, Bitbucket, and Azure DevOps source-control behavior remains intact. Pull-request linked-issue fields are optional on the wire for older server/client compatibility.
No issue-tracker plugin system, Jira adapter, Linear adapter, or new project setting is added here. This PR adds the smallest adapter seam needed for those later.
Verification
3e449340f.Host writes are covered at the CLI invocation and response-decoding boundaries. A maintainer credential pass is still useful for GitHub, GitLab, Bitbucket, and Azure DevOps before release.
Built with Claude Opus 5 and GPT-5.6 Codex in T3 Code.
Note
Add pluggable issue adapters, Linear integration, and issues workspace panel
IssueProviderRegistryandIssueServicewith caching and concurrency control/issuesroute with a sidebar entry, filter/sort UI, detail panel, and right-panel tab support; the pull requests page gains linked-issue tabs and issue-to-PR cross-linkingLinearConnectionDialog) with per-project team binding, legacy migration, and settings patch support forprojectBindings/projectTeamsgenerateWorkItemTaskandfindWorkItemMatchesimplemented across all text generation providers (Claude, Codex, Cursor, Grok, OpenCode) with prompt builders and a selection bar to draft agent tasksListRow,ListEmptyState,EntityPicker,SourceControlReactionBar,ConversationGroup,DetailTabStrip, etc.) consumed by both PR and issue surfacesRIGHT_PANEL_STORAGE_VERSIONbumped to 13, triggering migration of persisted panel state; invalid issue selections and shared issues panel entries are dropped silentlyMacroscope summarized 8431d60.