Skip to content
Merged
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
21 changes: 19 additions & 2 deletions src/patch-sources.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,26 @@ function bodyCitesTicket(body, ticketId) {
return re.test(body);
}

/**
* What happened to one pull request, from a `search/issues` item: open, merged
* or closed-unmerged.
*
* @param {Object} item
* @return {'open'|'merged'|'closed'}
*/
function prState(item) {
if (item.pull_request && item.pull_request.merged_at) return 'merged';
return item.state === 'closed' ? 'closed' : 'open';
}

/**
* Reduces a GitHub `search/issues` response to the PRs that cite the ticket.
* Returns newest-first — for a moving target like a PR the freshest is the one
* a contributor most likely wants.
*
* @param {Object} searchJson
* @param {number|string} ticketId
* @return {Array<{number: number, title: string, state: string, updatedAt: string, url: string}>}
* @return {Array<{number: number, title: string, state: 'open'|'merged'|'closed', updatedAt: string, url: string}>}
*/
function parseLinkedPrs(searchJson, ticketId) {
const items = searchJson && Array.isArray(searchJson.items) ? searchJson.items : [];
Expand All @@ -94,7 +106,12 @@ function parseLinkedPrs(searchJson, ticketId) {
prs.push({
number: item.number,
title: typeof item.title === 'string' ? item.title : '',
state: item.state === 'closed' ? 'closed' : 'open',
// Merged is a third state, not a flavour of closed: `state` only ever
// says open or closed, and the merge shows in `pull_request.merged_at`
// — which this same search response already carries, so keeping the
// distinction costs no second request against the shared
// unauthenticated quota this file is careful with.
state: prState(item),
updatedAt: item.updated_at || item.created_at || '',
url: item.html_url || `https://github.com/WordPress/wordpress-develop/pull/${item.number}`
});
Expand Down
19 changes: 16 additions & 3 deletions src/renderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { trunkAgeInfo, planUpdateSteps, updateStepStatuses, SKIP_INSTALL_MESSAGE
import { pickLatest } from '../latest-patch.cjs';
import { beginSetup, adoptSetupPath, discardSetup, rowPathAfterStatus } from './pending-setup.cjs';
import { parsePrRef } from '../patch-sources.cjs';
import { prStateBadge } from './pr-state.cjs';
import { ticketUrl, attachUrl } from './trac-ticket.cjs';
import { ticketBranchRows, ticketListCard } from './ticket-branch-list.cjs';
import { describeSwitchProgress } from '../switch-progress.cjs';
Expand Down Expand Up @@ -2437,11 +2438,22 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
const patchAttachments = (tracAttachments?.items || []).filter((a) => a.applyable);
const tracAttachmentsRead = tracAttachments
&& (tracAttachments.status === 'ok' || tracAttachments.status === 'no-attachments');
// One pill shape, two uses: the "Latest" marker on a patch row and a linked
// pull request's state. Only the words and the colours differ.
const pillStyle = { display: 'inline-flex', alignItems: 'center', flex: '0 0 auto', padding: '1px 7px', borderRadius: 999, fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' };
const latestPill = (isLatest) => (isLatest ? (
<span style={{ display: 'inline-flex', alignItems: 'center', flex: '0 0 auto', padding: '1px 7px', borderRadius: 999, fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', background: '#e7f1ff', color: '#0b5d95', marginLeft: 8 }}>
<span style={{ ...pillStyle, background: '#e7f1ff', color: '#0b5d95', marginLeft: 8 }}>
Latest
</span>
) : null);
const prStatePill = (state) => {
const badge = prStateBadge(state);
return (
<span style={{ ...pillStyle, background: badge.background, color: badge.color }}>
{badge.label}
</span>
);
};

const finishApply = (message) => {
markTerminalRunning(false);
Expand Down Expand Up @@ -3850,8 +3862,9 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
</span>
{latestPill(latestPatch?.kind === 'pr' && latestPatch.key === pr.number)}
</div>
<div style={{ fontSize: 11, color: '#6c6f72' }}>
{pr.state === 'closed' ? 'closed' : 'open'}{pr.updatedAt ? ` · updated ${new Date(pr.updatedAt).toLocaleDateString()}` : ''}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2, fontSize: 11, color: '#6c6f72' }}>
{prStatePill(pr.state)}
{pr.updatedAt ? <span>updated {new Date(pr.updatedAt).toLocaleDateString()}</span> : null}
</div>
</div>
<Button
Expand Down
45 changes: 45 additions & 0 deletions src/renderer/pr-state.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// The colours a linked pull request's state is shown in (#227).
//
// The state used to render as grey text the same weight as the date beside it,
// separated by a middle dot, so the one word that says what happened to the
// work read as part of the timestamp. Colour makes the outcome legible at a
// glance — GitHub's own three, so the pill matches what the contributor sees
// after clicking through to the pull request itself.
//
// Two rules the colours are chosen under:
//
// - The colour accompanies the word, never replaces it. A bare dot tells
// someone who cannot separate red from green less than the plain text did.
// - Red means "something failed" everywhere else in this window — a half-done
// update, an error banner. A closed pull request is not a failure, so the
// closed pill deliberately uses GitHub's red (#ffebe9/#82071e) rather than
// this app's error pair (#fcf0f1 on a #d63638 border), and wears the same
// borderless pill shape as the "Latest" marker instead of the bordered,
// alert-shaped box the errors use.
'use strict';

// GitHub's light-theme state colours: the subtle background and the text
// foreground of each state's own label.
const PR_STATE_BADGES = {
open: { label: 'open', background: '#dafbe1', color: '#116329' },
merged: { label: 'merged', background: '#f5e8ff', color: '#6639ba' },
closed: { label: 'closed', background: '#ffebe9', color: '#82071e' }
};

/**
* The label and colours for one pull request's state.
*
* An unrecognised state reads as open, which is what the row has always done —
* a list cached by an older build carries only `open` and `closed`, and
* anything that is not closed has always been shown as open rather than
* dropped or left blank.
*
* @param {string} state
* @return {{label: string, background: string, color: string}}
*/
function prStateBadge(state) {
const key = typeof state === 'string' ? state.toLowerCase() : '';
return PR_STATE_BADGES[key] || PR_STATE_BADGES.open;
}

module.exports = { PR_STATE_BADGES, prStateBadge };
25 changes: 25 additions & 0 deletions test/patch-sources.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ test('parseLinkedPrs: a closed PR is marked closed, not dropped (issue #11)', ()
assert.strictEqual(prs[0].state, 'closed');
});

// The three states the row colours (#227). GitHub's `state` says only open or
// closed, so a merged PR arrives as closed and the merge is visible solely in
// `pull_request.merged_at` — collapsing it loses which of two very different
// outcomes the work reached.
test('parseLinkedPrs: a merged PR is merged, not closed (issue #227)', () => {
const prs = parseLinkedPrs({
items: [item(7, { state: 'closed', pull_request: { url: 'x', merged_at: '2026-08-02T00:00:00Z' } })]
}, 62281);
assert.strictEqual(prs[0].state, 'merged');
});

test('parseLinkedPrs: closed with nothing merged stays closed (issue #227)', () => {
const prs = parseLinkedPrs({
items: [item(7, { state: 'closed', pull_request: { url: 'x', merged_at: null } })]
}, 62281);
assert.strictEqual(prs[0].state, 'closed');
});

test('parseLinkedPrs: an open PR stays open (issue #227)', () => {
const prs = parseLinkedPrs({
items: [item(7, { state: 'open', pull_request: { url: 'x', merged_at: null } })]
}, 62281);
assert.strictEqual(prs[0].state, 'open');
});

test('parseLinkedPrs: an empty or malformed response yields an empty list, not a throw (issue #11)', () => {
assert.deepStrictEqual(parseLinkedPrs({ items: [] }, 62281), []);
assert.deepStrictEqual(parseLinkedPrs({}, 62281), []);
Expand Down
69 changes: 69 additions & 0 deletions test/pr-state.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use strict';

// Colouring a linked pull request by its state (#227). No test renders the DOM
// here, so what is testable is the mapping the row reads its colours from, and
// that the row still reads it rather than restating the old grey text.

const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { PR_STATE_BADGES, prStateBadge } = require('../src/renderer/pr-state.cjs');

const INDEX_JSX = path.join(__dirname, '..', 'src', 'renderer', 'index.jsx');

// The accessibility rule the design is built on: colour accompanies the word,
// it never replaces it. A pill with a colour and no label tells a contributor
// who cannot separate red from green less than the plain text it replaced.
test('every state keeps its word, not only its colour (issue #227)', () => {
for (const [state, badge] of Object.entries(PR_STATE_BADGES)) {
assert.strictEqual(badge.label, state, `${state} must be labelled with its own word`);
}
});

test('the three states are told apart by colour, all three of them (issue #227)', () => {
const backgrounds = Object.values(PR_STATE_BADGES).map((b) => b.background);
const foregrounds = Object.values(PR_STATE_BADGES).map((b) => b.color);
assert.strictEqual(new Set(backgrounds).size, 3, 'two states share a background, so they read as the same outcome');
assert.strictEqual(new Set(foregrounds).size, 3, 'two states share a text colour');
});

// Red means "something failed" everywhere else in this window. A closed pull
// request is not a failure, so the closed pill must not be dressed as one: it
// wears GitHub's red, not the error pair the alert boxes are painted with.
test('the closed pill is not the error styling used elsewhere (issue #227)', () => {
const closed = prStateBadge('closed');
const source = fs.readFileSync(INDEX_JSX, 'utf8');
assert.ok(source.includes('#fcf0f1'), 'the error banner background moved; this test no longer compares against the real one');
assert.notStrictEqual(closed.background, '#fcf0f1');
assert.notStrictEqual(closed.color, '#d63638');
});

test('an unknown or missing state reads as open, the way the row has always behaved (issue #227)', () => {
assert.deepStrictEqual(prStateBadge('draft'), PR_STATE_BADGES.open);
assert.deepStrictEqual(prStateBadge(''), PR_STATE_BADGES.open);
assert.deepStrictEqual(prStateBadge(undefined), PR_STATE_BADGES.open);
assert.deepStrictEqual(prStateBadge(null), PR_STATE_BADGES.open);
// GitHub answers in lower case, but a cached list should not lose its colour
// over capitalisation either.
assert.deepStrictEqual(prStateBadge('MERGED'), PR_STATE_BADGES.merged);
});

test('the row renders the state through the pill, not as grey text (issue #227)', () => {
const source = fs.readFileSync(INDEX_JSX, 'utf8');

// The words come from this module, so the row cannot say one thing while the
// tested mapping says another.
assert.ok(
!/pr\.state === 'closed' \? 'closed' : 'open'/.test(source),
'index.jsx still collapses the state to its own open/closed text instead of using prStateBadge'
);

// One call site: the single pull-request row. Counted as a call rather than
// as the bare name so a comment naming the helper is not a red suite.
assert.strictEqual(
source.split('prStatePill(').length - 1,
1,
'expected exactly one prStatePill( call: the linked pull request row'
);
});
Loading