mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import type { PrPaneActivity } from "./data";
|
|
|
|
export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
|
|
|
|
export interface PrThreadEntry {
|
|
kind: "thread";
|
|
id: string;
|
|
/** Absent for general (non-file) discussion threads that carry no position. */
|
|
location?: PrThreadLocation;
|
|
/**
|
|
* Thread resolution state, unified across file threads (`location.isResolved`)
|
|
* and general discussions (`threadIsResolved`). Absent when the forge exposes no
|
|
* resolution for the thread.
|
|
*/
|
|
isResolved?: boolean;
|
|
comments: PrPaneActivity[];
|
|
}
|
|
|
|
export interface PrReviewEntry {
|
|
kind: "review";
|
|
id: string;
|
|
review: PrPaneActivity;
|
|
threads: PrThreadEntry[];
|
|
}
|
|
|
|
export type PrTimelineEntry =
|
|
| { kind: "single"; id: string; activity: PrPaneActivity }
|
|
| PrThreadEntry
|
|
| PrReviewEntry;
|
|
|
|
/**
|
|
* Builds the GitHub-style nested timeline:
|
|
* - comments sharing a thread id collapse into one thread entry (root comment +
|
|
* replies), placed at the first comment's position. The id is the top-level
|
|
* `threadId` (forge-neutral, used by GitLab general discussions) falling back
|
|
* to `location.threadId` (file-position threads);
|
|
* - threads whose root comment carries a `reviewId` nest under that review
|
|
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
|
|
* - everything else stays a standalone entry in original order.
|
|
*/
|
|
export function buildPrTimeline(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
|
|
const entries = groupThreads(activities);
|
|
return nestThreadsUnderReviews(entries);
|
|
}
|
|
|
|
function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
|
|
const entries: PrTimelineEntry[] = [];
|
|
const threadsById = new Map<string, PrThreadEntry>();
|
|
|
|
for (const activity of activities) {
|
|
const threadId = activity.threadId ?? activity.location?.threadId;
|
|
if (!threadId) {
|
|
entries.push({ kind: "single", id: activity.id, activity });
|
|
continue;
|
|
}
|
|
|
|
const existing = threadsById.get(threadId);
|
|
if (existing) {
|
|
existing.comments.push(activity);
|
|
continue;
|
|
}
|
|
|
|
const resolved = activity.location?.isResolved ?? activity.threadIsResolved;
|
|
const thread: PrThreadEntry = {
|
|
kind: "thread",
|
|
id: `thread:${threadId}`,
|
|
...(activity.location ? { location: activity.location } : {}),
|
|
...(resolved !== undefined ? { isResolved: resolved } : {}),
|
|
comments: [activity],
|
|
};
|
|
threadsById.set(threadId, thread);
|
|
entries.push(thread);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function nestThreadsUnderReviews(entries: PrTimelineEntry[]): PrTimelineEntry[] {
|
|
const reviewIds = new Set(
|
|
entries.flatMap((entry) =>
|
|
entry.kind === "single" && entry.activity.kind === "review" ? [entry.activity.id] : [],
|
|
),
|
|
);
|
|
const threadsByReviewId = new Map<string, PrThreadEntry[]>();
|
|
|
|
for (const entry of entries) {
|
|
if (entry.kind !== "thread") {
|
|
continue;
|
|
}
|
|
const reviewId = entry.comments[0]?.reviewId;
|
|
if (!reviewId || !reviewIds.has(reviewId)) {
|
|
continue;
|
|
}
|
|
const threads = threadsByReviewId.get(reviewId) ?? [];
|
|
threads.push(entry);
|
|
threadsByReviewId.set(reviewId, threads);
|
|
}
|
|
|
|
if (threadsByReviewId.size === 0) {
|
|
return entries;
|
|
}
|
|
|
|
const nestedThreads = new Set([...threadsByReviewId.values()].flat());
|
|
return entries.flatMap((entry): PrTimelineEntry[] => {
|
|
if (entry.kind === "thread") {
|
|
return nestedThreads.has(entry) ? [] : [entry];
|
|
}
|
|
if (entry.kind === "single" && entry.activity.kind === "review") {
|
|
const threads = threadsByReviewId.get(entry.activity.id);
|
|
if (threads) {
|
|
return [{ kind: "review" as const, id: entry.id, review: entry.activity, threads }];
|
|
}
|
|
}
|
|
return [entry];
|
|
});
|
|
}
|