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
This commit is contained in:
@@ -817,6 +817,42 @@ export type GitHubUserSummary = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
|
||||
// Each result carries the connected repo so the facade can surface cross-repo /
|
||||
// fork contexts, and the items are always arrays (empty on success with no
|
||||
// matches). `connected: false` means the lookup could not be performed and
|
||||
// must not be treated as an authoritative empty list.
|
||||
|
||||
export type GitHubUsersSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
users: GitHubUserSummary[];
|
||||
};
|
||||
|
||||
export type GitHubLabelsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
labels: GitHubIssueLabel[];
|
||||
};
|
||||
|
||||
export type GitHubMilestonesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
milestones: Array<{ title: string; state?: string }>;
|
||||
};
|
||||
|
||||
export type GitHubBranchesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
branches: string[];
|
||||
};
|
||||
|
||||
export type GitHubTagsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitHubRepoRef | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type GitHubRepoRef = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
@@ -1141,6 +1177,21 @@ export type GitHubIssueCommentResult = {
|
||||
comment?: GitHubIssueComment | null;
|
||||
};
|
||||
|
||||
export type GitHubIssueCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
};
|
||||
|
||||
export type GitHubIssueCreateResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
issue?: GitHubIssue | null;
|
||||
};
|
||||
|
||||
export type GitHubIssueUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
@@ -1236,6 +1287,12 @@ export interface GitHubAPI {
|
||||
authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>;
|
||||
me?(): Promise<GitHubUserSummary>;
|
||||
|
||||
searchUsers?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubUsersSearchResult>;
|
||||
searchLabels?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubLabelsSearchResult>;
|
||||
searchMilestones?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubMilestonesSearchResult>;
|
||||
searchBranches?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubBranchesSearchResult>;
|
||||
searchTags?(directory: string, query: string, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubTagsSearchResult>;
|
||||
|
||||
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>;
|
||||
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
|
||||
@@ -1257,6 +1314,7 @@ export interface GitHubAPI {
|
||||
repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>;
|
||||
repoBranches(owner: string, repo: string): Promise<string[]>;
|
||||
issueComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
|
||||
issueCreate?(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult>;
|
||||
issueUpdate?(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult>;
|
||||
prComment?(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult>;
|
||||
prReviewComment?(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult>;
|
||||
@@ -1392,6 +1450,40 @@ export type GitLabBranchesResult = {
|
||||
defaultBranch?: string | null;
|
||||
};
|
||||
|
||||
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
|
||||
// `connected: false` means the lookup could not be performed and must not be
|
||||
// treated as an authoritative empty list.
|
||||
|
||||
export type GitLabUsersSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
users: GitLabUserSummary[];
|
||||
};
|
||||
|
||||
export type GitLabLabelsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
labels: string[];
|
||||
};
|
||||
|
||||
export type GitLabMilestonesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
milestones: Array<{ title: string; state?: string }>;
|
||||
};
|
||||
|
||||
export type GitLabBranchesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
branches: string[];
|
||||
};
|
||||
|
||||
export type GitLabTagsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestCommit = {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
@@ -1438,6 +1530,8 @@ export type GitLabMergeRequestUpdateInput = {
|
||||
description?: string;
|
||||
state?: 'open' | 'closed';
|
||||
labels?: string[];
|
||||
/** Assignee logins; the server resolves them to user IDs via project members. */
|
||||
assignees?: string[];
|
||||
assigneeIds?: number[];
|
||||
milestone?: string | null;
|
||||
};
|
||||
@@ -1480,6 +1574,21 @@ export type GitLabIssueCommentResult = {
|
||||
comment?: GitLabIssueComment | null;
|
||||
};
|
||||
|
||||
export type GitLabIssueCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
namespace?: string;
|
||||
project?: string;
|
||||
};
|
||||
|
||||
export type GitLabIssueCreateResult = {
|
||||
connected: boolean;
|
||||
repo?: GitLabRepoRef | null;
|
||||
issue?: GitLabIssue | null;
|
||||
};
|
||||
|
||||
export type GitLabIssueUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
@@ -1487,6 +1596,8 @@ export type GitLabIssueUpdateInput = {
|
||||
body?: string;
|
||||
state?: 'open' | 'closed';
|
||||
labels?: string[];
|
||||
/** Assignee logins; the server resolves them to user IDs via project members. */
|
||||
assignees?: string[];
|
||||
assigneeIds?: number[];
|
||||
milestone?: string | null;
|
||||
namespace?: string;
|
||||
@@ -1569,10 +1680,17 @@ export interface GitLabAPI {
|
||||
mrTimeline?(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult>;
|
||||
|
||||
issueComment?(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult>;
|
||||
issueCreate?(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult>;
|
||||
issueUpdate?(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult>;
|
||||
mrComment?(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult>;
|
||||
mrApprove?(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult>;
|
||||
|
||||
searchUsers?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabUsersSearchResult>;
|
||||
searchLabels?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabLabelsSearchResult>;
|
||||
searchMilestones?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabMilestonesSearchResult>;
|
||||
searchBranches?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabBranchesSearchResult>;
|
||||
searchTags?(directory: string, query: string, options?: { namespace?: string; project?: string }): Promise<GitLabTagsSearchResult>;
|
||||
|
||||
repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult>;
|
||||
}
|
||||
|
||||
@@ -1705,6 +1823,21 @@ export type GiteaIssueCommentResult = {
|
||||
comment?: GiteaComment | null;
|
||||
};
|
||||
|
||||
export type GiteaIssueCreateInput = {
|
||||
directory: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
};
|
||||
|
||||
export type GiteaIssueCreateResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
issue?: GiteaIssue | null;
|
||||
};
|
||||
|
||||
export type GiteaIssueUpdateInput = {
|
||||
directory: string;
|
||||
number: number;
|
||||
@@ -1752,6 +1885,40 @@ export type GiteaRepoLabelsResult = {
|
||||
labels: GiteaRepoLabel[];
|
||||
};
|
||||
|
||||
// ---- Rich lookup results (repo-scoped search for pickers/mentions) ----
|
||||
// `connected: false` means the lookup could not be performed and must not be
|
||||
// treated as an authoritative empty list.
|
||||
|
||||
export type GiteaUsersSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
users: GiteaUserSummary[];
|
||||
};
|
||||
|
||||
export type GiteaLabelsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
labels: GiteaRepoLabel[];
|
||||
};
|
||||
|
||||
export type GiteaMilestonesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
milestones: Array<{ title: string; state?: string }>;
|
||||
};
|
||||
|
||||
export type GiteaBranchesSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
branches: string[];
|
||||
};
|
||||
|
||||
export type GiteaTagsSearchResult = {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export interface GiteaAPI {
|
||||
authStatus(): Promise<GiteaAuthStatus>;
|
||||
authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus>;
|
||||
@@ -1777,11 +1944,18 @@ export interface GiteaAPI {
|
||||
prReviews?(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult>;
|
||||
|
||||
issueComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
|
||||
issueCreate?(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult>;
|
||||
issueUpdate?(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult>;
|
||||
prComment?(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult>;
|
||||
prSubmitReview?(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult>;
|
||||
repoLabels?(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult>;
|
||||
|
||||
searchUsers?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaUsersSearchResult>;
|
||||
searchLabels?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaLabelsSearchResult>;
|
||||
searchMilestones?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaMilestonesSearchResult>;
|
||||
searchBranches?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaBranchesSearchResult>;
|
||||
searchTags?(directory: string, query: string, options?: { owner?: string; repo?: string }): Promise<GiteaTagsSearchResult>;
|
||||
|
||||
repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult>;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,14 @@ import {
|
||||
mapGiteaCommits,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
mapGiteaAssignee,
|
||||
mapGiteaIssue,
|
||||
mapGiteaPr,
|
||||
mapGiteaRepoRef,
|
||||
mapGiteaReviewsToEvents,
|
||||
mapGiteaReview,
|
||||
mapGiteaStatuses,
|
||||
mapGithubAssignee,
|
||||
mapGithubCommits,
|
||||
mapGithubContext,
|
||||
mapGithubIssue,
|
||||
@@ -53,6 +55,7 @@ import {
|
||||
mapGitlabCommits,
|
||||
mapGitlabContext,
|
||||
mapGitlabIssue,
|
||||
mapGitlabMember,
|
||||
mapGitlabMr,
|
||||
mapGitlabNoteComment,
|
||||
mapGitlabRepoRef,
|
||||
@@ -69,6 +72,11 @@ const GITHUB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
};
|
||||
|
||||
const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
@@ -81,6 +89,11 @@ const GITLAB_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
timelineEvents: true,
|
||||
inlineComments: false,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
};
|
||||
|
||||
const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
@@ -93,6 +106,11 @@ const GITEA_CAPABILITIES: ForgeProviderCapabilities = {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
};
|
||||
|
||||
// Gitea's 'commit-statuses' checks and inline comments land once Slice B adds
|
||||
@@ -341,6 +359,79 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
|
||||
return null;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options) {
|
||||
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchUsers(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
users: (result.users ?? []).map(mapGithubAssignee),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options) {
|
||||
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchLabels(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options) {
|
||||
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchMilestones(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
milestones: (result.milestones ?? []).map((milestone) => ({
|
||||
title: milestone.title,
|
||||
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options) {
|
||||
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchBranches(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
branches: result.branches ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options) {
|
||||
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const result = await api.searchTags(directory, query, { sourceRepo: parseOwnerRepo(options?.sourceRepo) });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGithubRepoRef(result.repo) : null,
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async addComment(directory, ref, input, options) {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const owner = selector?.owner;
|
||||
@@ -365,6 +456,25 @@ export const createGithubForgeProvider = (api: GitHubAPI): ForgeProvider => ({
|
||||
}
|
||||
},
|
||||
|
||||
async createIssue(directory, input, options) {
|
||||
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.issueCreate({
|
||||
directory,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.labels !== undefined ? { labels: input.labels } : {}),
|
||||
owner: selector?.owner,
|
||||
repo: selector?.repo,
|
||||
});
|
||||
if (!result.connected) return { ok: false, error: WRITE_ERROR };
|
||||
return { ok: true, issue: result.issue ? mapGithubIssue(result.issue) : null };
|
||||
} catch {
|
||||
return { ok: false, error: WRITE_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async replyToThread(directory, ref, input, options) {
|
||||
if (ref.kind !== 'pull') {
|
||||
// Issues have no inline review comments; reply as a flat thread comment.
|
||||
@@ -651,6 +761,84 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
return null;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options) {
|
||||
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchUsers(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
users: (result.users ?? []).map(mapGitlabMember),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options) {
|
||||
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchLabels(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
labels: (result.labels ?? []).map((name) => ({ name })),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options) {
|
||||
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchMilestones(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
milestones: (result.milestones ?? []).map((milestone) => ({
|
||||
title: milestone.title,
|
||||
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options) {
|
||||
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchBranches(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
branches: result.branches ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options) {
|
||||
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.searchTags(directory, query, { namespace, project });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGitlabRepoRef(result.repo) : null,
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async addComment(directory, ref, input, options) {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
if (ref.kind === 'issue') {
|
||||
@@ -673,6 +861,25 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
}
|
||||
},
|
||||
|
||||
async createIssue(directory, input, options) {
|
||||
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
|
||||
try {
|
||||
const { namespace, project } = parseGitlabNamespace(options?.sourceRepo);
|
||||
const result = await api.issueCreate({
|
||||
directory,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.labels !== undefined ? { labels: input.labels } : {}),
|
||||
namespace,
|
||||
project,
|
||||
});
|
||||
if (!result.connected) return { ok: false, error: WRITE_ERROR };
|
||||
return { ok: true, issue: result.issue ? mapGitlabIssue(result.issue) : null };
|
||||
} catch {
|
||||
return { ok: false, error: WRITE_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async replyToThread(directory, ref, input, options) {
|
||||
// GitLab's note-reply API is not wired up yet; reply as a flat comment.
|
||||
return this.addComment!(directory, ref, { body: input.body }, options);
|
||||
@@ -754,12 +961,13 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
if (ref.kind === 'issue') {
|
||||
if (!api.issueUpdate) return { ok: false, error: WRITE_ERROR };
|
||||
try {
|
||||
// GitLab assigns by user ID, not login; the facade takes logins, so
|
||||
// assignees are left unset until an id lookup exists.
|
||||
// GitLab assigns by user ID; the server resolves the facade's login
|
||||
// list to IDs via project members (see gitlab routes resolveAssigneeIds).
|
||||
const result = await api.issueUpdate({
|
||||
directory,
|
||||
number: ref.number,
|
||||
labels: input.labels,
|
||||
assignees: input.assignees,
|
||||
milestone: input.milestone,
|
||||
namespace,
|
||||
project,
|
||||
@@ -776,6 +984,7 @@ export const createGitlabForgeProvider = (api: GitLabAPI): ForgeProvider => ({
|
||||
directory,
|
||||
number: ref.number,
|
||||
labels: input.labels,
|
||||
assignees: input.assignees,
|
||||
milestone: input.milestone,
|
||||
});
|
||||
return { ok: true, entity: mapGitlabMr(mr) };
|
||||
@@ -926,6 +1135,84 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
|
||||
}
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options) {
|
||||
if (!api.searchUsers) return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.searchUsers(directory, query, { owner: selector?.owner, repo: selector?.repo });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
users: (result.users ?? []).map(mapGiteaAssignee),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, users: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options) {
|
||||
if (!api.searchLabels) return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.searchLabels(directory, query, { owner: selector?.owner, repo: selector?.repo });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
labels: (result.labels ?? []).map((label) => ({ name: label.name, color: label.color })),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, labels: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options) {
|
||||
if (!api.searchMilestones) return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.searchMilestones(directory, query, { owner: selector?.owner, repo: selector?.repo });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
milestones: (result.milestones ?? []).map((milestone) => ({
|
||||
title: milestone.title,
|
||||
...(milestone.state === 'open' || milestone.state === 'closed' || milestone.state === 'active' ? { state: milestone.state } : {}),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, milestones: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options) {
|
||||
if (!api.searchBranches) return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.searchBranches(directory, query, { owner: selector?.owner, repo: selector?.repo });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
branches: result.branches ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, branches: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options) {
|
||||
if (!api.searchTags) return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.searchTags(directory, query, { owner: selector?.owner, repo: selector?.repo });
|
||||
return {
|
||||
connected: result.connected,
|
||||
repo: result.repo ? mapGiteaRepoRef(result.repo) : null,
|
||||
tags: result.tags ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { connected: false, repo: null, tags: [], error: LOAD_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async getChecks(directory, number, options) {
|
||||
if (!api.prStatuses) return EMPTY_CHECKS;
|
||||
try {
|
||||
@@ -968,6 +1255,25 @@ export const createGiteaForgeProvider = (api: GiteaAPI): ForgeProvider => ({
|
||||
}
|
||||
},
|
||||
|
||||
async createIssue(directory, input, options) {
|
||||
if (!api.issueCreate) return { ok: false, error: WRITE_ERROR };
|
||||
try {
|
||||
const selector = parseOwnerRepo(options?.sourceRepo);
|
||||
const result = await api.issueCreate({
|
||||
directory,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.labels !== undefined ? { labels: input.labels } : {}),
|
||||
owner: selector?.owner,
|
||||
repo: selector?.repo,
|
||||
});
|
||||
if (!result.connected) return { ok: false, error: WRITE_ERROR };
|
||||
return { ok: true, issue: result.issue ? mapGiteaIssue(result.issue) : null };
|
||||
} catch {
|
||||
return { ok: false, error: WRITE_ERROR };
|
||||
}
|
||||
},
|
||||
|
||||
async replyToThread(directory, ref, input, options) {
|
||||
// Gitea's thread-reply API is not wired up yet; reply as a flat comment.
|
||||
return this.addComment!(directory, ref, { body: input.body }, options);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
aggregateStatusState,
|
||||
firstLine,
|
||||
mapCheckRunState,
|
||||
mapGiteaAssignee,
|
||||
mapGiteaCommits,
|
||||
mapGiteaComment,
|
||||
mapGiteaContext,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
mapGiteaReviewsToEvents,
|
||||
mapGiteaReview,
|
||||
mapGiteaStatuses,
|
||||
mapGithubAssignee,
|
||||
mapGithubCheckSummary,
|
||||
mapGithubCommits,
|
||||
mapGithubContext,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
mapGitlabCommits,
|
||||
mapGitlabContext,
|
||||
mapGitlabIssue,
|
||||
mapGitlabMember,
|
||||
mapGitlabMr,
|
||||
mapGitlabNoteComment,
|
||||
mapGitlabTimelineEvents,
|
||||
@@ -500,6 +503,37 @@ describe('gitea normalization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('repo-scoped lookup normalization', () => {
|
||||
test('maps a GitHub repo assignee', () => {
|
||||
expect(mapGithubAssignee(githubUser())).toEqual({
|
||||
id: 'octocat',
|
||||
login: 'octocat',
|
||||
name: 'Octo Cat',
|
||||
avatarUrl: 'https://avatars.example/octocat',
|
||||
});
|
||||
});
|
||||
|
||||
test('maps a GitLab project member', () => {
|
||||
expect(mapGitlabMember(gitlabUser())).toEqual({
|
||||
id: '5',
|
||||
login: 'gluser',
|
||||
name: 'GL User',
|
||||
avatarUrl: 'https://avatars.example/gluser',
|
||||
url: 'https://gitlab.example/gluser',
|
||||
});
|
||||
});
|
||||
|
||||
test('maps a Gitea repo assignee', () => {
|
||||
expect(mapGiteaAssignee(giteaUser())).toEqual({
|
||||
id: '3',
|
||||
login: 'guser',
|
||||
name: 'G User',
|
||||
avatarUrl: 'https://avatars.example/guser',
|
||||
url: 'https://gitea.example/guser',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rich-view normalization (commits / timeline / checks)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1126,7 +1160,7 @@ describe('write operations: updateMetadata', () => {
|
||||
expect(issueArgs.milestone).toBe('v2.0');
|
||||
});
|
||||
|
||||
test('gitlab sends labels/milestone but never assignees (id-based)', async () => {
|
||||
test('gitlab sends labels/assignee logins/milestone; the server resolves logins to IDs', async () => {
|
||||
let issueArgs: Record<string, unknown> = {};
|
||||
const api = {
|
||||
issueUpdate: async (input: Record<string, unknown>) => {
|
||||
@@ -1142,7 +1176,7 @@ describe('write operations: updateMetadata', () => {
|
||||
expect(result.ok).toBe(true);
|
||||
expect(issueArgs.labels).toEqual(['frontend']);
|
||||
expect(issueArgs.milestone).toBeNull();
|
||||
expect(issueArgs.assignees).toBe(undefined);
|
||||
expect(issueArgs.assignees).toEqual(['gluser']);
|
||||
});
|
||||
|
||||
test('gitea issue metadata passes through; PR metadata is unsupported', async () => {
|
||||
@@ -1246,6 +1280,11 @@ describe('buildForgeProvider', () => {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1262,6 +1301,11 @@ describe('buildForgeProvider', () => {
|
||||
timelineEvents: true,
|
||||
inlineComments: false,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1278,6 +1322,11 @@ describe('buildForgeProvider', () => {
|
||||
timelineEvents: true,
|
||||
inlineComments: true,
|
||||
threads: true,
|
||||
userSearch: true,
|
||||
labelSearch: true,
|
||||
milestoneSearch: true,
|
||||
branchSearch: true,
|
||||
tagSearch: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1577,3 +1626,310 @@ describe('getForgeProviderForDirectory', () => {
|
||||
expect(await getForgeProviderForDirectory('/repo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('write operations: createIssue', () => {
|
||||
test('github passes title/body/labels and parses sourceRepo', async () => {
|
||||
let args: Record<string, unknown> = {};
|
||||
const api = {
|
||||
issueCreate: async (input: Record<string, unknown>) => {
|
||||
args = input;
|
||||
return { connected: true, issue: githubIssue };
|
||||
},
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
|
||||
const result = await provider.createIssue!(
|
||||
'/repo',
|
||||
{ title: 'Add feature', body: 'The body', labels: ['bug'] },
|
||||
{ sourceRepo: 'upstream/widget' },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.issue?.number).toBe(7);
|
||||
expect(args).toEqual({
|
||||
directory: '/repo',
|
||||
title: 'Add feature',
|
||||
body: 'The body',
|
||||
labels: ['bug'],
|
||||
owner: 'upstream',
|
||||
repo: 'widget',
|
||||
});
|
||||
});
|
||||
|
||||
test('github omits optional fields and fails closed when the api is absent', async () => {
|
||||
let called = false;
|
||||
const api = {
|
||||
issueCreate: async () => {
|
||||
called = true;
|
||||
return { connected: true, issue: githubIssue };
|
||||
},
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
|
||||
const result = await provider.createIssue!('/repo', { title: 'T' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(called).toBe(true);
|
||||
|
||||
const empty = createGithubForgeProvider({} as unknown as GitHubAPI);
|
||||
const failed = await empty.createIssue!('/repo', { title: 'T' });
|
||||
expect(failed.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('gitlab passes namespace/project from a multi-segment sourceRepo', async () => {
|
||||
let args: Record<string, unknown> = {};
|
||||
const api = {
|
||||
issueCreate: async (input: Record<string, unknown>) => {
|
||||
args = input;
|
||||
return { connected: true, issue: gitlabIssue };
|
||||
},
|
||||
} as unknown as GitLabAPI;
|
||||
const provider = createGitlabForgeProvider(api);
|
||||
|
||||
const result = await provider.createIssue!(
|
||||
'/repo',
|
||||
{ title: 'Add feature', body: 'The body' },
|
||||
{ sourceRepo: 'group/sub/proj' },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.issue?.number).toBe(8);
|
||||
expect(args).toEqual({
|
||||
directory: '/repo',
|
||||
title: 'Add feature',
|
||||
body: 'The body',
|
||||
namespace: 'group/sub',
|
||||
project: 'proj',
|
||||
});
|
||||
});
|
||||
|
||||
test('gitea passes owner/repo and maps the created issue', async () => {
|
||||
let args: Record<string, unknown> = {};
|
||||
const api = {
|
||||
issueCreate: async (input: Record<string, unknown>) => {
|
||||
args = input;
|
||||
return { connected: true, issue: giteaIssue };
|
||||
},
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
|
||||
const result = await provider.createIssue!('/repo', { title: 'Add feature', labels: ['bug'] });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.issue?.number).toBe(12);
|
||||
expect(args).toEqual({
|
||||
directory: '/repo',
|
||||
title: 'Add feature',
|
||||
labels: ['bug'],
|
||||
owner: undefined,
|
||||
repo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('createIssue degrades to ok:false without throwing on wire failure', async () => {
|
||||
const api = {
|
||||
issueCreate: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
|
||||
const result = await provider.createIssue!('/repo', { title: 'T' });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('repo-scoped user search (searchUsers)', () => {
|
||||
test('github parses sourceRepo and maps assignees by login', async () => {
|
||||
let receivedDirectory = '';
|
||||
let receivedQuery = '';
|
||||
let receivedOptions: { sourceRepo?: { owner: string; repo: string } | null } | undefined;
|
||||
const api = {
|
||||
searchUsers: async (
|
||||
directory: string,
|
||||
query: string,
|
||||
options?: { sourceRepo?: { owner: string; repo: string } | null },
|
||||
) => {
|
||||
receivedDirectory = directory;
|
||||
receivedQuery = query;
|
||||
receivedOptions = options;
|
||||
return {
|
||||
connected: true,
|
||||
repo: { owner: 'acme', repo: 'widget', url: 'https://github.com/acme/widget' },
|
||||
users: [githubUser()],
|
||||
};
|
||||
},
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
|
||||
const result = await provider.searchUsers!('/repo', 'octo', { sourceRepo: 'upstream/widget' });
|
||||
expect(receivedDirectory).toBe('/repo');
|
||||
expect(receivedQuery).toBe('octo');
|
||||
expect(receivedOptions).toEqual({ sourceRepo: { owner: 'upstream', repo: 'widget' } });
|
||||
expect(result.connected).toBe(true);
|
||||
expect(result.repo?.owner).toBe('acme');
|
||||
expect(result.users).toEqual([{
|
||||
id: 'octocat',
|
||||
login: 'octocat',
|
||||
name: 'Octo Cat',
|
||||
avatarUrl: 'https://avatars.example/octocat',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('github passes connected:false through without an authoritative list', async () => {
|
||||
const api = {
|
||||
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
|
||||
const result = await provider.searchUsers!('/repo', 'octo');
|
||||
expect(result.connected).toBe(false);
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('github fails closed with an error when searchUsers is missing', async () => {
|
||||
const provider = createGithubForgeProvider({} as unknown as GitHubAPI);
|
||||
expect(await provider.searchUsers!('/repo', 'octo')).toEqual({
|
||||
connected: false,
|
||||
repo: null,
|
||||
users: [],
|
||||
error: 'failed to load',
|
||||
});
|
||||
});
|
||||
|
||||
test('github fails closed with an error when the wire call throws', async () => {
|
||||
const api = {
|
||||
searchUsers: async () => { throw new Error('boom'); },
|
||||
} as unknown as GitHubAPI;
|
||||
const provider = createGithubForgeProvider(api);
|
||||
|
||||
expect(await provider.searchUsers!('/repo', 'octo')).toEqual({
|
||||
connected: false,
|
||||
repo: null,
|
||||
users: [],
|
||||
error: 'failed to load',
|
||||
});
|
||||
});
|
||||
|
||||
test('gitlab parses namespace/project from a multi-segment sourceRepo and maps members', async () => {
|
||||
let receivedOptions: { namespace?: string; project?: string } | undefined;
|
||||
const api = {
|
||||
searchUsers: async (
|
||||
_directory: string,
|
||||
_query: string,
|
||||
options?: { namespace?: string; project?: string },
|
||||
) => {
|
||||
receivedOptions = options;
|
||||
return { connected: true, repo: null, users: [gitlabUser()] };
|
||||
},
|
||||
} as unknown as GitLabAPI;
|
||||
const provider = createGitlabForgeProvider(api);
|
||||
|
||||
const result = await provider.searchUsers!('/repo', 'gl', { sourceRepo: 'group/sub/proj' });
|
||||
expect(receivedOptions).toEqual({ namespace: 'group/sub', project: 'proj' });
|
||||
expect(result.connected).toBe(true);
|
||||
expect(result.users).toEqual([{
|
||||
id: '5',
|
||||
login: 'gluser',
|
||||
name: 'GL User',
|
||||
avatarUrl: 'https://avatars.example/gluser',
|
||||
url: 'https://gitlab.example/gluser',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('gitlab passes connected:false through without an authoritative list', async () => {
|
||||
const api = {
|
||||
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
|
||||
} as unknown as GitLabAPI;
|
||||
const provider = createGitlabForgeProvider(api);
|
||||
|
||||
const result = await provider.searchUsers!('/repo', 'gl');
|
||||
expect(result.connected).toBe(false);
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('gitlab fails closed with an error when searchUsers is missing', async () => {
|
||||
const provider = createGitlabForgeProvider({} as unknown as GitLabAPI);
|
||||
expect(await provider.searchUsers!('/repo', 'gl')).toEqual({
|
||||
connected: false,
|
||||
repo: null,
|
||||
users: [],
|
||||
error: 'failed to load',
|
||||
});
|
||||
});
|
||||
|
||||
test('gitlab fails closed with an error when the wire call throws', async () => {
|
||||
const api = {
|
||||
searchUsers: async () => { throw new Error('boom'); },
|
||||
} as unknown as GitLabAPI;
|
||||
const provider = createGitlabForgeProvider(api);
|
||||
|
||||
expect(await provider.searchUsers!('/repo', 'gl')).toEqual({
|
||||
connected: false,
|
||||
repo: null,
|
||||
users: [],
|
||||
error: 'failed to load',
|
||||
});
|
||||
});
|
||||
|
||||
test('gitea parses owner/repo and maps repo assignees', async () => {
|
||||
let receivedOptions: { owner?: string; repo?: string } | undefined;
|
||||
const api = {
|
||||
searchUsers: async (
|
||||
_directory: string,
|
||||
_query: string,
|
||||
options?: { owner?: string; repo?: string },
|
||||
) => {
|
||||
receivedOptions = options;
|
||||
return { connected: true, repo: null, users: [giteaUser()] };
|
||||
},
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
|
||||
const result = await provider.searchUsers!('/repo', 'g', { sourceRepo: 'acme/widget' });
|
||||
expect(receivedOptions).toEqual({ owner: 'acme', repo: 'widget' });
|
||||
expect(result.connected).toBe(true);
|
||||
expect(result.users).toEqual([{
|
||||
id: '3',
|
||||
login: 'guser',
|
||||
name: 'G User',
|
||||
avatarUrl: 'https://avatars.example/guser',
|
||||
url: 'https://gitea.example/guser',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('gitea passes connected:false through without an authoritative list', async () => {
|
||||
const api = {
|
||||
searchUsers: async () => ({ connected: false, repo: null, users: [] }),
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
|
||||
const result = await provider.searchUsers!('/repo', 'g');
|
||||
expect(result.connected).toBe(false);
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('gitea fails closed with an error when searchUsers is missing', async () => {
|
||||
const provider = createGiteaForgeProvider({} as unknown as GiteaAPI);
|
||||
expect(await provider.searchUsers!('/repo', 'g')).toEqual({
|
||||
connected: false,
|
||||
repo: null,
|
||||
users: [],
|
||||
error: 'failed to load',
|
||||
});
|
||||
});
|
||||
|
||||
test('gitea fails closed with an error when the wire call throws', async () => {
|
||||
const api = {
|
||||
searchUsers: async () => { throw new Error('boom'); },
|
||||
} as unknown as GiteaAPI;
|
||||
const provider = createGiteaForgeProvider(api);
|
||||
|
||||
expect(await provider.searchUsers!('/repo', 'g')).toEqual({
|
||||
connected: false,
|
||||
repo: null,
|
||||
users: [],
|
||||
error: 'failed to load',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,11 @@ export type {
|
||||
ForgeCommitsResult,
|
||||
ForgeTimelineResult,
|
||||
ForgeChecksResult,
|
||||
ForgeUsersResult,
|
||||
ForgeLabelsResult,
|
||||
ForgeMilestonesResult,
|
||||
ForgeBranchesResult,
|
||||
ForgeTagsResult,
|
||||
ForgeProvider,
|
||||
} from './provider';
|
||||
|
||||
@@ -50,6 +55,7 @@ export {
|
||||
firstLine,
|
||||
normalizeEventType,
|
||||
mapGithubUser,
|
||||
mapGithubAssignee,
|
||||
mapGithubPr,
|
||||
mapGithubIssue,
|
||||
mapGithubIssueComment,
|
||||
@@ -60,6 +66,7 @@ export {
|
||||
mapGithubCommits,
|
||||
mapGithubTimelineEvents,
|
||||
mapGitlabUser,
|
||||
mapGitlabMember,
|
||||
mapGitlabMr,
|
||||
mapGitlabIssue,
|
||||
mapGitlabNoteComment,
|
||||
@@ -68,6 +75,7 @@ export {
|
||||
mapGitlabCommits,
|
||||
mapGitlabTimelineEvents,
|
||||
mapGiteaUser,
|
||||
mapGiteaAssignee,
|
||||
mapGiteaPr,
|
||||
mapGiteaIssue,
|
||||
mapGiteaComment,
|
||||
|
||||
@@ -117,6 +117,36 @@ export const mapGiteaUser = (user: GiteaUserSummary): ForgeUser => ({
|
||||
url: user.webUrl,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo-scoped lookup results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map a GitHub repo assignee item onto `ForgeUser` (same shape as GitHubUserSummary). */
|
||||
export const mapGithubAssignee = (assignee: GitHubUserSummary): ForgeUser => ({
|
||||
id: assignee.login,
|
||||
login: assignee.login,
|
||||
name: assignee.name,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
});
|
||||
|
||||
/** Map a GitLab project member (wire `members/all` item) onto `ForgeUser`. */
|
||||
export const mapGitlabMember = (member: GitLabUserSummary): ForgeUser => ({
|
||||
id: String(member.id ?? member.username),
|
||||
login: member.username,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatarUrl,
|
||||
url: member.webUrl,
|
||||
});
|
||||
|
||||
/** Map a Gitea repo-assignee item onto `ForgeUser` (same shape as GiteaUserSummary). */
|
||||
export const mapGiteaAssignee = (assignee: GiteaUserSummary): ForgeUser => ({
|
||||
id: String(assignee.id ?? assignee.username),
|
||||
login: assignee.username,
|
||||
name: assignee.name,
|
||||
avatarUrl: assignee.avatarUrl,
|
||||
url: assignee.webUrl,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pull requests / merge requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,12 +4,15 @@ import type {
|
||||
ForgeCommit,
|
||||
ForgeFileChange,
|
||||
ForgeIssue,
|
||||
ForgeLabel,
|
||||
ForgeMilestone,
|
||||
ForgeProviderCapabilities,
|
||||
ForgeProviderKind,
|
||||
ForgePullRequest,
|
||||
ForgeRepoRef,
|
||||
ForgeReview,
|
||||
ForgeTimelineEvent,
|
||||
ForgeUser,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -95,6 +98,51 @@ export interface ForgeTimelineResult {
|
||||
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;
|
||||
@@ -135,6 +183,20 @@ export interface ForgeCommentResult {
|
||||
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;
|
||||
@@ -230,6 +292,43 @@ export interface ForgeProvider {
|
||||
*/
|
||||
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
|
||||
@@ -252,6 +351,17 @@ export interface ForgeProvider {
|
||||
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`);
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface ForgeProviderCapabilities {
|
||||
inlineComments: boolean;
|
||||
/** Comment threads that can be replied to. */
|
||||
threads: boolean;
|
||||
/** Repo-scoped user search (assignable users) for mentions/assignees. */
|
||||
userSearch: boolean;
|
||||
/** Repo-scoped label search for the label picker. */
|
||||
labelSearch: boolean;
|
||||
/** Repo-scoped milestone search for the milestone picker. */
|
||||
milestoneSearch: boolean;
|
||||
/** Repo-scoped branch search. */
|
||||
branchSearch: boolean;
|
||||
/** Repo-scoped tag search. */
|
||||
tagSearch: boolean;
|
||||
}
|
||||
|
||||
/** A person as surfaced by the forge (issue author, reviewer, commit author, ...). */
|
||||
|
||||
@@ -1290,6 +1290,13 @@ export const dict = {
|
||||
'forge.actions.closeConfirm': 'Diesen Vorgang schließen?',
|
||||
'forge.actions.comment': 'Kommentieren',
|
||||
'forge.actions.commentPlaceholder': 'Kommentar hinzufügen…',
|
||||
'forge.actions.createIssue': 'Issue erstellen',
|
||||
'forge.actions.issueBodyPlaceholder': 'Problem beschreiben…',
|
||||
'forge.actions.issueCreated': 'Issue erstellt',
|
||||
'forge.actions.issueDialogTitle': 'Neues Issue',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Labels (durch Kommas getrennt)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Titel',
|
||||
'forge.actions.newIssue': 'Neues Issue',
|
||||
'forge.actions.draftChanged': 'Entwurfsstatus aktualisiert',
|
||||
'forge.actions.edit': 'Bearbeiten',
|
||||
'forge.actions.error': 'Aktion fehlgeschlagen',
|
||||
@@ -1330,6 +1337,8 @@ export const dict = {
|
||||
'forge.files.empty': 'Keine Dateien geändert',
|
||||
'forge.files.noDiff': 'Kein Diff verfügbar',
|
||||
'forge.loading': 'Wird geladen...',
|
||||
'forge.lookup.empty': 'Keine Treffer',
|
||||
'forge.lookup.loading': 'Suche…',
|
||||
'forge.linkedSessions.count': 'Mit diesem Element verknüpfte Chats: {count}',
|
||||
'forge.linkedSessions.open': 'Sitzung „{title}“ öffnen',
|
||||
'forge.linkedSessions.title': 'Chats, die daran arbeiten',
|
||||
|
||||
@@ -1542,12 +1542,19 @@ export const dict = {
|
||||
'forge.actions.closeConfirm': 'Close this issue/PR?',
|
||||
'forge.actions.comment': 'Comment',
|
||||
'forge.actions.commentPlaceholder': 'Add a comment…',
|
||||
'forge.actions.createIssue': 'Create issue',
|
||||
'forge.actions.draftChanged': 'Draft status updated',
|
||||
'forge.actions.edit': 'Edit',
|
||||
'forge.actions.error': 'Action failed',
|
||||
'forge.actions.issueBodyPlaceholder': 'Describe the issue…',
|
||||
'forge.actions.issueCreated': 'Issue created',
|
||||
'forge.actions.issueDialogTitle': 'New issue',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Labels (comma-separated)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Title',
|
||||
'forge.actions.markDraft': 'Mark as draft',
|
||||
'forge.actions.markReady': 'Mark ready',
|
||||
'forge.actions.metadataChanged': 'Metadata updated',
|
||||
'forge.actions.newIssue': 'New issue',
|
||||
'forge.actions.posting': 'Posting…',
|
||||
'forge.actions.remove': 'Remove',
|
||||
'forge.actions.removed': 'Removed',
|
||||
@@ -1582,6 +1589,8 @@ export const dict = {
|
||||
'forge.files.empty': 'No files changed',
|
||||
'forge.files.noDiff': 'No diff available',
|
||||
'forge.loading': 'Loading...',
|
||||
'forge.lookup.empty': 'No matches',
|
||||
'forge.lookup.loading': 'Searching…',
|
||||
'forge.linkedSessions.count': 'Sessions linked to this entity: {count}',
|
||||
'forge.linkedSessions.open': 'Open session "{title}"',
|
||||
'forge.linkedSessions.title': 'Chats working on this',
|
||||
|
||||
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '¿Cerrar este problema/PR?',
|
||||
'forge.actions.comment': 'Comentar',
|
||||
'forge.actions.commentPlaceholder': 'Añadir un comentario…',
|
||||
'forge.actions.createIssue': 'Crear incidencia',
|
||||
'forge.actions.issueBodyPlaceholder': 'Describe el problema…',
|
||||
'forge.actions.issueCreated': 'Incidencia creada',
|
||||
'forge.actions.issueDialogTitle': 'Nueva incidencia',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Etiquetas (separadas por comas)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Título',
|
||||
'forge.actions.newIssue': 'Nueva incidencia',
|
||||
'forge.actions.draftChanged': 'Estado de borrador actualizado',
|
||||
'forge.actions.edit': 'Editar',
|
||||
'forge.actions.error': 'La acción falló',
|
||||
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': 'No hay archivos modificados',
|
||||
'forge.files.noDiff': 'No hay diff disponible',
|
||||
'forge.loading': 'Cargando...',
|
||||
'forge.lookup.empty': 'Sin coincidencias',
|
||||
'forge.lookup.loading': 'Buscando…',
|
||||
'forge.linkedSessions.count': 'Sesiones vinculadas a este elemento: {count}',
|
||||
'forge.linkedSessions.open': 'Abrir sesión «{title}»',
|
||||
'forge.linkedSessions.title': 'Chats trabajando en esto',
|
||||
|
||||
@@ -3215,6 +3215,13 @@ export const dict = {
|
||||
'forge.actions.closeConfirm': 'Fermer ce problème/pull request ?',
|
||||
'forge.actions.comment': 'Commenter',
|
||||
'forge.actions.commentPlaceholder': 'Ajouter un commentaire…',
|
||||
'forge.actions.createIssue': 'Créer une issue',
|
||||
'forge.actions.issueBodyPlaceholder': 'Décrivez le problème…',
|
||||
'forge.actions.issueCreated': 'Issue créée',
|
||||
'forge.actions.issueDialogTitle': 'Nouvelle issue',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Étiquettes (séparées par des virgules)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Titre',
|
||||
'forge.actions.newIssue': 'Nouvelle issue',
|
||||
'forge.actions.draftChanged': 'Statut de brouillon mis à jour',
|
||||
'forge.actions.edit': 'Modifier',
|
||||
'forge.actions.error': 'Échec de l’action',
|
||||
@@ -3255,6 +3262,8 @@ export const dict = {
|
||||
'forge.files.empty': 'Aucun fichier modifié',
|
||||
'forge.files.noDiff': 'Aucun diff disponible',
|
||||
'forge.loading': 'Chargement...',
|
||||
'forge.lookup.empty': 'Aucun résultat',
|
||||
'forge.lookup.loading': 'Recherche…',
|
||||
'forge.linkedSessions.count': 'Conversations liées à cet élément : {count}',
|
||||
'forge.linkedSessions.open': 'Ouvrir la conversation « {title} »',
|
||||
'forge.linkedSessions.title': 'Conversations en cours sur cet élément',
|
||||
|
||||
@@ -1539,6 +1539,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': 'このIssue/PRをクローズしますか?',
|
||||
'forge.actions.comment': 'コメント',
|
||||
'forge.actions.commentPlaceholder': 'コメントを追加…',
|
||||
'forge.actions.createIssue': 'イシューを作成',
|
||||
'forge.actions.issueBodyPlaceholder': '問題を説明…',
|
||||
'forge.actions.issueCreated': 'イシューを作成しました',
|
||||
'forge.actions.issueDialogTitle': '新しいイシュー',
|
||||
'forge.actions.issueLabelsPlaceholder': 'ラベル(カンマ区切り)',
|
||||
'forge.actions.issueTitlePlaceholder': 'タイトル',
|
||||
'forge.actions.newIssue': '新しいイシュー',
|
||||
'forge.actions.draftChanged': '下書きの状態を更新しました',
|
||||
'forge.actions.edit': '編集',
|
||||
'forge.actions.error': '操作に失敗しました',
|
||||
@@ -1579,6 +1586,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '変更されたファイルはありません',
|
||||
'forge.files.noDiff': '差分はありません',
|
||||
'forge.loading': '読み込み中...',
|
||||
'forge.lookup.empty': '一致する項目がありません',
|
||||
'forge.lookup.loading': '検索中…',
|
||||
'forge.linkedSessions.count': 'この項目にリンクされているセッション: {count}',
|
||||
'forge.linkedSessions.open': 'セッション「{title}」を開く',
|
||||
'forge.linkedSessions.title': 'この項目に取り組んでいるチャット',
|
||||
|
||||
@@ -1545,6 +1545,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '이 이슈/PR을 닫을까요?',
|
||||
'forge.actions.comment': '댓글',
|
||||
'forge.actions.commentPlaceholder': '댓글 추가…',
|
||||
'forge.actions.createIssue': '이슈 만들기',
|
||||
'forge.actions.issueBodyPlaceholder': '문제를 설명하세요…',
|
||||
'forge.actions.issueCreated': '이슈 생성됨',
|
||||
'forge.actions.issueDialogTitle': '새 이슈',
|
||||
'forge.actions.issueLabelsPlaceholder': '라벨(쉼표로 구분)',
|
||||
'forge.actions.issueTitlePlaceholder': '제목',
|
||||
'forge.actions.newIssue': '새 이슈',
|
||||
'forge.actions.draftChanged': '초안 상태가 업데이트됨',
|
||||
'forge.actions.edit': '편집',
|
||||
'forge.actions.error': '작업 실패',
|
||||
@@ -1585,6 +1592,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '변경된 파일이 없습니다',
|
||||
'forge.files.noDiff': 'diff를 사용할 수 없습니다',
|
||||
'forge.loading': '불러오는 중...',
|
||||
'forge.lookup.empty': '일치하는 항목이 없습니다',
|
||||
'forge.lookup.loading': '검색 중…',
|
||||
'forge.linkedSessions.count': '이 항목에 연결된 세션: {count}',
|
||||
'forge.linkedSessions.open': '세션 "{title}" 열기',
|
||||
'forge.linkedSessions.title': '이 항목에서 작업 중인 채팅',
|
||||
|
||||
@@ -2026,6 +2026,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': 'Zamknąć to zgłoszenie/PR?',
|
||||
'forge.actions.comment': 'Skomentuj',
|
||||
'forge.actions.commentPlaceholder': 'Dodaj komentarz…',
|
||||
'forge.actions.createIssue': 'Utwórz zgłoszenie',
|
||||
'forge.actions.issueBodyPlaceholder': 'Opisz problem…',
|
||||
'forge.actions.issueCreated': 'Zgłoszenie utworzone',
|
||||
'forge.actions.issueDialogTitle': 'Nowe zgłoszenie',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Etykiety (rozdzielone przecinkami)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Tytuł',
|
||||
'forge.actions.newIssue': 'Nowe zgłoszenie',
|
||||
'forge.actions.draftChanged': 'Zaktualizowano status wersji roboczej',
|
||||
'forge.actions.edit': 'Edytuj',
|
||||
'forge.actions.error': 'Operacja nie powiodła się',
|
||||
@@ -2066,6 +2073,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': 'Brak zmienionych plików',
|
||||
'forge.files.noDiff': 'Brak dostępnego diff',
|
||||
'forge.loading': 'Wczytywanie...',
|
||||
'forge.lookup.empty': 'Brak wyników',
|
||||
'forge.lookup.loading': 'Szukanie…',
|
||||
'forge.linkedSessions.count': 'Sesje powiązane z tym elementem: {count}',
|
||||
'forge.linkedSessions.open': 'Otwórz sesję „{title}“',
|
||||
'forge.linkedSessions.title': 'Czaty pracujące nad tym',
|
||||
|
||||
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': 'Fechar este problema/PR?',
|
||||
'forge.actions.comment': 'Comentar',
|
||||
'forge.actions.commentPlaceholder': 'Adicionar um comentário…',
|
||||
'forge.actions.createIssue': 'Criar issue',
|
||||
'forge.actions.issueBodyPlaceholder': 'Descreva o problema…',
|
||||
'forge.actions.issueCreated': 'Issue criada',
|
||||
'forge.actions.issueDialogTitle': 'Nova issue',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Etiquetas (separadas por vírgulas)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Título',
|
||||
'forge.actions.newIssue': 'Nova issue',
|
||||
'forge.actions.draftChanged': 'Status de rascunho atualizado',
|
||||
'forge.actions.edit': 'Editar',
|
||||
'forge.actions.error': 'A ação falhou',
|
||||
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': 'Nenhum arquivo alterado',
|
||||
'forge.files.noDiff': 'Nenhum diff disponível',
|
||||
'forge.loading': 'Carregando...',
|
||||
'forge.lookup.empty': 'Nenhum resultado',
|
||||
'forge.lookup.loading': 'Buscando…',
|
||||
'forge.linkedSessions.count': 'Sessões vinculadas a este item: {count}',
|
||||
'forge.linkedSessions.open': 'Abrir sessão "{title}"',
|
||||
'forge.linkedSessions.title': 'Chats trabalhando nisso',
|
||||
|
||||
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': 'Закрити цей issue/PR?',
|
||||
'forge.actions.comment': 'Прокоментувати',
|
||||
'forge.actions.commentPlaceholder': 'Додати коментар…',
|
||||
'forge.actions.createIssue': 'Створити завдання',
|
||||
'forge.actions.issueBodyPlaceholder': 'Опишіть проблему…',
|
||||
'forge.actions.issueCreated': 'Завдання створено',
|
||||
'forge.actions.issueDialogTitle': 'Нове завдання',
|
||||
'forge.actions.issueLabelsPlaceholder': 'Мітки (через кому)',
|
||||
'forge.actions.issueTitlePlaceholder': 'Назва',
|
||||
'forge.actions.newIssue': 'Нове завдання',
|
||||
'forge.actions.draftChanged': 'Статус чернетки оновлено',
|
||||
'forge.actions.edit': 'Редагувати',
|
||||
'forge.actions.error': 'Дія не вдалася',
|
||||
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': 'Змінених файлів немає',
|
||||
'forge.files.noDiff': 'Diff недоступний',
|
||||
'forge.loading': 'Завантаження...',
|
||||
'forge.lookup.empty': 'Немає збігів',
|
||||
'forge.lookup.loading': 'Пошук…',
|
||||
'forge.linkedSessions.count': 'Сеанси, пов\'язані з цим елементом: {count}',
|
||||
'forge.linkedSessions.open': 'Відкрити сеанс «{title}»',
|
||||
'forge.linkedSessions.title': 'Чати, що працюють над цим',
|
||||
|
||||
@@ -1509,6 +1509,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '关闭此问题/PR?',
|
||||
'forge.actions.comment': '评论',
|
||||
'forge.actions.commentPlaceholder': '添加评论…',
|
||||
'forge.actions.createIssue': '创建问题',
|
||||
'forge.actions.issueBodyPlaceholder': '描述问题…',
|
||||
'forge.actions.issueCreated': '问题已创建',
|
||||
'forge.actions.issueDialogTitle': '新问题',
|
||||
'forge.actions.issueLabelsPlaceholder': '标签(逗号分隔)',
|
||||
'forge.actions.issueTitlePlaceholder': '标题',
|
||||
'forge.actions.newIssue': '新问题',
|
||||
'forge.actions.draftChanged': '草稿状态已更新',
|
||||
'forge.actions.edit': '编辑',
|
||||
'forge.actions.error': '操作失败',
|
||||
@@ -1549,6 +1556,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '无文件更改',
|
||||
'forge.files.noDiff': '无可用差异',
|
||||
'forge.loading': '加载中...',
|
||||
'forge.lookup.empty': '无匹配项',
|
||||
'forge.lookup.loading': '搜索中…',
|
||||
'forge.linkedSessions.count': '与此事项关联的会话:{count}',
|
||||
'forge.linkedSessions.open': '打开会话「{title}」',
|
||||
'forge.linkedSessions.title': '正在处理此事项的聊天',
|
||||
|
||||
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.actions.closeConfirm': '關閉此 Issue/PR?',
|
||||
'forge.actions.comment': '留言',
|
||||
'forge.actions.commentPlaceholder': '新增留言…',
|
||||
'forge.actions.createIssue': '建立問題',
|
||||
'forge.actions.issueBodyPlaceholder': '描述問題…',
|
||||
'forge.actions.issueCreated': '問題已建立',
|
||||
'forge.actions.issueDialogTitle': '新問題',
|
||||
'forge.actions.issueLabelsPlaceholder': '標籤(以逗號分隔)',
|
||||
'forge.actions.issueTitlePlaceholder': '標題',
|
||||
'forge.actions.newIssue': '新問題',
|
||||
'forge.actions.draftChanged': '草稿狀態已更新',
|
||||
'forge.actions.edit': '編輯',
|
||||
'forge.actions.error': '操作失敗',
|
||||
@@ -1559,6 +1566,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'forge.files.empty': '沒有檔案變更',
|
||||
'forge.files.noDiff': '沒有可用的差異',
|
||||
'forge.loading': '載入中...',
|
||||
'forge.lookup.empty': '無相符項目',
|
||||
'forge.lookup.loading': '搜尋中…',
|
||||
'forge.linkedSessions.count': '與此項目關聯的會話:{count}',
|
||||
'forge.linkedSessions.open': '開啟會話「{title}」',
|
||||
'forge.linkedSessions.title': '正在處理此項目的聊天',
|
||||
|
||||
Reference in New Issue
Block a user