Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ pin and a line to read before you move.

## [Unreleased]

### Added

- PR list icons reflect approvals, requested changes, closed and merged states using GitHub-style
colors. Detail metadata names current approving reviewers.

## [1.0.1] — 2026-09-12

### Changed
Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,19 @@ what let a *cold* client — a fresh reload, a different device, a daemon restar
calls too. `force` on `board.load` is still the Refresh button asking both layers to bypass
themselves at once. Whatever should outlive an unmount and is not shaped like a cache belongs in one
of the two settings stores instead — that is the only other thing here that survives one.

## Review status in PR lists

`shared/pr-review.ts` normalizes GitHub's `reviewDecision`, with `latestOpinionatedReviews` as the
fallback for repositories without required reviews. A truncated review page must not imply approval.
`server/board/item.ts` maps this information for every caller, including independent search views.
Projects fetch the same review fields in `server/projects/single.ts`; `client/lib/project-item-status.ts`
adapts their state to the shared PR icon renderer. Approval and requested changes keep the PR glyph,
with color and an accessible label carrying the review state.
Detail loading paginates current opinions to list all approving reviewers, ignoring dismissed,
comment-only, pending and superseded approvals. Cache namespaces change when review fields are added.

`client/lib/pr-status.ts` owns terminal/draft/review precedence and accessible status labels.
`client/theme/pr-colors.ts` uses GitHub's light/dark state hues because Paseo has no merged/purple
semantic token and arbitrary accent colors must not look like merge status. This intentional color
exception serves the GitHub status convention; other UI continues to use the host theme.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,14 @@ and the other plugins that lived beside it there are not carried here. Thanks to
starting point and for the MIT licence that made it possible.

Licensed under the MIT licence — see [LICENSE](LICENSE).

## Pull request review status

In the main board, saved views, and Projects, the leading PR icon is green for approval and orange for requested changes,
a red closed-PR icon for closed work, or a purple merge icon for merged work. Drafts remain neutral.
Merged and closed states take precedence over reviews. Status is also included in the row's
accessibility label; no extra review badges or text are added to the list.

The detail metadata lists **Approved by USERNAME** for each current approving reviewer.
Dismissed or superseded approvals are excluded. GitHub's aggregate review decision takes precedence;
when a repository has no required reviews, the plugin falls back to current reviewer opinions.
28 changes: 17 additions & 11 deletions client/board/item-row-format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prStatusIcon, type PrStatusColors } from "../lib/pr-status";
import type { BoardItem, CheckSummary, ColumnId } from "../../shared/board";
import type { SortOrder } from "../lib/sort";

Expand Down Expand Up @@ -43,6 +44,7 @@ export function describeRow({
type,
accentColor,
mutedColor,
statusColors,
closes,
hasLabelMenu,
isWeb,
Expand All @@ -53,6 +55,7 @@ export function describeRow({
type: ColumnId;
accentColor: string;
mutedColor: string;
statusColors: PrStatusColors;
/** The linked issues this row closes, already joined for the label. */
closes: string;
hasLabelMenu: boolean;
Expand All @@ -63,27 +66,30 @@ export function describeRow({
const stampLabel = stampMissing ? "updated" : order.rowLabel;
const stampDate = stampMissing ? item.updatedAt : stamp;

const iconName =
type === "draft-prs"
? "GitPullRequestDraft"
: type === "open-prs"
? "GitPullRequest"
: type === "discussions"
? "MessageSquare"
: "CircleDot";
const iconColor = type === "draft-prs" || type === "discussions" ? mutedColor : accentColor;
const pr =
type === "draft-prs" || type === "open-prs" ? prStatusIcon(item, type, statusColors) : null;
const iconName = pr?.iconName ?? (type === "discussions" ? "MessageSquare" : "CircleDot");
const iconColor = pr?.iconColor ?? (type === "discussions" ? mutedColor : accentColor);

const byline = item.author !== null && item.author !== viewerLogin ? item.author : null;

const openedBy = byline === null ? "" : `, opened by ${byline}`;
const linkedTo = closes === "" ? "" : `, closes ${closes}`;
const checksLabel = item.checks === null ? "" : `, checks ${checksSentence(item.checks)}`;
const accessibilityLabel = `${item.repository} #${item.number}: ${item.title}${openedBy}${linkedTo}${checksLabel}`;
const accessibilityLabel = `${item.repository} #${item.number}: ${item.title}${pr === null ? "" : `, ${pr.label}`}${openedBy}${linkedTo}${checksLabel}`;

let accessibilityHint: string | undefined;
if (hasLabelMenu) {
accessibilityHint = isWeb ? "Right-click to edit labels." : "Press and hold to edit labels.";
}

return { stampLabel, stampDate, iconName, iconColor, byline, accessibilityLabel, accessibilityHint };
return {
stampLabel,
stampDate,
iconName,
iconColor,
byline,
accessibilityLabel,
accessibilityHint,
};
}
1 change: 1 addition & 0 deletions client/board/item-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export const ItemRow = memo(function ItemRow({
type,
accentColor,
mutedColor,
statusColors: styles.prStatusColors,
closes,
hasLabelMenu: onLabels !== null,
isWeb,
Expand Down
5 changes: 5 additions & 0 deletions client/detail/detail-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export function DetailSummary({
{item.title}
</Text>
<Text style={styles.detailMeta}>{meta}</Text>
{(details?.approvedBy ?? []).map((login) => (
<Text key={login} style={styles.detailMeta}>
Approved by {login}
</Text>
))}
{details?.branches ? (
<Text style={styles.detailMeta}>
{details.branches.head} → {details.branches.base}
Expand Down
63 changes: 63 additions & 0 deletions client/lib/pr-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, it } from "vitest";
import { prStatusIcon } from "./pr-status";
import type { BoardItem } from "../../shared/board";

const item: BoardItem = {
id: "pr",
number: 1,
title: "Example",
url: "",
repository: "example/project",
owner: "example",
updatedAt: "",
createdAt: "",
lastCommitAt: null,
commentsCount: 0,
labels: [],
author: null,
detail: null,
relations: [],
linkedIssues: [],
checks: null,
};
const colors = {
approved: "green",
changesRequested: "orange",
closed: "red",
merged: "purple",
neutral: "gray",
};

it("keeps PR icons with accessible review colors without adding list text", () => {
expect(prStatusIcon({ ...item, prReview: "approved" }, "open-prs", colors)).toEqual({
iconName: "GitPullRequest",
iconColor: "green",
label: "Approved",
});
expect(prStatusIcon({ ...item, prReview: "changes-requested" }, "open-prs", colors)).toEqual({
iconName: "GitPullRequest",
iconColor: "orange",
label: "Changes requested",
});
expect(prStatusIcon(item, "open-prs", colors)).toMatchObject({
iconName: "GitPullRequest",
iconColor: "gray",
});
});

it("gives terminal and draft state precedence over old reviews", () => {
expect(
prStatusIcon(
{ ...item, prState: "MERGED", prReview: "changes-requested" },
"draft-prs",
colors,
),
).toMatchObject({ iconName: "GitMerge", iconColor: "purple" });
expect(
prStatusIcon({ ...item, prState: "CLOSED", prReview: "approved" }, "open-prs", colors),
).toMatchObject({ iconName: "GitPullRequestClosed", iconColor: "red" });
expect(prStatusIcon({ ...item, prReview: "approved" }, "draft-prs", colors)).toMatchObject({
iconName: "GitPullRequestDraft",
iconColor: "gray",
});
});
48 changes: 48 additions & 0 deletions client/lib/pr-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { BoardItem, ColumnId } from "../../shared/board";

export interface PrStatusColors {
approved: string;
changesRequested: string;
closed: string;
merged: string;
neutral: string;
}

/** Terminal state and draft status take precedence over earlier review opinions. */
export function prStatusIcon(
item: Pick<BoardItem, "prState" | "prReview">,
type: ColumnId,
colors: PrStatusColors,
) {
if (item.prState === "MERGED")
return { iconName: "GitMerge", iconColor: colors.merged, label: "Merged" };
if (item.prState === "CLOSED")
return {
iconName: "GitPullRequestClosed",
iconColor: colors.closed,
label: "Closed",
};
if (type === "draft-prs")
return {
iconName: "GitPullRequestDraft",
iconColor: colors.neutral,
label: "Draft",
};
if (item.prReview === "changes-requested")
return {
iconName: "GitPullRequest",
iconColor: colors.changesRequested,
label: "Changes requested",
};
if (item.prReview === "approved")
return {
iconName: "GitPullRequest",
iconColor: colors.approved,
label: "Approved",
};
return {
iconName: "GitPullRequest",
iconColor: colors.neutral,
label: item.prReview === "review-required" ? "Review required" : "Open",
};
}
39 changes: 39 additions & 0 deletions client/lib/project-item-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PluginTheme } from "@getpaseo/plugin";
import type { ProjectItem } from "../../shared/board";
import { prStatusColors } from "../theme/pr-colors";
import { prStatusIcon } from "./pr-status";

export function projectItemGlyph(item: ProjectItem, colors: PluginTheme["colors"]) {
if (item.kind === "draft")
return {
name: "FileText",
color: colors.foregroundMuted,
label: "Draft note",
};
if (item.kind === "pull-request") {
const status = prStatusIcon(
{
prState: item.state === "merged" ? "MERGED" : item.state === "closed" ? "CLOSED" : "OPEN",
prReview: item.prReview,
},
item.state === "draft" ? "draft-prs" : "open-prs",
prStatusColors(colors),
);
return {
name: status.iconName,
color: status.iconColor,
label: status.label,
};
}
if (item.state === "closed")
return {
name: "CircleSlash",
color: colors.statusDanger,
label: "Closed issue",
};
return {
name: "CircleDot",
color: colors.statusSuccess,
label: "Open issue",
};
}
35 changes: 14 additions & 21 deletions client/projects/project-board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ProjectItem } from "../../shared/board";
import { loadProject } from "../../shared/board";
import type { ProjectsStyles } from "./projects.styles";

type ThemeColors = PluginSurfaceProps["theme"]["colors"];
import { projectItemGlyph } from "../lib/project-item-status";

/** What `loadProject` answered with, plus the reference it was asked for. */
interface ProjectDetail {
Expand All @@ -17,23 +17,6 @@ interface ProjectDetail {
columns: readonly { name: string; items: readonly ProjectItem[] }[];
}

/**
* One item's leading glyph and colour, by what it is and where it stands.
* `merged` borrows the accent colour rather than inventing a purple the theme
* does not expose: the six status/accent tokens are what a plugin gets.
*/
function itemGlyph(item: ProjectItem, colors: ThemeColors): { name: string; color: string } {
if (item.kind === "draft") return { name: "FileText", color: colors.foregroundMuted };
if (item.kind === "pull-request") {
if (item.state === "merged") return { name: "GitMerge", color: colors.accent };
if (item.state === "closed") return { name: "GitPullRequestClosed", color: colors.statusDanger };
if (item.state === "draft") return { name: "GitPullRequestDraft", color: colors.foregroundMuted };
return { name: "GitPullRequest", color: colors.statusSuccess };
}
if (item.state === "closed") return { name: "CircleSlash", color: colors.statusDanger };
return { name: "CircleDot", color: colors.statusSuccess };
}

/**
* One project rendered as its own board: the columns GitHub's own project
* view groups items into, folded down to the ones that hold something
Expand Down Expand Up @@ -70,7 +53,12 @@ export function ProjectBoardView({
setDetailLoading(true);
fetchProject({ owner, number, force: false })
.then((result) => {
if (live) setDetail({ title: result.title, url: result.url, columns: result.columns });
if (live)
setDetail({
title: result.title,
url: result.url,
columns: result.columns,
});
})
.catch((cause: unknown) => {
if (live) setDetailError(cause instanceof Error ? cause.message : String(cause));
Expand All @@ -96,13 +84,16 @@ export function ProjectBoardView({

const renderProjectItem = useCallback(
(item: ProjectItem, spaced: boolean) => {
const glyph = itemGlyph(item, theme.colors);
const glyph = projectItemGlyph(item, theme.colors);
const rowStyle = spaced ? [styles.itemRow, styles.itemRowSpaced] : styles.itemRow;
return (
<Pressable
key={item.id}
style={rowStyle}
accessibilityRole={item.url !== null ? "link" : "text"}
accessibilityLabel={[item.title, item.repository, item.number, glyph.label]
.filter((value) => value !== null && value !== undefined)
.join(", ")}
disabled={item.url === null}
onPress={item.url !== null ? () => onOpenUrl(item.url as string) : undefined}
>
Expand Down Expand Up @@ -153,7 +144,9 @@ export function ProjectBoardView({
<ScrollView contentContainerStyle={styles.boardBody}>
{visibleColumns.map((column) => (
<View key={column.name === "" ? "\0no-status" : column.name} style={styles.group}>
<Text style={styles.groupTitle}>{column.name === "" ? "No status" : column.name}</Text>
<Text style={styles.groupTitle}>
{column.name === "" ? "No status" : column.name}
</Text>
{column.items.map((item, index) => renderProjectItem(item, index > 0))}
</View>
))}
Expand Down
28 changes: 28 additions & 0 deletions client/theme/pr-colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { PluginTheme } from "@getpaseo/plugin";
import type { PrStatusColors } from "../lib/pr-status";

/** GitHub's state hues stay distinct even when a Paseo theme uses a purple accent. */
export function prStatusColors(colors: PluginTheme["colors"]): PrStatusColors {
const hex = /^#([0-9a-f]{6})$/i.exec(colors.surface0)?.[1];
const brightness =
hex === undefined
? 255
: 0.2126 * parseInt(hex.slice(0, 2), 16) +
0.7152 * parseInt(hex.slice(2, 4), 16) +
0.0722 * parseInt(hex.slice(4, 6), 16);
return brightness < 128
? {
approved: "#3fb950",
changesRequested: "#db6d28",
closed: "#f85149",
merged: "#a371f7",
neutral: colors.foregroundMuted,
}
: {
approved: "#1a7f37",
changesRequested: "#bc4c00",
closed: "#d1242f",
merged: "#8250df",
neutral: colors.foregroundMuted,
};
}
Loading