Files
paseo/packages/app/src/git/pull-request-panel/data.ts
T
paseo-bot[bot]andDillon 48059aeae3 Show the PR panel's state, activity verbs, and thread badges in the app language (#6225)
* fix(app): show PR panel state, activity verbs, and thread badges in the app language

The PR panel's state label, the verb after each activity author, and the
Resolved and Outdated thread badges were English literals, so they stayed
English in every other language. The state and verb translations already
existed and were unused; the badges get new keys in every locale.

Closes #6214

* fix(app): translate every PR panel verb and state, and phrase them after the author

The zh-CN state and review labels still held English ("Open", "Draft",
"已 review"), and the Spanish and Portuguese verbs were adjectives that
do not read after an author's name. The translation test now checks
each verb and state on its own, so one English label fails it.

Co-authored-by: Dillon <dillonzq@outlook.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Dillon <dillonzq@outlook.com>
2026-10-06 18:03:10 +02:00

336 lines
10 KiB
TypeScript

import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { type Forge, getForgePresentation } from "@/git/forge";
import type { PresentableCheck } from "@/git/check-presentation";
import { parseClientForgeFacts } from "@/git/forges";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
import { type CheckStatus, mapCheckStatus } from "./check-status";
import { getNativeFallbackChecks } from "./native-data";
export type { CheckStatus } from "./check-status";
export type PrState = "open" | "draft" | "merged" | "closed";
export type ReviewState = "approved" | "changes_requested" | "commented";
export type ActivityKind = "review" | "comment";
export type PullRequestProvider = Forge;
export interface PullRequestProviderMetadata {
id: PullRequestProvider;
label: string;
url?: string | null;
}
export interface PrPaneCheck extends PresentableCheck {
provider: PullRequestProvider;
name: string;
workflow?: string;
status: CheckStatus;
/**
* What the row says about time: how long a finished check took ("2m"), or how long a
* running one has been going ("running 2m"). Absent when the forge reports neither.
*/
timing?: string;
isManual?: boolean;
requiresAction?: boolean;
url: string;
/**
* Forge-neutral reference for fetching this check's detail/logs on demand. Any
* forge that exposes a check-run id populates it; the daemon resolves the logs
* through the neutral check-details RPC.
*/
detailRef?: {
checkRunId?: number;
workflowRunId?: number;
};
}
export interface PrPaneActivity {
provider: PullRequestProvider;
id: string;
kind: ActivityKind;
author: string;
authorUrl?: string | null;
avatarColor: string;
avatarUrl?: string | null;
reviewState?: ReviewState;
body: string;
age: string;
url: string;
/** For inline review comments: the review this comment was submitted with. */
reviewId?: string;
/**
* Forge-neutral discussion id, independent of a file position. Groups general
* (non-file) reply chains into one thread; file threads also carry it.
*/
threadId?: string;
/**
* Resolution state for a thread with no file position (e.g. a GitLab general
* discussion). File threads carry resolution under `location.isResolved`.
*/
threadIsResolved?: boolean;
location?: {
path: string;
line?: number;
startLine?: number;
threadId?: string;
isResolved?: boolean;
isOutdated?: boolean;
};
}
export interface PrPaneData {
provider: PullRequestProviderMetadata;
/** The forge hosting this change request. */
forge: Forge;
number: number;
repoOwner?: string;
repoName?: string;
/** Neutral project identity (GitLab namespaces nest beyond owner/name). */
projectPath?: string;
title: string;
state: PrState;
url: string;
reviewDecision: "approved" | "changes_requested" | "pending";
awaitingReviewers: string[];
checks: PrPaneCheck[];
/**
* The forge's already-validated native facts, passed through so pane native
* contributions (e.g. GitLab pipeline/approvals) derive their surfaces without
* the neutral data type carrying forge-specific fields.
*/
forgeSpecific?: ForgeSpecificStatusFacts;
activity: PrPaneActivity[];
}
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
type PullRequestTimelineItem = PullRequestTimeline["items"][number];
export function mapPrPaneData(
status: CheckoutPrStatus,
timeline: PullRequestTimeline | null | undefined,
nowMs = Date.now(),
forge: Forge = "github",
): PrPaneData | null {
if (!status) {
return null;
}
const number = status.number ?? parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
const timelineMatchesStatus = timeline?.prNumber === number;
const provider = toProviderMetadata(forge);
const forgeSpecific = parseClientForgeFacts(status.forgeSpecific);
return {
provider,
forge,
number,
repoOwner: status.repoOwner,
repoName: status.repoName,
projectPath: status.projectPath,
title: status.title,
state: derivePrState(status),
url: status.url,
reviewDecision: mapReviewDecision(status.reviewDecision),
// Requested reviewers are intentionally unwired until the server exposes them.
awaitingReviewers: [],
checks: mapChecks(status, forge),
...(forgeSpecific ? { forgeSpecific } : {}),
activity: timelineMatchesStatus
? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge))
: [],
};
}
function toProviderMetadata(forge: Forge): PullRequestProviderMetadata {
return { id: forge, label: getForgePresentation(forge).brandLabel };
}
// Avatars are identity, not status: they draw from the shared identity table so a PR
// participant square sits at the same weight as a project icon. Logins are matched
// case-insensitively, so the same person keeps one color across forges.
export function deriveAvatarColor(login: string): string {
return identityColor(deriveIdentityColorName(login.toLowerCase()));
}
export function formatAge(createdAtMs: number, nowMs = Date.now()): string {
const elapsedMs = Math.max(0, nowMs - createdAtMs);
const elapsedSeconds = Math.floor(elapsedMs / 1000);
if (elapsedSeconds < 60) {
return "just now";
}
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
if (elapsedMinutes < 60) {
return `${elapsedMinutes}m ago`;
}
const elapsedHours = Math.floor(elapsedMinutes / 60);
if (elapsedHours < 24) {
return `${elapsedHours}h ago`;
}
const elapsedDays = Math.floor(elapsedHours / 24);
if (elapsedDays < 30) {
return `${elapsedDays}d ago`;
}
if (elapsedDays < 365) {
return `${Math.floor(elapsedDays / 30)}mo ago`;
}
return `${Math.floor(elapsedDays / 365)}y ago`;
}
function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
if (status.isMerged || status.state === "merged") {
return "merged";
}
if (status.state !== "open") {
return "closed";
}
if (status.isDraft) {
return "draft";
}
return "open";
}
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
const checks = (status.checks ?? []).map((check) => mapCheck(check, forge, status.url));
if (checks.length > 0) {
return checks;
}
return getNativeFallbackChecks(status, forge);
}
function mapCheck(
check: NonNullable<CheckoutPrStatus>["checks"][number],
forge: Forge,
fallbackUrl: string,
): PrPaneCheck {
const status = mapCheckStatus(check.status);
const timing = formatCheckTiming(check.duration, status);
return {
provider: forge,
name: check.name,
status,
...(check.traits ? { traits: check.traits } : {}),
url: check.url ?? fallbackUrl,
...(check.workflow ? { workflow: check.workflow } : {}),
...(timing ? { timing } : {}),
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
? {
detailRef: {
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
},
}
: {}),
};
}
/**
* The forge measures how long a check ran, whether or not it has finished. Which one it
* is comes from the status: on a running check a bare "2m" would read as "took 2m", so
* the row says the run is still going.
*/
function formatCheckTiming(duration: string | undefined, status: CheckStatus): string | null {
if (!duration) {
return null;
}
return status === "pending" ? `running ${duration}` : duration;
}
function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] {
if (item.kind === "comment") {
if (item.body.trim() === "") {
return [];
}
return [
{
id: item.id,
provider: forge,
kind: "comment",
author: item.author,
authorUrl: item.authorUrl,
avatarColor: deriveAvatarColor(item.author),
avatarUrl: item.avatarUrl,
body: item.body,
age: formatAge(item.createdAt, nowMs),
url: item.url,
reviewId: item.reviewId,
threadId: item.threadId,
threadIsResolved: item.threadIsResolved,
location: item.location,
},
];
}
if (item.reviewState === "commented" && item.body.trim() === "") {
return [];
}
return [
{
id: item.id,
provider: forge,
kind: "review",
author: item.author,
authorUrl: item.authorUrl,
avatarColor: deriveAvatarColor(item.author),
avatarUrl: item.avatarUrl,
reviewState: item.reviewState,
body: item.body,
age: formatAge(item.createdAt, nowMs),
url: item.url,
},
];
}
function mapReviewDecision(
reviewDecision: NonNullable<CheckoutPrStatus>["reviewDecision"],
): PrPaneData["reviewDecision"] {
if (reviewDecision === "approved" || reviewDecision === "changes_requested") {
return reviewDecision;
}
return "pending";
}
function parsePullRequestNumber(url: string): number | null {
try {
const match = new URL(url).pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
export function getStateLabelKey(state: PrState) {
if (state === "draft") return "workspace.git.pr.states.draft";
if (state === "merged") return "workspace.git.pr.states.merged";
if (state === "closed") return "workspace.git.pr.states.closed";
return "workspace.git.pr.states.open";
}
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
if (item.reviewState === "changes_requested") {
return "workspace.git.pr.activity.requestedChanges";
}
return "workspace.git.pr.activity.reviewed";
}