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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Notable changes per release. Versions follow semver. PostPile is alpha software:
- PostPile now marks handled notifications read on GitHub by default, so a busy inbox goes down instead of piling up. Installs that never touched the GitHub writes lock get writes on with this update; installs that locked writes on purpose stay locked. Mark-reads that waited while writes were locked go to GitHub only where nothing happened on the thread since you clicked; the rest stay unread, and anything that fails stays in the footer to send or discard.
- The GitHub writes lock now lives only in the footer: a small lock icon while writes are on, a quiet "read-only" while they are locked. The busy inbox card no longer asks you to unlock writes.

### Removed

- PostPile no longer shows CI status: the PR pane's Checks fact, the CI lines in a PR's activity and the "Until CI is green" snooze are gone. A snooze set that way ends after the update, like one whose time is up. CI results go stale fast and bringing a PR to green is its author's job, so they never drove anything in PostPile; now it doesn't fetch them either. That makes each PR fetch from GitHub smaller and faster (on PRs with many checks about a quarter less data), and the checks already stored are removed once in the background a little after the update.

### Fixed

- A tile that already says "Not yours" no longer offers "Not mine" in its ⋯ menu. Mark read clears it. A stack or set only counts when its verdict says Not yours, so one PR the agent calls Not yours next to one that needs a look keeps the option.
Expand Down
145 changes: 96 additions & 49 deletions DESIGN.md

Large diffs are not rendered by default.

44 changes: 34 additions & 10 deletions NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ now".
build with `POSTPILE_FAKE_BUSY=1`: footer on, locked, locked with one
pending; busy card and Why? without the lock. Not tried: the default
switch on a real database copy with a real backlog.
- No CI (2026-10-05, for 0.21.0; DESIGN.md "CI is not tracked"; step 3 of
normalizing the PR snapshot, Later): the PR query asks for no checks,
`Pr` has none, the CI event, the pane's Checks fact, the CLI rollup and
the "Until CI is green" snooze are gone (a stored one ends like an
expired snooze). Migration 030 deletes the CI events and their log rows;
the storage job `checks_strip` removes the old checks from the stored
JSON; reads drop them meanwhile; every PR read runs in one read
transaction. Measured: on 12 PostHog PRs the batch response 621 → 471
KB and 5.3–8.1 → 3.7–4.3 s; heavy copy 93 MB of JSON freed in 138 slices
(max 54 ms), migration 0.5 s; hot-set heap 283 → 264 MB. Not tried by
hand: the app on a real database.
- No "Not mine" on a Not yours tile (2026-10-05, for 0.21.0; DESIGN.md
Product model › "Action details"): core's `TileOffers.notMine` leaves it out of
the tile's ⋯ menu while the verdict pill says Not yours, read from
Expand Down Expand Up @@ -1339,30 +1350,32 @@ the app meanwhile.
(`pr`, migration 028), the rest of each PR is one JSON blob in
`pr_snapshot.json`: comments are half of it (95% of their text from
bots, cut since 0.19.0), thread comments and review bodies are second
copies of comments, and check contexts are 10% that no rule reads. So a
copies of comments, and check contexts were 10% (dropped in step 3). So a
hot board parses whole PRs to read a few fields. Design checked with
Codex GPT-6.1 (2026-10-05); its review points win where they differ from
the first draft. Estimate from prototyped tables, hot set of 1,500 PRs on
the heavy copy: 284 MB of heap today, about 140 MB with comment rows,
about 60 MB with the board diet. The plan, one PR each, in this order:
1. Newer-schema guard: `openDatabase` refuses a database from a newer
PostPile (DESIGN.md "Safety while building"). Ships before anything
destructive.
PostPile (DESIGN.md "Safety while building"). Done, 0.20.0.
2. One storage job runner (`packages/engine/src/storage-jobs/`), with the
bot body trim ported as its first job under the trim's existing meta
keys. Fails closed (never `done` unless the job's check passes),
bounded `BEGIN IMMEDIATE` units with a short busy timeout that
reschedule on SQLITE_BUSY, ~30 ms slices 50 ms apart, pauses while
sync, poll, consolidation or catch-up run and while the Mac sleeps,
cursor and done flag in the unit's transaction, telemetry
`storage_job_done`.
3. Checks summary pilot (migration 030): `pr.rows_version` and `check_*`
header columns (rollup, passed / failed / pending / total, newest
finish, FAILURE names), dual-write, a backfill job, the read switch.
Next, after the runner.
`storage_job_done`. Done, 0.20.0.
3. Drop CI checks (decided 2026-10-05, replacing the checks summary
pilot): no checks fetched or stored, CI events deleted (migration
030), the old checks stripped from the stored JSON by the storage job
`checks_strip`, every PR read in one read transaction (DESIGN.md "CI is
not tracked"). Built for 0.21.0. `rows_version` waits for step 4.
4. Comments, reviews and threads as rows (`pr_comment`, `pr_thread`,
`pr_review`, header `mentioned_teams`), shipped in one release together
with the strip of the switched fields from the stored JSON.
`pr_review`, header `mentioned_teams`, `rows_version`), shipped in one
release together with the strip of the switched fields from the stored
JSON. The first phase that runs the dual-write, backfill and read
switch protocol.
5. Board diet: board reads leave out bot bodies no rule reads
(`isBodyReadByRules`), `FullPr` for the readers that need every body
(event derivation, write actions, lessons, "Why?" excerpts).
Expand Down Expand Up @@ -1431,6 +1444,17 @@ the app meanwhile.

## Decided

- **Drop CI checks: costly to fetch, usually stale, deprioritized**
(2026-10-05, DESIGN.md "CI is not tracked"). PostPile fetches no
checks, keeps no CI event, shows no Checks fact and offers no "Until CI
is green" snooze. Replaces the checks summary pilot (a summary on the PR
header with a backfill; built, not shipped). Fetching the checks was the
costliest part of a PR fetch (on PostHog PRs with 100 checks: a quarter of
the response, and the query ran in about 60% of the time without them),
and CI had been off everything that ranks or speaks since 2026-09-29. The
stored CI events go in migration 030 rather than with the next
re-derivation, which never comes for merged and closed PRs.

- **No "Not mine" where the tile already says Not yours** (2026-10-05, owner
report): the menu offered to teach the agent what its verdict already
said. Mark read is the way to clear such a tile. A stack or set counts as
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function formatPr(detail: PrDetail, events: EventView[]): string {
const { pr } = detail;
const lines = [
`${pr.key} ${pr.title}`,
`${pr.state.toLowerCase()} by ${pr.author}, +${pr.additions} -${pr.deletions}, CI ${pr.checks.rollup.toLowerCase()}`,
`${pr.state.toLowerCase()} by ${pr.author}, +${pr.additions} -${pr.deletions}`,
pr.url,
`topic ${detail.topicId ?? 'none'}, tiles ${detail.tileIds.join(', ') || 'none'}`,
];
Expand Down
16 changes: 8 additions & 8 deletions apps/desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ with a `title` that says why. Hiding it makes the gap invisible to the next agen
type (`views.ts`), fill it in the engine's `read-models.ts` and in
`FakeEngine`, then read it here.
- `PrDetail.pr` is core's slim `PrPaneView` (`pr-pane.ts`), never the
stored `Pr`: no comments, threads, commits, timeline or check contexts.
A new pane field goes into `prPaneView`, which both engines call. The
Checks fact reads `pr.checks` (a `ChecksSummary`), "pushed" reads
stored `Pr`: no comments, threads, commits or timeline, and no checks at
all (PostPile does not fetch CI since 0.21.0). A new pane field goes into
`prPaneView`, which both engines call. "Pushed" reads
`pr.lastCommitAt`; comment text, Reply and Thumbs up come from
`PrDetail.activity`. Its rows are core's `ActivityEvent` (lines extend
it with body, `eventCount` and the reply): no raw events, and no
Expand Down Expand Up @@ -350,7 +350,8 @@ with a `title` that says why. Hiding it makes the gap invisible to the next agen
actions). `text-faint` (2.3-2.6:1) is decoration only: separators,
chevrons, ages next to a louder line, done tiles.
- **Diff red**: `--diff-red` for deletions in the Size fact. Coral
(`unread`) is never a diff or CI colour; the Checks fact is grey.
(`unread`) is never a diff colour. There is no CI anywhere (DESIGN.md "CI
is not tracked").
- **One colour per meaning** (2026-10-01, DESIGN.md "Colour per
meaning"): `closer` only for the agent's Look closer; `status-bad` the
one red for bad (closed, changes requested, risk, errors); `safe` the one
Expand Down Expand Up @@ -509,7 +510,7 @@ detail and fix commands); `lib/tools.ts` only picks where it shows, and
stack (`stackQueueWord`, "Merge queue: with 3/3"): the top branch holds
their commits, so they merge with it. Review state: `StateWordLabel` with a
`StateWord` from `reviewWord` / `rowStateWord` (`lib/pr.ts`). Never a CI
icon or word outside `PrFacts`.
icon or word: PostPile has no CI data.
- Icons carry words: a lone icon gets a `title` (and `aria-label` when it
is the only content of a control).

Expand Down Expand Up @@ -552,9 +553,8 @@ tints (`lib/why.ts`, `lib/events.ts`, `reviewWord` / `rowStateWord` in `lib/pr.t
state keeps its color on done tiles; title and counts go grey. When only
agents approved (`PrStatus.agentApprovers`) the word reads "Approved by
agent", names in the tooltip; the detail uses `PrDetail.agentApprovers`
with `approvedText` in `lib/pr.ts`. **No CI on rows, tiles, the detail
state line, the RISK box or the your-move chip**: checks only show in
`PrFacts` (DESIGN.md "CI is not a signal"; `PrStatus` has no checks).
with `approvedText` in `lib/pr.ts`. **No CI anywhere**: PostPile fetches no
checks (DESIGN.md "CI is not tracked"; `PrStatus` and `PrPaneView` have none).
- PR rows: a single-PR tile's row has no title (`PrRow` `showTitle`
false; the heading is the title). The author's avatar is who opened it
(`PrSummary.author`, a bot for agent PRs); when someone else is assigned,
Expand Down
21 changes: 6 additions & 15 deletions apps/desktop/src/renderer/src/components/DetailPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,33 +162,24 @@ describe('DetailPane', () => {
commits: [makeCommit({ committedAt: at(30) })],
reviews: [makeReview({ id: 'r1', author: 'lyra', state: 'APPROVED', body: 'Ship it.' })],
comments: [makeComment({ id: 'c1', author: 'bob', body: 'Why one key for all jobs?', url: 'https://github.com/acme/app/pull/11#issuecomment-1' })],
checks: {
rollup: 'FAILURE',
contexts: [
{ name: 'lint', conclusion: 'SUCCESS', completedAt: at(40) },
{ name: 'test', conclusion: 'FAILURE', completedAt: at(45) },
{ name: 'e2e', conclusion: null, completedAt: null },
],
},
});
const comment = eventView(makeEvent({ id: 'acme/app#11:comment:c1', prKey: stored.key, actor: 'bob', sourceId: 'c1', summary: 'bob commented' }));
const ci = eventView(
makeEvent({ id: 'acme/app#11:ci:head:FAILURE', prKey: stored.key, kind: 'ci', actor: '', isBot: true, summary: 'CI failed: test', ruleReason: 'bot activity', seenAt: at(50), at: at(45) }),
const deploy = eventView(
makeEvent({ id: 'acme/app#11:deploy:d1', prKey: stored.key, kind: 'deploy', actor: 'vercel[bot]', isBot: true, summary: 'Preview deployed', ruleReason: 'bot activity', seenAt: at(50), at: at(45) }),
);
const detail = detailOf(stored, activityList([comment, ci], viewer, null, stored));
const detail = detailOf(stored, activityList([comment, deploy], viewer, null, stored));
expect(detail.pr).not.toHaveProperty('comments');

renderCached(stored.key, detail);

expect(screen.getByText('3 checks · 2 not passing')).toBeTruthy();
expect(screen.getByText(/^pushed /)).toBeTruthy();
expect(screen.getByText('One key for every job.')).toBeTruthy();
expect(screen.getByText('lyra').parentElement?.textContent).toContain('approved');
// The reply target comes with the activity line, built from the stored PR on the server.
expect(screen.getByText('Why one key for all jobs?')).toBeTruthy();
expect(screen.getByRole('button', { name: /^Reply$/ })).toBeTruthy();
// The folded bot/CI rows draw from the slim items too: summary, and the reason in the hover title.
fireEvent.click(screen.getByRole('button', { name: 'Show 1 bot/CI event' }));
expect(screen.getByText('CI failed: test').closest('[title]')?.getAttribute('title')).toBe('seen: bot activity');
// The folded bot rows draw from the slim items too: summary, and the reason in the hover title.
fireEvent.click(screen.getByRole('button', { name: 'Show 1 bot event' }));
expect(screen.getByText('Preview deployed').closest('[title]')?.getAttribute('title')).toBe('seen: bot activity');
});
});
15 changes: 2 additions & 13 deletions apps/desktop/src/renderer/src/components/PrFacts.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import type { PrPaneView } from '@postpile/core';
import { checksNote, mergeStatus } from '../lib/pr.ts';
import { mergeStatus } from '../lib/pr.ts';
import { ageLabel } from '../lib/time.ts';
import { useNow } from '../lib/use-now.ts';
import { SectionLabel } from './SectionLabel.tsx';
Expand Down Expand Up @@ -29,12 +29,11 @@ function Fact(props: { label: string; children: ReactNode }) {
);
}

/** Size, checks, age and what stands between the PR and a merge. */
/** Size, age and what stands between the PR and a merge. No checks: PostPile does not fetch CI (0.21.0). */
/** `agentApprovers` (`PrDetail.agentApprovers`) lets "To merge" say "approved by reviewbot (agent)". */
export function PrFacts(props: { pr: PrPaneView; agentApprovers: string[] }) {
const now = useNow();
const { pr } = props;
const { checks } = pr;
const pushedAt = pr.lastCommitAt;
const age = pr.mergedAt ? `merged ${ageLabel(pr.mergedAt, now)}` : `opened ${ageLabel(pr.createdAt, now)}`;
return (
Expand All @@ -53,16 +52,6 @@ export function PrFacts(props: { pr: PrPaneView; agentApprovers: string[] }) {
]}
/>
</Fact>
{/* Neutral on purpose: CI is not a signal in PostPile, so no pass or fail colour (2026-09-29). */}
<Fact label="Checks">
<span className="font-mono text-[11.5px] leading-[normal] text-hint tabular-nums">{checks.total === 0 ? 'none' : checksNote(checks)}</span>
<SplitBar
parts={[
{ weight: checks.passed, tone: 'bg-dot-quiet' },
{ weight: checks.failed + checks.pending, tone: 'bg-chip' },
]}
/>
</Fact>
<Fact label="Age">
<span className="flex gap-1.5 font-mono text-[11.5px] leading-[normal] text-ink-2 tabular-nums">
{age}
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/components/SnoozeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ function tomorrowAtNine(): string {
const OPTIONS: { label: string; condition: () => SnoozeCondition }[] = [
{ label: 'Until someone replies', condition: () => ({ kind: 'someone_replies' }) },
{ label: 'Until a new push', condition: () => ({ kind: 'new_push' }) },
{ label: 'Until CI is green', condition: () => ({ kind: 'ci_green' }) },
{ label: 'For 1 hour', condition: () => ({ kind: 'until_time', until: new Date(Date.now() + 3_600_000).toISOString() }) },
{ label: 'Until tomorrow 9:00', condition: () => ({ kind: 'until_time', until: tomorrowAtNine() }) },
];
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,6 @@ const GLYPH_PATHS: Record<EventGlyph, string> = {
closed: `${ring(4, 3.5, 1.6)} ${ring(4, 12.5, 1.6)} ${ring(12, 12.5, 1.6)} M4 5.1v5.8 M12 7.5v3.4 M10.3 2.3l3.4 3.4 M13.7 2.3l-3.4 3.4`,
ready: `${ring(4, 3.5, 1.6)} ${ring(4, 12.5, 1.6)} ${ring(12, 12.5, 1.6)} M4 5.1v5.8 M12 10.9V6a2 2 0 0 0-2-2H7.5 M9 2.5L7.5 4 9 5.5`,
draft: `${ring(4, 3.5, 1.6)} ${ring(4, 12.5, 1.6)} ${ring(12, 12.5, 1.6)} M4 5.1v5.8 M12 8v.01 M12 5v.01`,
ci: `${ring(8, 8, 6.2)} M5.8 5.8l4.4 4.4 M10.2 5.8l-4.4 4.4`,
deploy: 'M8 11.5V2.5 M4.5 6L8 2.5 11.5 6 M3 14h10',
queue: 'M2.5 4h7 M2.5 8h7 M2.5 12h7 M12 6.2l2 1.8-2 1.8',
bot: 'M3.5 6h9v7h-9z M8 3.5V6 M6 9.3v.01 M10 9.3v.01 M1.5 9v2 M14.5 9v2',
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/renderer/src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export type EventGlyph =
| 'closed'
| 'ready'
| 'draft'
| 'ci'
| 'deploy'
| 'queue'
| 'bot';
Expand All @@ -44,7 +43,6 @@ const GLYPHS: Record<EventKind, EventGlyph> = {
reopened: 'ready',
ready_for_review: 'ready',
converted_to_draft: 'draft',
ci: 'ci',
deploy: 'deploy',
merge_queue: 'queue',
bot_comment: 'bot',
Expand Down
15 changes: 2 additions & 13 deletions apps/desktop/src/renderer/src/lib/pr.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { describe, expect, it } from 'vitest';
import { prPaneView, type ChecksSummary, type PrStatus, type Review } from '@postpile/core';
import { prPaneView, type PrStatus, type Review } from '@postpile/core';
import { at, makePr } from '@postpile/core/fixtures';
import { approvedText, checksNote, ICON_WORDS, mergeQueueWord, mergeStatus, reviewRows, reviewWord, rowStateWord, stackQueueWord } from './pr.ts';
import { approvedText, ICON_WORDS, mergeQueueWord, mergeStatus, reviewRows, reviewWord, rowStateWord, stackQueueWord } from './pr.ts';

function review(author: string, state: Review['state'], minutes: number): Review {
return { id: `${author}-${minutes}`, author, state, body: '', submittedAt: at(minutes), commitOid: null };
}

/** The pane's checks summary with these counts. */
function checks(passed: number, failed: number, pending: number): ChecksSummary {
return { rollup: 'PENDING', total: passed + failed + pending, passed, failed, pending, finishedAt: null, failedNames: [] };
}

describe('pr helpers', () => {
it('lists pending requests first, then newest reviews, teams last', () => {
const pr = prPaneView(
Expand All @@ -34,12 +29,6 @@ describe('pr helpers', () => {
expect(reviewRows(pr)).toEqual([{ login: 'lyra', status: 'changes_requested', at: at(20) }]);
});

it('words checks neutrally, failed and running together as not passing', () => {
expect(checksNote(checks(2, 1, 1))).toBe('4 checks · 2 not passing');
expect(checksNote(checks(3, 0, 0))).toBe('3 checks · all passing');
expect(checksNote(checks(1, 0, 0))).toBe('1 check · all passing');
});

it('describes the merge status', () => {
expect(mergeStatus(prPaneView(makePr({ state: 'MERGED', mergedBy: 'rowan' })), [])).toBe('merged by rowan');
expect(mergeStatus(prPaneView(makePr()), [])).toBe('needs review');
Expand Down
13 changes: 1 addition & 12 deletions apps/desktop/src/renderer/src/lib/pr.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
import type { ChecksSummary, MergeQueueStep, PaneReview, PrIcon, PrPaneView, PrStatus, TileStack } from '@postpile/core';
import type { MergeQueueStep, PaneReview, PrIcon, PrPaneView, PrStatus, TileStack } from '@postpile/core';
import { prNumber } from './tiles.ts';
import { sinceLabel } from './time.ts';

/**
* The Checks fact's note, "12 checks · 2 not passing" ("all passing" at
* none). Neutral words: CI is not a signal here (2026-09-29), so failed and
* still running both count as not passing, without a colour.
*/
export function checksNote(checks: ChecksSummary): string {
const total = `${checks.total} ${checks.total === 1 ? 'check' : 'checks'}`;
const notPassing = checks.failed + checks.pending;
return notPassing === 0 ? `${total} · all passing` : `${total} · ${notPassing} not passing`;
}

export type ReviewStatus = 'requested' | 'approved' | 'changes_requested' | 'commented' | 'dismissed';

export interface ReviewRow {
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const TOKEN_HEADER = 'x-postpile-token';
const snoozeCondition = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('someone_replies') }),
z.object({ kind: z.literal('new_push') }),
z.object({ kind: z.literal('ci_green') }),
// Snoozes compare ISO strings, so any offset is normalised to UTC "Z" form here.
z.object({
kind: z.literal('until_time'),
Expand Down
Loading
Loading