Files
openchamber/packages/ui/src/lib/forge/provider.ts
T
bot-hermes 3800c84948 feat(ui): forge user lookup — assignee combobox, @-mentions, repo-scoped user search
Repo-scoped assignable-user search for GitHub, GitLab, and Gitea, surfaced as
an assignee combobox in the metadata editor and @-mention autocomplete in
forge comment/reply/review surfaces.

- server: GET /api/{provider}/users/search (assignees / project members),
  query + directory/override repo resolution, 429 -> 503, connected:false
  degradation; GitLab assignee writes resolve login -> ID server-side
- wire: searchUsers (+ searchLabels/milestones/branches/tags) on the three
  API clients with tests
- facade: userSearch capability (all three), searchUsers adapters,
  mapGithubAssignee/mapGitlabMember/mapGiteaAssignee -> ForgeUser
- ui: ForgeLookupCombobox (keyboard nav, debounced 30s-TTL cache,
  connected-only caching), ForgeMentionTextarea (@ token parsing, caret
  restore), free-text fallback when lookup is unavailable; i18n in 12 locales
- extras sharing the same infrastructure: GitLab create-issue dialog and
  label/milestone/branch/tag lookups in the metadata editor
2026-08-16 16:29:25 +00:00

431 lines
16 KiB
TypeScript

import type {
ForgeChecksSummary,
ForgeComment,
ForgeCommit,
ForgeFileChange,
ForgeIssue,
ForgeLabel,
ForgeMilestone,
ForgeProviderCapabilities,
ForgeProviderKind,
ForgePullRequest,
ForgeRepoRef,
ForgeReview,
ForgeTimelineEvent,
ForgeUser,
} from './types';
/**
* Provider-agnostic git-forge facade.
*
* Adapters implement this interface against the per-provider wire APIs declared
* in `@/lib/api/types.ts` and normalize the results onto the shapes in
* `./types.ts`. Consumers (issue/PR views) depend only on this interface plus
* the capability flags, never on a specific provider's API.
*
* Result envelopes mirror the existing wire envelopes (`{ connected, repo, ... }`)
* from `@/lib/api/types.ts`, so `connected: false` keeps the same meaning: the
* forge was not reachable/authenticated and no authoritative data was fetched.
* Callers must never treat a `connected: false` envelope as an empty success.
*/
/** Paginated pull request list result (see `GitHubPullRequestsListResult` / `GitLabMergeRequestsListResult`). */
export interface ForgePullRequestsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
prs: ForgePullRequest[];
page: number;
hasMore: boolean;
}
/**
* Full context for one pull request: comments, review comments, files, diff,
* and check summary. Roughly the union of `GitHubPullRequestContextResult`
* (comments + review comments + files + diff + checkDetails), the GitLab
* merge-request context (comments + files + diff), and the Gitea PR context
* (comments + files + diff).
*/
export interface ForgePullRequestContext {
connected: boolean;
repo?: ForgeRepoRef | null;
pr?: ForgePullRequest | null;
issueComments: ForgeComment[];
reviewComments: ForgeComment[];
files: ForgeFileChange[];
diff?: string;
checks?: ForgeChecksSummary | null;
/** Timestamp (epoch ms) the context was fetched at; adapters may set it for staleness. */
fetchedAt?: number;
}
/** Paginated issue list result (see `GitHubIssuesListResult` / `GitLabIssuesListResult`). */
export interface ForgeIssuesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
issues: ForgeIssue[];
page: number;
hasMore: boolean;
}
/** Issue detail plus its comments (see `GitHubIssueGetResult` + `issueComments`, `GitLabIssueGetResult`). */
export interface ForgeIssueDetail {
connected: boolean;
repo?: ForgeRepoRef | null;
issue?: ForgeIssue | null;
comments: ForgeComment[];
/**
* Set when the issue loaded but its comments failed to fetch — never
* masquerade as authoritative empty. null when comments fetched cleanly or
* were never attempted (e.g. the whole fetch failed).
*/
commentsError?: string | null;
}
/** Commits on a PR/MR (see `github prCommits`, `gitlab mrCommits`, `gitea prCommits`). */
export interface ForgeCommitsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
commits: ForgeCommit[];
/** Set when the wire call failed after resolving a repo — never a valid empty success. */
error?: string | null;
}
/** Activity timeline for a PR/MR (see `github prTimeline`, `gitlab mrTimeline`, gitea reviews). */
export interface ForgeTimelineResult {
connected: boolean;
repo?: ForgeRepoRef | null;
events: ForgeTimelineEvent[];
error?: string | null;
}
/**
* Repo-scoped user lookup for mentions/assignees. Wraps the per-provider
* assignable-user endpoints (github `issues.listAssignees`, gitlab project
* members, gitea repo assignees). `connected: false` means the lookup failed —
* never treat it as an authoritative empty list.
*/
export interface ForgeUsersResult {
connected: boolean;
repo?: ForgeRepoRef | null;
users: ForgeUser[];
error?: string | null;
}
/** Repo-scoped label lookup for pickers. */
export interface ForgeLabelsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
labels: ForgeLabel[];
error?: string | null;
}
/** Repo-scoped milestone lookup for pickers. */
export interface ForgeMilestonesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
milestones: ForgeMilestone[];
error?: string | null;
}
/** Repo-scoped branch lookup. */
export interface ForgeBranchesResult {
connected: boolean;
repo?: ForgeRepoRef | null;
branches: string[];
error?: string | null;
}
/** Repo-scoped tag lookup. */
export interface ForgeTagsResult {
connected: boolean;
repo?: ForgeRepoRef | null;
tags: string[];
error?: string | null;
}
/** Rolled-up checks for a PR/MR; only non-null for providers with a dedicated checks surface. */
export interface ForgeChecksResult {
connected: boolean;
repo?: ForgeRepoRef | null;
checks: ForgeChecksSummary | null;
error?: string | null;
}
// --- Write operations ---
/** Open/closed state a write may set on an issue or PR ('merged' is not writable). */
export type ForgeWriteState = 'open' | 'closed';
/** Review events the facade can submit; providers map them onto their own vocabulary. */
export type ForgeReviewEvent = 'approve' | 'request-changes' | 'comment';
/** Target of a write operation: an issue or a pull request, by number. */
export interface ForgeEntityRef {
kind: 'issue' | 'pull';
number: number;
}
/**
* Reply/inline comment input. `inReplyToId` anchors a thread reply; `path` and
* `line` place a new inline review comment (GitHub review comments only).
*/
export interface ForgeCommentInput {
body: string;
inReplyToId?: string | null;
path?: string | null;
line?: number | null;
}
/** Result of posting a comment; `ok: false` with `error` means nothing was posted. */
export interface ForgeCommentResult {
ok: boolean;
error?: string | null;
comment?: ForgeComment | null;
}
/** Input for creating an issue; `labels` is a full-set list of label names. */
export interface ForgeIssueCreateInput {
title: string;
body?: string;
labels?: string[];
}
/** Result of creating an issue; `issue` is the created entity when `ok`. */
export interface ForgeIssueCreateResult {
ok: boolean;
error?: string | null;
issue?: ForgeIssue | null;
}
/** Result of an entity update; `entity` is the refreshed issue/PR when `ok`. */
export interface ForgeUpdateResult {
ok: boolean;
error?: string | null;
entity?: ForgeIssue | ForgePullRequest | null;
}
/** Result of submitting a review; GitLab approvals synthesize a minimal review. */
export interface ForgeReviewResult {
ok: boolean;
error?: string | null;
review?: ForgeReview | null;
}
/**
* Provider-agnostic forge operations. Every method resolves the target
* repository from the working directory (remotes + connected accounts) and
* takes a directory argument, matching the per-provider APIs in
* `@/lib/api/types.ts`.
*/
export interface ForgeProvider {
readonly kind: ForgeProviderKind;
readonly capabilities: ForgeProviderCapabilities;
// --- Pull requests ---
/**
* Resolve the PR/MR open on `branch` (falling back to the merged one), or
* null when there is none. Wraps `github prStatus` (GitHubPullRequestStatus),
* `gitlab mrsList` filtered by `sourceBranch`, and `gitea prsList` filtered
* by `sourceBranch`. Used to link the current worktree branch to its PR.
*/
getPullRequestForBranch(directory: string, branch: string, options?: { remote?: string }): Promise<ForgePullRequest | null>;
/**
* List pull requests, paginated. Wraps `github prsList`, `gitlab mrsList`,
* and `gitea prsList`; `query` is passed through to the provider's search
* where supported. Normalization intent: `state` maps to the provider's
* open/merged/closed vocabulary and `draft` is always a boolean.
*/
listPullRequests(directory: string, options?: { page?: number; query?: string }): Promise<ForgePullRequestsResult>;
/**
* Full context for one PR: issue comments + review comments + files + diff +
* checks. Wraps `github prContext`, `gitlab mrContext`, and `gitea prContext`,
* plus the checks APIs where the provider exposes them. `sourceRepo` selects
* a cross-repo (fork) repository, mirroring `GitHubRepoSelector`.
*/
getPullRequestContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; sourceRepo?: string | null },
): Promise<ForgePullRequestContext>;
// --- Issues ---
/**
* List issues, paginated. Wraps `github issuesList`, `gitlab issuesList`,
* and `gitea issuesList`. `query` passes through to the provider's search
* where supported.
*/
listIssues(directory: string, options?: { page?: number; query?: string }): Promise<ForgeIssuesResult>;
/**
* Fetch a single issue plus its comments. Wraps `github issueGet` +
* `issueComments`, `gitlab issueGet` + `issueComments`, and `gitea issueGet`
* + `issueComments`. `sourceRepo` selects a cross-repo (fork) repository.
*/
getIssue(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeIssueDetail>;
// --- Rich entity view (commits / timeline / checks) ---
/**
* Commits on a pull request. Wraps `github prCommits`, `gitlab mrCommits`,
* and `gitea prCommits`. Optional: providers without a commits route do not
* implement it, and the UI gates on method presence.
*/
getCommits?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeCommitsResult>;
/**
* Activity timeline for a pull request. Wraps `github prTimeline`,
* `gitlab mrTimeline`, and (for gitea, which has no timeline endpoint) a
* timeline synthesized from its reviews. Optional like `getCommits`.
*/
getTimeline?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeTimelineResult>;
/**
* Rolled-up checks for a pull request. Only gitea has a dedicated surface
* (`gitea prStatuses`); GitHub check runs ride on
* `getPullRequestContext().checks` and GitLab has no checks surface, so both
* return null here. `null` return means the provider exposes no checks
* through this method.
*/
getChecks?(directory: string, number: number, options?: { sourceRepo?: string | null }): Promise<ForgeChecksResult | null>;
// --- Rich lookups (pickers / mentions) ---
//
// Repo-scoped searches for the fields the UI offers as pickers. Every method
// resolves the target repo from the working directory (remotes + connected
// accounts) and takes a directory argument, matching the per-provider APIs.
// All are optional and capability-flagged (`capabilities.userSearch`,
// `.labelSearch`, `.milestoneSearch`, `.branchSearch`, `.tagSearch`); the UI
// gates on method presence plus the flag before enabling an affordance.
// Results return `{ connected: false }` — never throw — when the runtime API
// is missing, the wire call fails, or the provider cannot resolve the repo.
/**
* Search the repo's assignable users (matches for assignees and mentions).
* Wraps github `issues.listAssignees`, gitlab project members, and gitea repo
* assignees; `query` is a free-text substring (case-insensitive). `sourceRepo`
* selects a cross-repo (fork) repository.
*/
searchUsers?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeUsersResult>;
/**
* Search the repo's labels. Wraps github `issues.listLabelsForRepo`, gitlab
* project labels, and gitea repo labels.
*/
searchLabels?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeLabelsResult>;
/**
* Search the repo's milestones. Wraps github `issues.listMilestonesForRepo`,
* gitlab project milestones, and gitea repo milestones.
*/
searchMilestones?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeMilestonesResult>;
/** Search the repo's branches. */
searchBranches?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeBranchesResult>;
/** Search the repo's tags. */
searchTags?(directory: string, query: string, options?: { sourceRepo?: string | null }): Promise<ForgeTagsResult>;
// --- Write operations ---
//
// Every write method is optional and capability-flagged: the UI gates on
// method presence plus `capabilities` before enabling an affordance. Methods
// return `{ ok: false, error }` — never throw — when the runtime API is
// missing, the wire call fails, or the provider does not support the
// operation.
/**
* Post a comment on an issue or pull request (the issue-thread comment, not a
* review comment). Providers route by `ref.kind`, so issues and PRs both get
* their thread comment. Wraps `github issueComment`/`prComment`, `gitlab
* issueComment`/`mrComment`, and `gitea issueComment`/`prComment`.
* `sourceRepo` selects a cross-repo (fork) repository.
*/
addComment?(
directory: string,
ref: ForgeEntityRef,
input: { body: string },
options?: { sourceRepo?: string | null },
): Promise<ForgeCommentResult>;
/**
* Create a new issue in the repository. Wraps `github issueCreate`, `gitlab
* issueCreate`, and `gitea issueCreate`; labels are a full-set list of names
* on the create payload (provider-dependent support).
*/
createIssue?(
directory: string,
input: ForgeIssueCreateInput,
options?: { sourceRepo?: string | null },
): Promise<ForgeIssueCreateResult>;
/**
* Reply to a comment thread. On GitHub this posts a proper inline
* review-comment reply via `prReviewComment` (anchored on `inReplyToId`);
* GitLab and Gitea have no thread-reply API wired up yet, so they fall back
* to a flat comment on the thread.
*/
replyToThread?(
directory: string,
ref: ForgeEntityRef,
input: ForgeCommentInput,
options?: { sourceRepo?: string | null },
): Promise<ForgeCommentResult>;
/**
* Update an issue/PR: title, body, or open/closed state. Wraps `github
* issueUpdate`/`prUpdate`, `gitlab issueUpdate`/`mrUpdate`, and `gitea
* issueUpdate`/`prUpdate`. GitHub's PR route requires a title, so the adapter
* fetches the current title first when the input omits it.
*/
updateEntity?(
directory: string,
ref: ForgeEntityRef,
input: { title?: string; body?: string; state?: ForgeWriteState },
options?: { sourceRepo?: string | null },
): Promise<ForgeUpdateResult>;
/**
* Submit a review on a pull request. GitHub maps the normalized events onto
* `APPROVE`/`REQUEST_CHANGES`/`COMMENT`; Gitea onto `APPROVED`/
* `REQUEST_CHANGES`/`COMMENT`; GitLab supports approvals only —
* `request-changes` and `comment` return `{ ok: false, error: 'not
* supported' }` because GitLab exposes no such MR review events.
*/
submitReview?(
directory: string,
ref: ForgeEntityRef,
input: { event: ForgeReviewEvent; body?: string },
options?: { sourceRepo?: string | null },
): Promise<ForgeReviewResult>;
/**
* Toggle draft state. GitHub sets `pulls.update.draft`; GitLab prepends or
* strips the `Draft: ` title prefix (fetching the current title first);
* Gitea has no draft concept (`capabilities.draft: false`) and leaves this
* method undefined.
*/
toggleDraft?(
directory: string,
ref: ForgeEntityRef,
draft: boolean,
options?: { sourceRepo?: string | null },
): Promise<ForgeUpdateResult>;
/**
* Update issue/PR metadata: labels (full-set replace), assignees (logins;
* not applied on GitLab, which assigns by user ID — no id lookup yet), and
* milestone (a title the server resolves, or `null` to clear). Wraps the
* per-provider issue/PR update routes.
*/
updateMetadata?(
directory: string,
ref: ForgeEntityRef,
input: { labels?: string[]; assignees?: string[]; milestone?: string | null },
options?: { sourceRepo?: string | null },
): Promise<ForgeUpdateResult>;
}