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
85 changes: 81 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ The tab has five views and a page per project:
turns can be changed in place or with `PATCH /api/v1/settings`. A team's lead can
take just their team's rows, and copy them as text for a meeting's
notes. It weighs every project, whatever the filter bar narrows the
other views to. The tab's label counts what's left.
other views to. The tab's label counts what's left. With automatic
calls on (below), it leaves out a reason only when Pulldasher makes a
call that answers it, and asks the rest as before.
- **Roadmap**: the plan set against everything actually in flight. Across
the top, how many projects were in flight each week (from their PRs)
and, for the weeks ahead, the plans plus every project big enough to owe
Expand Down Expand Up @@ -208,6 +210,69 @@ The tab has five views and a page per project:
row opens the PRs that took the most days and who spent them. It all
copies as text for the retro's notes.

Pulldasher makes the roadmap calls that only say what already happened, so
the roadmap follows the work without anyone answering Decide. After each
sync of the projects' work (a webhook on a project's issue, or the hourly
pass), it:

- picks a plan up again: a parked plan whose project gets a PR opened, or
an open PR a person labeled into it, after it was parked goes back in progress
("Picked up again by owner/repo#12 on 2026-10-20"). A done or dropped
plan takes 2 such PRs, since one fix after a finish isn't a return. PRs
that were already open when someone marked it done don't count, and
neither does an old PR the labeling job files later: a job's PR counts
by when it opened, not when its label went on. A plan whose project
issue closed after the plan last changed isn't picked up: Decide asks
about that.
- puts a plan down: a plan in progress is parked when, for `idleDays` (28),
its project had no PR opened, merged or pushed to, nobody posted an
update, and no person changed it.
- finishes a plan: one under way whose issue closed, or all of whose issues
closed, with no PRs open, is done, or dropped when they were closed as not
planned. One past its hard end with none open, its status untouched
since, is done.
- plans a project: one with 3 or more PRs open or merged in the last 14
days that never had a plan gets one, in progress from its first open
PR's week (or its first PR's, when all have merged) with no end, named
by its issue or its label. A burst that already merged counts too, and
put down parks it once it goes quiet. A plan someone removed isn't made
again, and Not tracked, ongoing and closed projects get none. Decide's
own question about a new project still needs a PR open.
- names a lead: a plan planned or in progress with no lead, whose status
it isn't changing in the same pass, gets its project issue's assignee,
else the author with the most PRs in its project over the last 60 days.
A lead someone set is never replaced, and a plan a person changed since
its project's PRs last moved keeps an empty lead, since they may have
cleared it.

Each call is a roadmap write like a person's, recorded as `pulldasher`,
with why in the plan's `auto_note` and when in `auto_at` (a lead fills in
without counting as a call). Decide lists the week's under "Changed on its
own". A person's change always wins: the rules only read what happened
after a plan's status last changed, and a call that finds its plan's
status changed since it read it doesn't land. Decide, the API's and the
board's, works out the calls Pulldasher makes now and leaves out only
the reasons those answer (`pending_calls` on `/api/v1/decide`). A plan
being finished is asked nothing more.

The labeling job files PRs into projects and re-sorts its own filings at
each sweep, so a fresh label of its own is still a guess. A PR the job
labeled (a bot's label, or one inside a `machineLabelRuns` window) counts
toward picking a plan up or making a new one only once that label is
`settleDays` old (3 by default). A PR a person labeled, or one that joins
by an issue link, counts at once.

It's off until `autoCalls: true` is set in config.js's `projects`, and
while it's off Decide asks those calls. To turn it on:

1. Deploy.
2. On the server, run `bin/auto-calls` to see what it would do now. It
writes nothing.
3. Run `bin/auto-calls --at <the day 28 days from today>` to see the plans
that will be put down once a burst of new labels goes quiet. It runs
the rules as of that day on today's PRs and plans.
4. Set `autoCalls: true`.

To put the plans where people already look, `bin/sync-issue-fields` copies
each plan under way into its project issue's Start date, Target date and
Priority fields: the plan's first Monday and last day, and Urgent for a
Expand All @@ -225,8 +290,12 @@ bin/sync-issue-fields --apply # write it
Everything the tab shows, and every change the roadmap takes, is in the API,
Bearer-authed with the caller's own GitHub token like `/api/v1/pulls`, so a
script or a Claude session can read the projects and run the roadmap
without a browser. `GET /api/v1` lists every route with what it does, and
the rules a roadmap write is checked against:
without a browser. A job reads it with a read key instead: the labeling job
sends one as its Bearer value. config.js's `apiReadKeys` lists the SHA-256
hex digest of each key, never the key itself (`printf %s "$KEY" | sha256sum`).
A read key is let in for `GET` only, and any write with one is refused, since
it has no login to record. `GET /api/v1` lists every route with what it does,
and the rules a roadmap write is checked against:

```sh
curl -s -H "Authorization: Bearer $(gh auth token)" https://pulldasher.example.com/api/v1
Expand All @@ -235,7 +304,15 @@ curl -s -H "Authorization: Bearer $(gh auth token)" https://pulldasher.example.c
- `GET /api/v1/projects` and `GET /api/v1/people`: each project and each
person with where they stand today and their numbers for a window. Both
take `start` and `end` as `YYYY-MM-DD` days (default: the last 30 days, at
most 400), and `project=<slug>` narrows the numbers to one project.
most 400), and `project=<slug>` narrows the numbers to one project. Each
project also says what the labeling job reads: `status`, its plan's
status (null with none), `last_activity`, the team's day
(`projects.timeZone`) its newest PR opened, merged or was pushed to, the
same activity a plan is put down by, and `examples`, up to 5 of its labeled PRs (`repo`, `number`,
`title`, `merged_at`, `by`), open or merged, the ones a person labeled (`by: "person"`)
before the job's (`"job"`), newest first. A bot's label, or one a script
added with a person's token inside a `projects.machineLabelRuns` window,
is the job's.
- `GET /api/v1/decide`: the calls owed now, worst first, each with its
project, its roadmap item if it has one, and its reasons. `GET /api/v1`
says what each reason means and which write clears it. It also names who
Expand Down
11 changes: 10 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { syncRepoIssues } from './lib/project-issue-sync.js';
import { loadSettingsWithRetry } from './lib/settings.js';
import { issueRepos, projectSettings } from './lib/projects.js';
import { setOnWorkSynced, syncWork } from './lib/work.js';
import { runAutoCalls } from './lib/auto-calls.js';
import { missingMigrations } from './lib/schema-check.js';
import apiAuth from './lib/api-auth.js';
import Debug from './lib/debug.js';
Expand Down Expand Up @@ -116,7 +117,15 @@ app.use(PROJECT_WRITES, function (req, res, next) {
}
next();
});
setOnWorkSynced(projectsChanged);
// once the work is read again, Pulldasher makes the roadmap calls that only
// say what happened (lib/auto-calls.js), then open boards fetch again
setOnWorkSynced(function () {
runAutoCalls(projectSettings())
.catch(function (err) {
console.error('Roadmap calls failed: %s', (err && err.message) || err);
})
.finally(projectsChanged);
});
app.post('/project-issues', canWrite, projectsController.attachIssue);
app.delete('/project-issues', canWrite, projectsController.detachIssue);
// the roadmap is the one part of the Projects tab people edit here: reads are
Expand Down
37 changes: 37 additions & 0 deletions bin/auto-calls
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* What the roadmap calls Pulldasher makes on its own after each sync of the
* projects' work (lib/auto-calls.js) would be now: a plan picked up again,
* put down, finished, planned or given a lead. It only says so and writes
* nothing, whether or not config.js projects.autoCalls is on: the server is
* the one writer, so two processes never make the same call. With --at, it
* runs the rules as of that day (lib/auto-calls.js asOf), on today's PRs
* and plans: what nothing new would bring, such as the put-downs idleDays
* after a burst of labels.
*
* Usage: bin/auto-calls [--at YYYY-MM-DD]
*/
import db from '../lib/db.js';
import dbManager from '../lib/db-manager.js';
import pullManager from '../lib/pull-manager.js';
import { asOf, describe, runAutoCalls } from '../lib/auto-calls.js';
import { projectSettings } from '../lib/projects.js';

async function main() {
const settings = projectSettings();
if (!settings) throw new Error('this Pulldasher has no projects config');
const now = asOf(process.argv);
// the board's pulls, as the server holds them: what's open now is read off them
const pulls = await dbManager.getRecentPulls(pullManager.getOldestAllowedPullTimestamp());
for (const pull of pulls) pullManager.updatePull(pull);
const calls = await runAutoCalls(settings, { apply: false, now });
for (const call of calls) console.log(describe(call));
console.log(`${calls.length} call(s). Nothing was written.`);
}

main()
.catch(err => {
console.error(err);
process.exitCode = 1;
})
.finally(() => db.end());
36 changes: 36 additions & 0 deletions config.example.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ module.exports = {
secret: 'secret for signing session cookies',
},

// Read-only keys for /api/v1, for a job with no person's token (the
// labeling job sends one as its Bearer value): the SHA-256 hex digest of
// each key, never the key (printf %s "$KEY" | sha256sum). A key reads
// (GET) and never writes.
apiReadKeys: [],

// When true (default), GitHub PR reviews with state APPROVED count as CR
// signoffs without requiring a CR :emoji: tag in the review body.
useGithubApprovalForCr: true,
Expand Down Expand Up @@ -145,6 +151,36 @@ module.exports = {
// leaves Decide, the counts and capacity. One PR in another repo ends
// it. Omit or [] for none.
// untrackedRepos: ['owner/ops'], // or just 'ops'
// Pulldasher makes the roadmap calls that only say what already
// happened: a plan picked up again by a new PR, put down after
// idleDays with no PR opened, merged or pushed, finished when its
// issues close or its hard end passes with nothing open, a plan for a
// project with 3 or more PRs and none, and a lead for a plan with
// none (its issue's assignee, else its busiest author lately). Each
// is recorded as 'pulldasher' with why, and a person's change always
// wins. Off unless true: deploy, run bin/auto-calls on the server to
// see what it would do, then set it true. Off, Decide asks them.
autoCalls: false,
idleDays: 28,
// Days a PR the labeling job filed waits before it counts toward
// picking a plan up or making a new one: the job re-sorts its own
// filings, so a fresh one is still a guess. A person's label counts
// at once.
settleDays: 3,
// When a script added project labels with a person's token: a label
// `actor` added between `from` and `to` (ISO times, UTC) counts as the
// labeling job's, not theirs: in /api/v1/projects `examples`, and it
// waits `settleDays` before counting toward an automatic call.
machineLabelRuns: [
{ actor: 'jarstelfox', from: '2026-09-30T06:57:00Z', to: '2026-09-30T07:00:00Z' },
{ actor: 'jarstelfox', from: '2026-10-10T07:55:00Z', to: '2026-10-10T08:05:00Z' },
{ actor: 'jarstelfox', from: '2026-10-10T18:14:00Z', to: '2026-10-10T18:51:00Z' },
{ actor: 'jarstelfox', from: '2026-10-10T19:28:00Z', to: '2026-10-10T19:41:00Z' },
{ actor: 'jarstelfox', from: '2026-10-10T22:50:00Z', to: '2026-10-11T00:11:00Z' },
{ actor: 'jarstelfox', from: '2026-10-11T02:35:00Z', to: '2026-10-11T03:10:00Z' },
{ actor: 'jarstelfox', from: '2026-10-13T22:23:00Z', to: '2026-10-13T22:59:00Z' },
{ actor: 'jarstelfox', from: '2026-10-13T23:32:00Z', to: '2026-10-13T23:42:00Z' },
],
},

// The usual MySQL stuff. Like every other MySQL webapp, basically.
Expand Down
40 changes: 34 additions & 6 deletions controllers/api-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export const PROJECT_WRITES = [
* GET /api/v1 lists it, so a script (or an agent) reading the list can't
* miss a route. Each takes the caller's own GitHub token as
* `Authorization: Bearer <token>`; a write also needs a JSON body and is
* recorded as that token's login.
* recorded as that token's login. A read key (config.js apiReadKeys) reads
* only (lib/api-auth.js).
*/
export const API_ROUTES = [
{
Expand All @@ -68,7 +69,11 @@ export const API_ROUTES = [
'A PR counts toward its project label, or with none the project whose issue it links; ' +
'`double_labeled` holds the open PRs in two. Each project says its `initiative` (slug, or null for ' +
'Unsorted), whether it is `tracked`, and whether it `grew` while not tracked. A project Not tracked because ' +
'its PRs are all in projects.untrackedRepos has `initiative: null` with `tracked: false`',
'its PRs are all in projects.untrackedRepos has `initiative: null` with `tracked: false`. For the ' +
'labeling job: `status`, its plan鈥檚 status or null; `last_activity`, the team鈥檚 day (projects.timeZone) ' +
'its newest PR opened, merged or was pushed to, the activity a plan is put down by; `examples`, up to 5 ' +
'of its labeled PRs, open or merged, {repo, number, title, merged_at, by}, `by` "person" ' +
'(listed first) or "job" (a bot, or a projects.machineLabelRuns window), newest first',
},
{
method: 'get',
Expand Down Expand Up @@ -151,8 +156,11 @@ export const API_ROUTES = [
handlers: [projectsController.getDecide],
does:
'The decisions owed now, worst first, each with its project, roadmap item, `initiative` (its home, or ' +
'the one asked about), and reasons (see `decide`); days are the team鈥檚 (config.js projects.timeZone, ' +
'default America/Los_Angeles). Not-tracked projects are left out, but for the ones that grew',
'the one asked about), and reasons (see `decide`). `auto_calls`: whether Pulldasher makes the calls ' +
'that only say what happened itself (see `decide.made_by_pulldasher`); `pending_calls`, the ones it ' +
'makes this pass, and the reasons they answer are left out. Days ' +
'are the team鈥檚 (config.js projects.timeZone, default America/Los_Angeles). Not-tracked projects are ' +
'left out, but for the ones that grew',
},
{
method: 'get',
Expand Down Expand Up @@ -316,7 +324,9 @@ export const API_ROUTES = [
* against, so a caller can learn the API from the API. */
export function apiIndex(req, res) {
res.json({
auth: 'Authorization: Bearer <your GitHub token>, from a member of the org',
auth:
'Authorization: Bearer <your GitHub token>, from a member of the org. A read key (what the ' +
'labeling job sends; config.js apiReadKeys holds its SHA-256) reads (GET) and never writes',
endpoints: API_ROUTES.map(({ method, path, does }) => ({
method: method.toUpperCase(),
path,
Expand Down Expand Up @@ -345,6 +355,8 @@ export function apiIndex(req, res) {
},
done_when: `one line on what finished looks like, up to ${DONE_WHEN_MAX} characters; line breaks become spaces`,
notes: 'up to 2000 characters',
auto_note:
'read only: why Pulldasher last changed it on its own, and `auto_at` when; null when it never has',
waits_on: `ids of other items this one waits on, at most ${WAITS_ON_MAX}, never a loop`,
},
initiative: {
Expand All @@ -370,7 +382,7 @@ export function apiIndex(req, res) {
off_track: 'its latest update says off track, and the plan hasn鈥檛 changed since',
at_risk: 'its latest update says at risk, and the plan hasn鈥檛 changed since',
issue_closed:
'its project issue was closed (`as` done or dropped, `on` a day) after the plan last changed, and the plan is still under way',
'its project issue was closed (`as` done or dropped, `on` a day) after the plan last changed, and the plan is still under way; `open` = its PRs still open',
reopened:
'marked done or dropped a week ago or more (`by` the roadmap or by closing the issue), but `open` PRs are still open',
moving: 'parked, but its PRs changed after it was parked',
Expand All @@ -385,6 +397,22 @@ export function apiIndex(req, res) {
reopened_in_closed:
'its `initiative` is closed (`as` done or dropped), but it has open PRs or a plan under way again: the same rule that refuses a close. On the project鈥檚 own row when it has one',
},
made_by_pulldasher:
'With config.js projects.autoCalls true (off by default), Pulldasher makes these calls itself after each ' +
'work sync, recorded as `pulldasher` with why in the item鈥檚 `auto_note`. Decide leaves a reason out ' +
'only when a call this pass answers it: `new` (a plan in progress with no end, made for any project ' +
'with 3 or more PRs open or merged in the last 14 days, merged bursts too, unless one was ever ' +
'removed), `reopened` and `moving` (back in progress once a PR opens, or an open PR is labeled in, ' +
'after the status was set: 1 for a parked plan, 2 for a done or dropped one, and never once its issue ' +
'closed after its last change), `issue_closed` and `issues_done` with no PRs open (done, or dropped when closed as ' +
'not planned), and `ended` while nobody changed its status since its end; a plan being finished is ' +
'asked nothing more. A PR the labeling job filed counts toward a pick-up or a new plan once its label ' +
'is projects.settleDays old (default 3), and toward a pick-up only by when it opened; a person鈥檚 ' +
'label counts at once. An empty lead a person may have cleared (they changed the plan since its PRs ' +
'last moved) stays empty. It also parks a plan in progress after projects.idleDays ' +
'(default 28) with no PR opened, merged or pushed, no update and no change by a person, and fills an ' +
'empty lead on a plan under way with its issue鈥檚 assignee, else the author of the most PRs in the ' +
'last 60 days. A person鈥檚 write wins: change it back as usual',
to_clear:
'Make the call as a roadmap write. With no item: POST /api/v1/roadmap {name, project, status, ' +
'start, weeks, end_kind: "hard"}. With one: PATCH /api/v1/roadmap/:id with {status: "active", weeks, ' +
Expand Down
6 changes: 4 additions & 2 deletions controllers/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ export default {
},

/** GET /api/v1/me -- the authenticated caller's login, so a skill can
* confirm its token works and resolve the default reviewer. */
* confirm its token works and resolve the default reviewer. A read key has
* none: `read_key` says it reads only. */
getMe: function (req, res) {
res.json({ login: req.apiUser.login });
const { login, readKey } = req.apiUser;
res.json(readKey ? { login, read_key: true } : { login });
},
};
Loading
Loading