Repository navigation
Projects: a tab for what each team is building - #513
Merged
Merged
Conversation
The team wants Pulldasher to show which projects the open PRs serve, who is on each, and backlog numbers for any date range. A job outside Pulldasher files each PR into a project with one label, `project:<slug>`, and opens one GitHub issue per project with the same label: the title is its name, the assignee its lead, the milestone its target, `parent:<slug>` labels its parents, and an `ongoing` label marks work with no end. This is the pure half the board and the server both run, the way shared/model/status.ts serves both: 1) projectSlugs and projectOf read a PR's project off its labels. A real project beats misc; otherwise the first label alphabetically. 2) buildToday groups open PRs and the last 14 days of merges into live projects, quiet ones (an open issue with nothing in flight), misc, not sorted yet, and PRs with two project labels. It flags only what is true: one person on 3 or more PRs, 2 or more open PRs all waiting on review, a lead with work in 3 or more other live projects, and a closed issue with a PR still open. 3) windowStats counts the backlog at each end, opened, merged, closed without merging, and median age for the UTC days start..end, overall, per project and per person, plus one cumulative point per day for the backlog chart. Every bucket keeps start + opened - merged - closed = end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PR whose close webhook gets lost stays open on the board until the next restart, because #501 repairs it only at startup. Every backlog number counts those PRs: on 2026-09-29 the board listed 314 open PRs, and 67 of them were already merged or closed on GitHub. Now the server lists each tracked repo's open pulls once an hour and refreshes only the ones the DB has wrong: open on GitHub but not open in the DB (a lost `opened` or `reopened`), and open in the DB but missing from the listing (a lost `closed`, repaired by #501's own refreshStaleOpenPulls). The listing costs one API call per 30 open pulls in each repo, and an hour with nothing wrong refreshes nothing. This is option 1 from #501's list, which went with startup only. The startup refresh still refetches every open pull; this doesn't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Projects tab and a Claude skill need the project issues and PR history, and the socket sends neither. Three read-only endpoints, each taking start and end as YYYY-MM-DD UTC days (default: the last 30 days; at most 400): 1) GET /projects-data, session-authed like /stats-history: every project issue plus the window's numbers. The tab builds Today itself from the live socket pulls, so it moves with the board. 2) GET /api/v1/projects, Bearer-authed like /api/v1/pulls: each project's issue fields, where it stands today (open PR ids, people, idle days, flags), and its numbers for the window, plus the totals and one point per day for the backlog chart. 3) GET /api/v1/people: per person, the window's numbers, the projects they had PRs in, the live projects they're on, and PRs open now. Most live projects first, which is how you spot someone spread thin. No schema change. Project issues come from the issues table and their labels from pull_labels, where updateAllIssueData already writes the labels of issues. The server syncs the projects repo's issues at startup, then once an hour asks GitHub only for the ones updated since the last clean sync. It all stays off unless config.js has a `projects` block, and the board shows its tab only when the initialize payload has projectLabelPrefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project manager needs to see who does the work, not just how much. When people outside the developer teams open a large share of PRs, every one of those needs a developer's review. The window numbers couldn't show either. Now config.js `projects.developerTeams` names the teams (team to logins); anyone else with a PR counts as a non-developer. windowStats takes the team lookup and the window's CR/QA stamps, and adds: 1) per bucket: the median days from opened to merged, and how many developers and non-developers had PRs; 2) per project: its first PR's opening day and its last close; 3) per person: their team, the reviews they gave, and how many of those were on a non-developer's PR; 4) one point per Monday-start week: opened and merged by each group, merged PRs by project, and reviews by whose PR they were on. The server loads the stamps with one join of pull_signatures to pulls, leaves out bots on either side, and narrows them with `project=` the same way it narrows PRs. /projects-data sends the teams, and /api/v1/people gains each person's team and review counts. Project records carry their issue's opened and closed dates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project manager wants to lay out the next months by hand: which work comes first, when it starts, and roughly how long it takes. GitHub has nowhere to keep a plan's start, length and order, so this is the one part of the Projects tab Pulldasher stores itself, in a new roadmap_items table (schema.sql creates it on the next start, and 0022 records it). The column is lead_login because LEAD is a reserved word in MySQL 8. An item has a name, a first week (always a Monday), a length in weeks, a status (planned, active, done, dropped), an optional team and lead, notes, and an optional project label slug that links it to that project's PRs. Its place in one shared order is its priority. 1) GET /roadmap (session) and GET /api/v1/roadmap (Bearer) list it. 2) POST /roadmap adds at the bottom, PATCH /roadmap/:id edits any subset, DELETE /roadmap/:id removes. shared/model/roadmap.ts checks every write, so the board and the server word a mistake the same. 3) PUT /roadmap/order rewrites every priority in one UPDATE, and only when the ids name every item exactly once; otherwise it answers 409 with the current list, so two people dragging at once can't drop or repeat an item. Writes need a signed-in session and a JSON body. A JSON write from another site needs a CORS preflight this server never answers, which keeps cross-site form posts out without a token. Each row records who changed it and when. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review board answers "what should I review next?" but nobody could answer "what are we working on, who is on it, and is it moving?" without reading every PR. The server now knows each PR's project and has a roadmap table, so this is the tab that shows them. 1) Overview: headline numbers for a picked date range, each compared with the days just before. Below them, the backlog chart, where merged work went week by week, and one list of every project to sort, group, search, or download as CSV. 2) Roadmap: the plan by month or quarter. Drag a row to set priority, a bar to move it, its edge to change its length; each drag has a keyboard twin. A linked project's real PR activity shows under its bar. 3) People: developers by team and everyone else, with the reviews each gave, including reviews of non-developers' PRs. 4) A page per project with its open PRs, people, flags and numbers. Roadmap changes show at once and roll back if the server says no. The tab, Recharts and react-day-picker are lazy chunks, so the review board's main bundle grows by 5 KB gzipped (144.8 to 149.7) and never downloads the charts. The dummy board files its PRs into projects, teams and a roadmap that show every state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plan's dates say when the work should happen, not how it's going. The planner had to ask each lead, then retype the answers into a status report. Linear and GitHub Projects solve this with a short update: on track, at risk or off track, and a note, kept as a history. 1) Each item takes updates in a new roadmap_updates table. An update keeps the plan as it stood, so the next can say how far the end moved in between. 2) The row says the latest health. At risk, off track, and an overdue update (none for 14 days while in progress) are amber, since each means someone has something to do. 3) Opening an item shows a form to post one, what changed since the last (PRs merged and opened, the plan's move), and the history. 4) The Overview opens with where the plans stand, worst first, with a button that copies it as text for an email or chat. The open item is now in the URL, so the Overview can link to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two things the planner kept doing by hand. Checking a plan against its
milestone meant opening the project issue, and showing the roadmap to
people outside engineering meant explaining the week columns first.
1) A row linked to a project with a milestone draws a short line at
its due date and names it ("target Dec 14").
- Both turn amber when the plan ends after it, or when work still
running past its plan has missed it: the planner owes a new plan
or a new date.
2) A third layout, Now, next, later, lists the same plan without
dates, each column in priority order.
- Now is work in progress and plans whose start has come, next
starts within 13 weeks, later is further out.
- The columns come from the dates, so the layouts can't disagree.
- A plan still marked planned after its start week reads "Was to
start" in amber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prettier 2.7.1 with 3-space tabs, single quotes, 100 columns and no parens around a lone arrow argument leaves the board's older files, like Lane.tsx and Stats.tsx, unchanged. The Projects files were written by hand and didn't match it. This changes whitespace and line breaks only, so the feature commits after it show just what they change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub sends a milestone's due date as a time, like 2026-12-14T07:00:00Z, though people set it as a day. The board has always shown the day part (StatePopover, the CSV), but the project list and project page turned the time into a local date. On the dummy board that put the same milestone on Dec 13 in the list and Dec 14 on the roadmap. Every date in the Projects tab now goes through one dayWords in model/projectData.ts, which reads the day part as UTC. Four copies of the same formatter go away with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A planner wants to know how much of the team's work went to what they planned, and how much went elsewhere. Swarmia calls this investment balance. The Overview's merged work chart already had the numbers per project, so it now has a second view, Roadmap or not: 1) Projects on the roadmap (linked to an item that isn't dropped). 2) Other projects. 3) One-offs, the project:misc PRs. 4) PRs in no project. Under it, the share, which reads "7 of 14 merged PRs (50%) were in projects on the roadmap" on the dummy board, followed by the same share for the days before when they had merges. Both periods are measured against today's roadmap. The choice is in the URL (split=roadmap). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some plans can't start until another finishes: the checkout redesign
needs the Shopify sync done first. The planner kept that in the notes,
where nothing checked it, so a drag could put the redesign ahead of the
work it needs without anyone noticing.
1) An item has a "Waits on" list of other items.
- The editor only offers choices that can be saved: never the item
itself, dropped work, or a loop (A waits on B, which waits on A).
- The server checks the same rules and answers 400 with the reason.
2) The row names what it waits on ("after Search reindex"). It turns
amber, "starts before Shopify sync ends", when the item's first week
comes before the other's planned end, or when the other was dropped.
3) Now, next, later shows the same words on each card.
The list is a waits_on column of ids ("3,7"). roadmap_items is new on
this branch and not deployed anywhere, so the column goes into its
CREATE TABLE rather than an ALTER.
The today line is now drawn per row, so an open editor no longer has a
dashed line through its fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Projects tab is a lazy chunk so developers reviewing PRs don't download it, but two imports pulled its code into the main bundle anyway: 1) app.tsx read DEFAULT_RANGE from model/projectData.ts for the URL, which brought that file, the shared projects model and the dummy projects with it. The constant now lives in lens.ts. 2) backend/dummy.ts, which the board always loads, held the dummy projects, teams and roadmap, and so the roadmap model. They move to backend/dummyProjects.ts, which only the tab imports. Main bundle, gzipped: 150.9 KB before, 146.2 KB now, against 144.8 KB on master. The Projects chunk grows by what left the main bundle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jira Plans and Asana both check a plan against the people who'd do it. Without effort estimates the honest version here is plans against developers, so each team lane's header now says it: "at most 2 at once for 4 developers". It turns amber once a week has as many plans as the team has developers, "2 at once in the week of Oct 5 for 2 developers", because then at least one plan has one developer or none. That's the same worry as the "one person" flag on live projects, caught while it's still a plan. The count runs from this week to the end of the timeline, and only planned and in-progress items count. The dummy board's FixBot team drops to two developers and gets a plan that overlaps its webdriver work, so the amber state shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every component that called useRoadmap started its own 60-second timer and focus listener, and the Overview mounts two of them, so it fetched /roadmap twice a minute and twice on every focus. The first view to mount now starts one timer and the last to unmount stops it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project page's forecast read the milestone's due time in local time, so on the dummy board it said "after the Sep 24 target" right under a facts row saying "due Sep 25". It now takes the day part the way every other date in the tab does since 88bcdcf, and compares the forecast's local day against it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The roadmap knew how each plan was going, but the project list and the
project pages didn't, so a planner scanning projects had to switch to
the roadmap to see which were in trouble.
1) The Overview's project list has a Plan column.
- It shows the linked roadmap item's latest health, "No update" or
"Update due" when one is owed, or else its status.
- Sorting by it puts the worst first; the CSV gets a Roadmap health
column.
2) A project's page has a plan row under its facts: the planned weeks,
status, health and latest note, with a link that opens it on the
roadmap. A project off the roadmap says so.
The health words and the worst-first order move into the shared model
so the list, the overview card and the CSV use one copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The roadmap could only be changed from the board: every write needed a browser session, and /api/v1 had reads only. We want a CLI, and a Claude session driving it, to manage the roadmap with the same GitHub token the review skills already use. 1) Every roadmap write is now under /api/v1 as well: add, change, remove, reorder, and post an update. canWrite accepts the Bearer caller that lib/api-auth.js checked, and records the write as that login. 2) Two routes a script needs that the board didn't: GET /api/v1/roadmap/:id for one item, and POST /api/v1/roadmap/:id/move, which puts one item above another without sending the whole order. 3) GET /api/v1 lists every route with what it does, and the rules a write is checked against. app.js registers the routes from the same table in controllers/api-routes.js, so the list can't miss one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of this branch's roadmap commits turned up five bugs, each
confirmed against the code:
1) Deleting an item left its id in other items' waits_on.
- The server refuses unknown ids, so those items could no longer be
saved.
- Deleting now takes the id off every list: on the server, in the
dummy API, and on screen.
2) Links to a plan did nothing while the roadmap showed now, next and
later.
- The Overview's plan list and the project page now go through
openPlan, which switches to a timeline.
- The roadmap scrolls to the open item whenever it changes.
3) "Roadmap or not" read 0% until the roadmap loaded. It now waits.
4) A load that went out before a save and answered after it put the
old plan back for up to a minute, and a new item could show twice.
- Loads older than the latest write are dropped.
- A new item replaces a copy a load already brought.
5) Save sent every field the editor opened with, so it undid a bar
dragged while the editor was open. It sends only what changed.
Two copy fixes from the same review: form errors are amber, not red,
since red is CI's; and cards in Next and Later say the month their work
starts instead of week dates. New tests cover the store's races and the
amber rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The timeline was drawn for a handful of plans, but we have over a
hundred projects in flight, most with no plan, and it couldn't show how
overloaded that makes us. Its marks also needed a legend: a gray line
for a project's PRs and an upright tick for its milestone meant nothing
to someone reading it cold.
1) A load chart across the top, on the rows' own weeks.
- Weeks up to today are labeled "In flight, from PRs", blue on the
roadmap and gray with no plan; weeks after are labeled "Planned".
- A dashed line says how many developers there are, and the band
above it is amber: more in flight than people.
- The left side says this week's count, split the same way, and how
many that is per developer.
2) Every live project shows, planned or not. One with no plan is a
dashed bar labeled "since Aug 17, no plan", with a plus that puts it
on the roadmap over the weeks its PRs have run.
3) Marks label themselves.
- A linked plan still in flight past its end grows an amber piece
labeled "+3 wk over".
- A milestone is a flag with its date ("Dec 14 target").
- Bars say their dates and weeks inside when they have room.
4) Reading spans: month lines run through every row (quarters
stronger), the header with the column names stays put while the rows
scroll, and rows take one line of meta.
5) Zoom: a column name fills the width with that quarter or month. The
toolbar zooms back out one step and pages to the period before or
after, and the zoom is in the URL, so Back works too.
6) Lanes fold and remember it. A team lane's words count its plans and
its projects with no plan against its developers.
7) The editor plans this or next month or quarter in one click, for
organizing by month or quarter.
Show: "Only the plan" hides the projects with no plan. The load model,
the zoom and the columns are pure modules with tests, and the main
bundle stays at 146.4 KB gzipped (the zoom key's pattern lives in
lens.ts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 375px the timeline's track sat beside the names in about 100px, so six quarters' names ran into each other and the bars were slivers. On a phone each row's name and details now sit above a track the full width of the card, and the header drops its small month marks and zoom glasses, which don't fit. From 640px up nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The developer teams now drive the roadmap's developer line and each
lane's load, but they lived in config.js, so a person joining or leaving
a team meant a deploy. And a CLI driving the roadmap should be able to
change them the same way it changes a plan.
1) A project_settings table holds settings saved from the board or the
API, one JSON value per name. Saved developer teams replace
config.js's for every count; null goes back to config.js.
2) GET and PATCH /api/v1/settings (and /settings for the board's
session), behind the same write gate as the roadmap. checkDeveloperTeams
refuses a login on two teams, since a developer's work counts toward
one team's load.
3) The People view lists the teams and says where they come from.
- Editing gives each team a name and a member list, with add, remove,
save, and a way back to config.js.
- Every loaded window loads again after a save, since each developer
count changes.
The settings are cached in memory, loaded at startup and after each save;
one process serves the board.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dummy board has 12 projects in flight; a real board can have over
a hundred.
The roadmap looked fine at 12 and was too tall and too repetitive at
100, and the bench couldn't show it.
?projects=100 in the dummy board's URL now adds that many projects in
flight, each with one to four open PRs cloned from the fixture, spread
over the last six months, named like real work ("Checkout caching").
About one in eight is on the roadmap, and each dummy team gains
developers, so the load reads like a real board: 112 in flight for 18
developers. Everything comes from the index, so a reload looks the same,
and backend/dummyScale.ts loads only when the parameter is there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At a real board's size the roadmap listed 94 projects with no plan, two
or three lines each, and the only way to plan one was a plus that
guessed its weeks. Organizing them by month or quarter meant planning
each with the guess, then opening its editor to fix the dates.
1) A project with no plan is one line: its name, lead and open PRs. The
dashed bar already says since when, so the row doesn't repeat it.
The section is about 1,200px shorter at 100 projects.
2) Its plus opens a chooser under the row: the weeks its PRs have run
and two more, this or next month, or this or next quarter. One
click plans it; it's in progress once its first week has come.
3) Dragging across its weeks on the timeline plans it for them, with
the dates shown while you drag.
4) A find box narrows the rows by name, label, lead or team, and each
section says what it shows ("3 of 92 live projects"). The load chart
still counts everything.
The header's first column says Project, not Priority, since the rows
below the plans aren't in priority order, and the name column widens
from 1024px so fewer names wrap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most of the roadmap did nothing when clicked: the chart's weeks and counts, the words
on a row, the milestone flags, the month names. A planner reached for
them and nothing happened. And one click did the wrong thing: a plain
click on the track of a project with no plan created a one-week plan.
1) The load chart.
- A week's bar picks that week, and the rows narrow to what was in
flight or planned then, banded through every row.
- Arrow keys move the pick; Escape clears it. The chart takes one
tab stop, not one per week.
- Its counts filter to what they count, adding a third Show, "No plan
yet", and the developer count opens the teams.
2) The header: a month name under the quarters zooms in, like the
quarter names, and while zoomed Today comes back to this period.
3) A plan's row.
- A plain click on its bar opens its editor; only a drag moves it.
- The lead narrows to their work, the health opens the updates,
"after X" opens X, and the flag or a missed target opens the
project. The amber overrun opens the plan.
4) A project with no plan: a plain click on its track opens the
chooser, and only a drag plans it. Its lead and PR count click
through too.
5) A Now, next, later card opens its plan, and the Plan cell in the
project list opens it on the roadmap.
DESIGN.md gets the rule: what would someone expect clicking a mark to
do? Build that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same rule as the roadmap: a number or a word a planner would reach for goes where it comes from. 1) Live projects scrolls to the project list, Waiting on review now opens the review board, and Developers and others opens People. Tiles with nowhere to go stay plain. 2) In Where the plans stand, the health word opens the plan and its updates, like the name, and the lead opens the roadmap narrowed to their work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some work stops without being done or dropped: its people got pulled onto something else, or it waits on a call nobody has made yet. The roadmap could only say planned, in progress, done or dropped, so stopped work either kept counting as in progress or got marked dropped, which isn't true either. Parked means stopped for now. It leaves the weeks ahead of the load and Now, next and later, and owes no updates. Anything waiting on it reads as a clash, the way waiting on dropped work does. Its bar is a gray outline. The status is a new value in migration 0022's enum. That migration hasn't shipped yet, so it changes in place instead of getting a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A team with no product manager has nobody whose job is to notice that a plan slipped, a project stalled, or new work started with no decision behind it. The roadmap had the facts but waited for someone to go looking. On the dummy board at a hundred projects, 94 of 112 in flight have no decision at all. Decide lists those calls, worst first, and says why each one is there: 1) new: in flight with no roadmap item 2) its plan ended, with the project still in flight or with nothing in flight at all 3) stalled: open PRs with no activity for 21 days 4) its latest update says at risk or off track, and the plan hasn't changed since 5) marked done or dropped a week or more ago, but PRs are still open 6) parked, but its PRs moved Each call is one click that writes the roadmap: commit through the end of a coming month or quarter, park, finish, or drop. A decided row stays in place at the same height and says what was decided, so nothing vanishes and the next row doesn't slide under the pointer. The tab's label counts what's left. Any change to an item counts as a decision (its updated_at). So an edit now stamps updated_at on screen right away, and the dummy board stamps each write instead of once at page load. GET /api/v1/decide lists the same rows for a script or a Claude session, and GET /api/v1 says what each reason means and which write clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The load chart counted only the plans after today. With most work in flight undecided, it dropped off a cliff at today and said the team frees up next week, when nothing says so. Now each week ahead counts: 1) every plan until its planned end 2) a plan past its end whose project is still in flight, since nothing says it's done 3) every project still open with no decision at all Parked, finished and dropped work stops counting. The future half is labeled "Ahead, if nothing changes" and drawn in paler tints of the same colors. The only way to bring it down is a decision. The model moves to shared/model/load.ts, so the chart, the team lanes and the new GET /api/v1/load count the same way. The roadmap builds its spans with the shared spansFrom instead of its own copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project's row said "lead in 3 other projects" in amber when its lead had PRs in three or more other live projects. With several projects in flight per developer, that's most leads, so the amber showed on most rows and stopped meaning anything. Amber on a project row means someone owes the project something, and a busy lead isn't that. The People view already counts who is on four or more live projects, which is where that question gets answered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sessions live in server memory, so every deploy signs everyone out. An open tab reconnected its socket within seconds, then asked /token who it was; the server redirected that to GitHub's sign-in, the browser blocked the cross-site hop, and the board read it as a network blip. It showed "Live updates lost, retrying in the background" and retried every 30 seconds with no way to succeed. A hidden tab never reloaded, so it never recovered, and the "Pulldasher was updated." banner, which rides on the same sign-in, never showed. Now /token doesn't follow redirects, so a redirect reads as an expired session (the visible tab reloads and signs back in), a tab that comes back into view retries at once, and a working sign-in clears the "Live updates lost" banner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A playful nudge to close out PRs: when your own open PRs pass a limit (more than 7 open, or any open longer than 14 days; drafts, bots and deploy-held PRs don't count), a jail cell drops over the board and lists them. Dragging the key into the padlock lets you out. It shows again only when things get worse: at least 4 hours after it last showed, with more open PRs or another one past the age line. A refresh never brings it back, and open tabs stay in sync. A lock badge beside the bell shows while you're over a limit and reopens the list. Settings hold both limits, whether drafts count, and a preview button (or #jail=1 in the URL). The art follows the light and dark themes; the robot stands behind the door with his tally wall beside him; on a phone the list comes first and the key is one drag straight down into the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open jail showed a copy of your PRs from the moment it dropped, so a merge or a new PR arriving over the socket never reached it: it stayed on "8 open PRs" after a ninth came in. The cell now reads the live list; merging under the limits says "You're under the limits now. Use the key to walk out." Leaving saves what you last saw, so PRs opened while it was up don't drop it again right away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The jail listed your PRs as one flat, oldest-first list, so the ones you
could merge in seconds sat between ones waiting on someone else. Now it
splits them the way My work does (same rowWord): Ready to merge on top,
then Your move, then Waiting on others, each oldest first. Each row
shows the age, the move, a CI mark, and who it waits on.
The why line counts against the groups ("Merge your 3 ready ones and
close 10 more"), never counts a parked PR as ready, and doesn't promise
"you're out" while an old PR would still hold you. The preview now
shows your own PRs as jail counts them. CiGlyph comes out of CiStatus
so the board and the jail draw the same CI mark.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
People complained about the jail. The full-screen animated cell with the key and the lock was a lot to sit through, and some people don't want the feature at all. Now it's a modal with a small sad robot behind bars, drawn as a few lines in the board's own color tokens so it matches the UI and themes with dark mode, above the same grouped list. It still has no close button: you drag the card onto a trash can at the bottom of the screen (on a phone that's a swipe down), and a keyboard or screen reader presses the trash. A new PR jail setting turns it off, badge and all; it defaults to on. The cell, key, lock and inmate art and their CSS go away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some people want the header lock without the pop-up: they'd rather check their PRs when they choose to. The single on/off switch couldn't say that, since Off hid the lock too. Now the PR jail setting has three choices: Automatic pops it up when you're over the limits, Manual only shows the lock to open when you like, and Off hides both. Anyone who already picked Off keeps it. The trash can at the bottom becomes a snooze clock labeled "Snooze". Dragging your PR list into a trash can read like throwing the PRs away; snooze is what it really does, since jail comes back when things get worse. The hint under the list now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging the card onto a clock was the only way past the jail without fixing anything, and it didn't work the same on a phone or a keyboard. The point is a little friction, not a puzzle. 1) Snooze is now a hold: press and hold the row at the bottom for 2 seconds, with a pointer, a finger, Enter or Space. A small line-drawn padlock shows it: the key in the keyhole wiggles while it waits, turns a quarter turn as you hold while a ring around it fills, and the shackle pops open at the end. Letting go early springs it back. A tap, a click or Esc does nothing. 2) The jail lets you go by itself once you're under the limits: it says "You're out. Nice work." and closes after a couple of seconds. 3) A countdown to freedom: "6 to go" next to the title and on the header badge, enough PRs to get under the count limit plus every one past the age limit, ticking down live as you merge or close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The jail's list was tight: 13px rows two pixels apart, the move and the
detail run together with dots, and the snooze lock just closed the card
when you finished holding it.
1) Finishing the hold opens the lock: the shackle lifts out of the
body and swings over its right leg to the other side, and the ring
and the key turn green, before the jail closes.
2) Focus starts on "Hold to snooze", so holding Enter or Space works
the moment it opens.
3) More room: a wider card with more padding, rows split by hairlines
with real space between, 14px titles, ages in days, and the next
step as a small pill instead of a dot-separated word. Each group
says what to do with it ("Each of these is waiting on you").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Hold to snooze" was our word, not the user's, and the jail had grown its own styles (uppercase eyebrows, hint lines, a subtext under the button) when the board already has a header system people know. 1) The button says what happens: "Hold to get back to work", full width with the lock at its side. A quick click says "Hold it down", so the gesture teaches itself; the subtext is gone (Settings already says when it comes back). Done, it reads "Unlocked". 2) Groups use the board's GroupHeader (title, a short note, the count on the right), pinned to the top of the list as you scroll. 3) Size: 40rem on a desktop so titles fit one line, at most 80% of the screen tall; a sheet up from the bottom on a phone, with the button at thumb height. The header and the button stay put and only the list scrolls. 4) "Copy a nudge" on Waiting on others copies a Slack-ready list of those PRs, their links, and who each one waits on. 5) A PR that merges or closes while the jail is up slides out of the list as the countdown ticks, instead of vanishing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Developers now CR and QA their own PRs unless they ask for review, so "needs CR" no longer means someone else owes a review. derive() needs to know which PRs are the author's own job and which are waiting on people who were asked. A pull is ownReview when its author is a developer (config projects.developerTeams, or everyone when that's unset) and nothing requested a review: no person, no team, no claim. askedOf lists who was asked, with a requested GitHub team counting as everyone on the roster team of that name, and askedAt is when. Only pulls waiting on others can starve. An author's own stale stamp now owes a re-stamp, like anyone else's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The self-review policy needs a few things from the server: 1) A clean merge of master into a PR no longer voids its CR and QA stamps, the way GitHub keeps an approval across one. The commit list is only fetched when a stamp actually predates the head, to spare the API quota. A merge that resolved conflicts reads the same as a clean one; the code says so. 2) Review requests to GitHub teams (requested_teams) are stored and sent to the board, which counts one as a request to everyone on the roster team of that name. 3) "Input hints": which areas a PR's diff touches that usually deserve team input (CI, migrations, alerting, agent docs, deploy config, dependencies), from its changed paths, fetched once per head. 4) The board gets the developer roster, the server-side derive uses it, and /api/v1 adds review.own, asked_of and asked_at. 5) Stats history's time to first CR only counts someone other than the author. Migration 0033 adds the two columns; run it before this code. The dummy board seeds team requests, request times, self-stamped pulls and input hints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With authors stamping their own PRs, "time to first CR" drops to about zero and review debt counts work nobody owes. The Stats tab now shows the time to answer a review request (median and slowest 10%, in hours), who's waiting on someone (open requests by hours, outside contributors' PRs by days), and how merged PRs were reviewed: asked, by someone else, self-reviewed, or not stamped, with a rough count of reverts and quick follow-up fixes after self-reviewed merges. Open PRs by author and repo say "in self-review" and "waiting on others"; stamps per day count only stamps on other people's PRs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bot's PR still needs someone else's review, but derive() only knew the [bot] suffix, so with no roster configured a config-listed bot like ifixit-systems counted as reviewing its own. The review policy now carries config.bots, on the board and the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Developers review their own PRs unless they ask, so the board stops treating every unstamped PR as someone else's job. 1) Nothing assigns review: the rotation, "your turn" alerts and cheers, and "Return the favor" are gone. 2) The review queue and Needs QA hold only PRs that asked you (by name or team), ones you said you'd review, and PRs from outside the dev team or bots. Requests lead Waiting on you, oldest first, with "asked 3h ago"; a new request sends a desktop alert. 3) Your own PRs say what you owe: Stamp CR, Stamp QA, Re-stamp, or "waiting on bob · asked 3h ago", turning into "Nudge bob" after 4 hours. "Find a QA-er", "Nudge for a review" and "in the CR queue" are gone, and so is the "Getting QA is a to-do" setting. 4) Claim reads "I'll review" and "Drop it". 5) New: "Could use your input", a closed-by-default fold of other people's self-reviews in your code regions or repos you review, with no counts; and a quiet "worth asking for review?" hint on your PRs that touch CI, migrations, alerting, agent docs, deploy config or dependencies. 6) Words across Review, My work, Team, CI, Classic, Projects stages, cheers and PR jail now match the policy, and "is:asked" finds requests of you. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Requests are answered in hours under the self-review policy, but the
"asked 3h ago" clock only lived in the state popover. Rows waiting on
someone who was asked now carry it as a flag ("asked 3h ago", or
"review asked" for a team request with no time), amber past 4 hours,
naming who was asked on hover.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found three holes in the self-review model: 1) A CR comment doesn't clear GitHub's review request, so a reviewer who had stamped stayed "asked": the author was told to nudge them, and everyone on a requested team was told to QA it. askedOf now drops anyone whose CR already counts. 2) Someone who stamped a self-reviewed PR without being asked was told to re-stamp after the next push. On a self-reviewed PR only the author owes a re-stamp now. 3) A team request had no time, so it never got the hours clock. The wire gains team_requests (slug and time); members inherit their team's time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1) Being asked to review means code review: a requested reviewer sees
"Review it" while it's in review, never "QA it". The author tests
their own ("Stamp QA"); Needs QA holds only claims and PRs from
outside the dev team or bots.
2) The "Starving" block in the review queue is gone. Requests of you
come first, oldest request first, then the rest of the queue.
3) "Board's clear" only counts review that's yours.
4) Stats' "Asked" no longer counts a claim.
5) Ready to merge shows your own ready PRs, plus others' only when you
were asked or said you'd review; the author merges. It replaces the
Merge row in Waiting on you.
6) A team request that names nobody on the roster is in nobody's
queue: the author sees "waiting on a review from {team}", and it
can show under Could use your input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found: 1) "Update branch" on GitHub dropped every stamp until the refresh put them back, and sent reviewers a false "Re-stamp owed". The push webhook now checks the new head and skips that when it's a base merge. 2) The keep-the-stamp rule failed open: with no commit dated after the stamp, or a PR past GitHub's 250-commit list, the stamp survived any push. Both now drop it. A commit made before the stamp but pushed after still slips through; the code says so. 3) The commit list was fetched on every webhook, closed PRs included, whenever any old stamp predated the head, and a GitHub error failed the whole refresh. It's now open PRs only, newest stamp per person, cached per head, and errors fall back to the old behavior. 4) Team review requests now record when they were asked, so a team's members get the hours clock. 5) The "alerting" hint fired on any Alert* file, like AlertBanner.tsx; it now matches alerting directories and config files only. The dummy board gets dated team requests and hints on your own PRs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch carried three features. PR jail and the self-review policy now each get their own pull, stacked on this one, so each can be read and merged on its own. This takes their 17 commits back out; the pr-jail and self-review-policy branches put them back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1) A project issue stopped refreshing whenever GitHub's issue-fields read failed: the error rejected the whole refresh, so a closed issue never showed as closed. A failed read now keeps the stored fields and refreshes the rest. 2) Issue search and attach read any repo in the tracked orgs with the bot's token, so someone could see titles from a private repo they can't open. Both now only reach the repos this board reads issues from. 3) The startup schema check didn't know migrations 0022 to 0024, so a fresh database got a confusing error instead of "these are missing". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Look back, People and time spent read comments, stamps, reviews and pulls by date with no index, so every fetch scanned those tables, and every project save makes open Projects tabs fetch again. Migration 0034 adds the date indexes online. It's numbered after 0033, which the self-review branch already ran on dev and prod. The startup schema check learns to look for an index, so it names 0034 until it's in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found: 1) 0034 put four ALTERs in one file and the schema check only looked for the last index, so a failure partway (a lock timeout, a dropped connection) left a file that failed on every rerun with "Duplicate key name". Each index is now its own one-statement migration (0034 to 0037), checked by name, so a rerun picks up where it stopped. bin/migrate-missing also gives up on a table lock after 10 seconds instead of stalling the live board behind it. 2) A plan's project allowed 24 lowercase characters while project labels and attached issues allow 64 with dots and underscores, so a longer label could never get a plan. One shared rule now checks all three, and 0038 widens roadmap_items.project to 64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of the Projects views found: 1) Look back and People kept their own 5-minute cache, so after a teams save or an issue added on a project page they showed old numbers. A projects save now refreshes them too, live as well as on the dummy board. 2) Undo on a plan saved as Planned whose PRs had started wrote back "active", a change nobody made. Undo restores the stored status. 3) Portfolio's "missed target" did instant math rounded up, so a target read missed from 5pm Pacific on its day while the rest of the tab said it was still due. It now counts whole days like the rest. 4) The drafted update's finish used the UTC day while the project page used the local one, so they could disagree by a day. 5) The date range allowed 401 days and then dropped the pick silently; it stops at 400. 6) A reload answered while a removal was in flight could put a removed plan back for up to a minute. Loads that land during a removal are dropped and run again once it settles. 7) A plan starting later could be given a finish before its start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of the Projects branch found: 1) A board only heard about project changes while connected, so after a laptop slept or the server restarted, the review board's parked and finishing order and the Projects data stayed old until a reload. A reconnect now fetches them again. 2) Two teams typed with the same name merged before the server could refuse them, so the first team's members silently stopped being developers. The form now says "the team X is listed twice", the server's own words, and doesn't save. 3) The Overview's overload tile counted everyone, non-developers included, while the teams were still loading or had failed to load. It waits for them, and says "?" when they failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review with real-world messy data in mind found: 1) A deleted or transferred issue stayed "open" in its project forever: GitHub answers NOT_FOUND, which looked like a failed batch. Now NOT_FOUND removes it from its projects, and so do the deleted and transferred issue webhooks, so Decide can ask "Is it done?" again. 2) If the saved settings failed to load at startup, the first "mark ongoing" or teams save wrote over the saved ones. The load retries until it works, and saves answer "Settings are still loading" until it has. 3) One issue that always failed made the hourly issue sync re-read the whole window every hour. The marker moves on, and failed items retry on their own, three times at most. 4) One renamed or unreadable repo made GitHub reject the whole search, so the Add an issue box found nothing. Search retries that batch a repo at a time and skips the bad one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1) The reconnect refetch counted snapshots per subscriber, and both subscribers start after the first snapshot, so the first reconnect, the very case it was for, was taken for the connect. The socket now counts snapshots itself, before any subscriber. 2) The search fallback asked each repo alone after any error, so a rate limit multiplied the calls, and the empty answer was kept for a minute. It now falls back only when GitHub says a repo can't be searched, passes other errors up, and keeps no answer with a missing part. Also notes that a retried issue whose save fails gets one retry, not three: refresh.issue only rejects when GitHub can't be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖
We skip separate project-tracking tools as too slow for how fast we work. Leads still need to see what each team is building, what's late, and what needs a decision. This adds a Projects tab to Pulldasher that answers those from the PRs and issues people already make, so keeping it current is mostly putting a label on a PR.
It has run on dev and prod with real data since early October.
Changes
project:label on a PR or issue, issues in the projects repo, and PRs that link those issues ("Parts of #N"). Every save can be undone./api/v1.bin/migrate-missingapplies whatever migrations a database lacks.Important
Run
bin/migrate-missingbefore starting this code. Dev and prod already have 0022 to 0032; they still need 0034 to 0038 (date indexes, built online, and a wider project column). Each is one statement, so a failed run can simply be run again, and it gives up on a table lock after 10 seconds instead of stalling the board.Anyone in the org can edit or remove plans, project issues and the developer roster, on purpose: it's a team tool and every save can be undone. Only taking back an update is limited to its author.
PR jail and the self-review policy were built on this branch too; they're now their own pulls, stacked on this one.
Review
Six rounds of blind review, each fixed before the next. The last found nothing to fix. Known follow-up: renaming or deleting a project label on GitHub can split a project's history, since nothing listens for label events yet.
QA
npm run dev:dummyinfrontend-v2and open each Projects view.npm testat the root andnpx vitest runinfrontend-v2.