Documentation
¶
Overview ¶
backend.go is the seam. The FORMAT is the five event lines in event.go; a backend decides only where a line is appended and how the lines are read back, and it decides nothing about what a line means. The two backends must produce identical listings from identical histories — that is a test, not an aspiration — and this interface is what makes that cheap to keep true: nothing above it can learn which backend it has.
THE READ IS THE WHOLE LOG. There is no window. The prototype read the last forty comments, which means a card older than forty events VANISHES, and a vanished card makes the count fall without any work being done — the one thing a board may not do. A board is small by construction: it is what is owed, and what is owed is closed.
A CACHE MAY AVOID RE-READING; IT MAY NEVER TRUNCATE, and it may never outlive the run. The prototype kept a twenty-second time-to-live cache in a temporary directory, which is exactly wrong for the duplicate-filing failure this tool exists to close: two reviewers filing nine seconds apart both read the board as it was before either wrote. The cache here is a field in memory, held for one invocation, and INVALIDATED BY THIS TOOL'S OWN APPEND, so a close's read-before-append can never be answered from bytes read before this run wrote. There is no cache file anywhere, and rule 8 forbids one.
derive.go is events in, cards out. Nothing about a card is stored: the state, the owner, the counts and staleness are all derived from the log on every read, which is why the board can say the same thing to every line that reads it.
THE FOLD ORDER IS CAUSAL FIRST, THEN TOTAL, AND IT IS THE SAME IN EVERY CLONE. The after= edges form a directed acyclic graph rooted at the card event, and the fold walks it topologically: every predecessor before every event that names it. Among events that are eligible at one step — and eligible events are exactly the ones with no after= path between them, which is what concurrent means — the one that goes first is the smallest by the stable total key (at, as, id, verb, the line's bytes). The key is never applied across a causal edge: an event whose writer SAW another event folds after it whatever its clock says, because a clock behind is a clock and not a cause.
Two clones that merged the same concurrent appends in opposite textual orders therefore derive the same owner, and so do the two backends, which is the property the whole tool rests on. Never by the order the lines appear in a file or a comment thread.
WHAT IS COUNTED RATHER THAN HIDDEN. Two events with the same after= are concurrent: the fold chooses one and counts the pair in conflicts=, so a take that won a tie is visible as a tie rather than as the only take. Two card lines with one id and different fields are a conflict and neither silently wins. An event whose after= names no event of this card, one naming an event of another card, and one on a cycle are QUARANTINED: not folded, counted, named once, and never guessed at.
STALENESS IS THE ONLY CLOCK-DEPENDENT DERIVATION HERE, and it is an annotation made at read time from the take's own stamp. The board writes nothing for it: it has no way to know whether a line is silent, it knows only when the take was written.
dir.go is the directory backend: one file per card, `<dir>/<id>.board`, created by add and appended to by take and close. The board is a FOLD over every file in the directory.
ONE FILE PER CARD, ONE WRITER PER CARD, AND NO LOCK. There is no shared file and no index, so there is no serial step for two lines to queue behind: an add creates a file whose name is the tool-owned id, so two adds of one thing are two files; a take or a close appends one line to one file, and a second line appending to the same file appends a DIFFERENT line, which a union merge keeps in either order and the fold order sorts. The tool holds no lock because it needs none.
CREATION IS EXCLUSIVE AGAINST HAND-MADE FILES. The card file is created with O_EXCL and is never truncated. An id that already exists is a refusal at exit 1 and no file is ever replaced.
THE DIRECTORY BACKEND APPENDS AND NEVER RUNS GIT. Committing and pushing it is the caller's, and saying so is more honest than a push hidden inside add. That is the one asymmetry between the backends: an add --dir is durable when the caller lands it, and an add --issue is durable when the command returns.
Package board is the owed-work layer: a board is the list of things a group of lines owes, kept as an append-only log of events, and the list of open cards is DERIVED from that log rather than stored anywhere. This file is the format — the five event lines, rendered and parsed — and the two identities a board uses.
Two rules live here and nowhere else, because everything above them depends on both.
THE ID IS A DRAW, NEVER A DERIVATION. add reads 128 bits from the operating system's random source and renders them as thirty-two lower-case hex characters. It is computed from nothing — not the stamp, not the filer, not the text, not a count — so two adds from two clones, under one name, at one second, with one text, get two ids without reading anything first. An earlier draft derived the id from a hash over an unsynchronized sequence number, which is a read two writers can both make before either writes; a creation identity may depend on nothing of that kind.
PARSING IS ANCHORED AND BY ID. A line binds to a card only when it BEGINS with one of the five verbs and the id is the token immediately after it. The prototype matched `closed[ :]*<sid>` anywhere in any comment body, so on a board about a repository full of issue numbers a card whose text held a six-digit number could be closed by a sentence about something else.
Every key=value value on an event line goes through oneline.Field and the free text after the ": " through oneline.Escape, so an event is one line whatever a filer pastes and a whitespace-splitting scanner counts exactly the fields the tool wrote. The values a parsed event carries are the ESCAPED ones — that is what the board holds — and rendering them again is a fixed point, which is what makes a parsed event render back to the bytes it was read from.
issue.go is the issue-comment backend: one event per comment, in comment order, read and appended with `gh`. It is where today's board lives, and it is chosen for a good reason — every line in this family can already read and write it, from any machine, with no clone and no write access to a repo's branches.
`gh` stays, NAMED, under a timeout: a subprocess that never returns is a tool that has stopped saying anything, which from the outside is indistinguishable from a tool that is working. `jq` does not stay; the prototype had it as a second hard dependency and this is encoding/json.
The read is the WHOLE thread, fully paginated. The prototype's `.[-40:]` is the window this spec forbids.
Index ¶
- Constants
- Variables
- func Closing(verb string) bool
- func Dur(d time.Duration) string
- func HashOf(renderedText string) string
- func Hex(s string, n int) bool
- func NewEv(r io.Reader) (string, error)
- func NewID(r io.Reader) (string, error)
- func ParseIssue(spec string) (owner, repo string, number int, err error)
- func Render(e Event) string
- func SetGHBinary(path string) (restore func())
- func Stamp(t time.Time) string
- func Tail(s string) string
- type Backend
- type Board
- type Card
- type Counts
- type Dir
- type Event
- type Issue
- type Leg
- type Line
- type Log
Constants ¶
const ( IDHex = 32 EvHex = 12 )
IDHex and EvHex are the two identity widths. A card's id is 128 bits because two draws of it meeting is not an event this family will see; an event's is 48, which is enough to name a predecessor inside one card's history and short enough to read.
const Version = "BOARD v1"
Version is the first line of every card file: a later format read as this one would be entries nobody wrote.
Variables ¶
var ErrExists = errors.New("id exists")
ErrExists is an append of a card whose id already exists. With a random id that is a hand-made file, a copied one, or a broken random source — never an overwrite.
var ErrNoCard = errors.New("no card with that id")
ErrNoCard is an append of a later event to a card the backend does not hold.
var Verbs = []string{"card", "taken", "closed", "landed", "probed"}
Verbs are the five event lines, and that is the whole format.
Functions ¶
func Dur ¶
Dur renders an age. A clock a hair behind another line's reads as zero rather than as a negative number, which is a number no reader has a use for.
func HashOf ¶
HashOf is the content hash: the first twelve hex characters of a SHA-256 over the card's text AS RENDERED. The text only — not the leg, owner, deadline, default or evidence — and it is never an identity. It has one use: a note beside a filing that looks like one already open.
func ParseIssue ¶
ParseIssue reads the `<owner>/<repo>#<n>` a caller names.
The three parts are checked rather than pasted: they become gh's own argv, so a value beginning with a dash is an OPTION to gh and not a repository, and this tool would run it. The charset is deliberately narrow — the cost of being narrow is a refusal a person reads, and the cost of being wide is a shell.
func SetGHBinary ¶
func SetGHBinary(path string) (restore func())
SetGHBinary points this backend at one gh and returns the restore, for a test to hand to t.Cleanup. It is exported for the tests of the command that drives this backend in-process; nothing in the tool itself calls it.
func Tail ¶
Tail prepares a free-text tail for an event line: capped at oneline.TailBytes with the mark that says so, then escaped. Cap runs BEFORE the escape, on a rune boundary, so an escape sequence is never halved. A caller builds a tail once, at creation; Render does not cap, so that re-rendering a parsed event cannot cut a tail twice.
Types ¶
type Backend ¶
type Backend interface {
// Events reads the WHOLE log.
Events() (Log, error)
// Append adds one event line. The line is already rendered and already one line.
Append(line string) error
}
Backend is where a board's lines live.
Two methods, and deliberately no third: nothing above this can ask which backend it has, so a rule cannot be written that holds in one and not the other.
type Board ¶
type Board struct {
Cards []*Card
Unparsed int
UnparsedFiles int
Quarantined int
Conflicts int
FirstQuarantined string
Orphans int // events naming a card no card event creates
}
Board is the whole fold.
func Derive ¶
Derive folds a log into cards. now and stale are the two clock inputs, injected rather than read, so that every derivation in the tests is a pure function of its arguments.
func (*Board) Lines ¶
Lines returns one row per owner of an open card, in the order the cards are in, so the output is deterministic without being alphabetised into a shape the fold did not choose.
func (*Board) Next ¶
Next is the one thing to do first, by a fixed order: the oldest overdue card, else the oldest stale card, else the leg with the most owed rows, else the oldest ordinary OPEN card, else — and only when open is zero — nothing owed. A board with one fresh card and a future deadline HAS something to do first, and a tool that said "nothing owed" over it would be the count falling for the wrong reason, said in words.
type Card ¶
type Card struct {
ID string
Text string
Hash string
Owner string // the latest take in the fold order, else the add's --owner, else the filer
CardOwner string // the owner= ON THE CARD LINE, which a take never moves
Filer string
By string // the deadline as the add stored it
Default string
Thing string
Leg string
Evidence string
Since time.Time
SinceRaw string
State string // OPEN or CLOSED, derived and stored nowhere
Close *Event // the FIRST close in the fold order; the later ones stay in the log
LatestAt time.Time
TakenAt time.Time
HasTake bool
Conflicts int
Quarantined int
Conflict bool
Stale bool
Overdue bool
Row bool
Probed bool
// Age and Taken are rendered here because they are derivations from the injected
// clock, and the printing side has no clock of its own.
Age string
Taken string
Events []Event // the card's events in the fold order, quarantined ones excluded
}
Card is one thing owed, as the log says it stands right now.
func (*Card) After ¶
After is the ev a writer appending to this card names as what it saw: the newest event in ITS OWN fold, and the card's id when there was none. A writer never names an event its fold does not hold — a quarantined event is not in the fold, so it is never named.
type Counts ¶
type Counts struct {
Cards, Open, Closed, Stale, Overdue, Owed, Lines, Conflicts, Quarantined int
}
Counts are the nine facts BOARD OK carries. They are the truth about the BOARD and never about the output: the listing is capped, the counting never is.
type Dir ¶
type Dir struct {
// contains filtered or unexported fields
}
Dir is the directory backend.
func NewDir ¶
NewDir returns the backend for a directory of card files. The directory must exist: a tool that made one would be guessing at where a caller meant to keep a board.
func (*Dir) Append ¶
Append writes one event line. A card event creates its file with O_EXCL and never truncates; every other event appends to the file its id names.
type Event ¶
type Event struct {
Verb string // card, taken, closed, landed, probed
ID string // the card's thirty-two hex id
Ev string // this event's own twelve hex id; empty on card
After string // the newest ev in the writer's fold, or the card id; empty on card
As string // the --as of the verb that appended it: a label, never an identity
At time.Time
AtRaw string // what the line carried, kept when it will not parse
AtOK bool
Override bool // true exactly when --anyway went over a refusal
Hash string // card: twelve hex over the rendered text, for a duplicate note only
Owner string // card: --owner, else the filer
By string // card: the deadline, stored as given
Default string // card: what happens if nobody closes it
Thing string // card: a row of the owed ledger
Leg string // card: the leg the row is owed on
Evidence string // card: a path the filer named, carried and never opened
In string // landed: <repo>#<n>
Tail string // card text, a close's how, a probe's evidence
Line string // the bytes this event was parsed from, empty when it was built
}
Event is one line of a board's log.
A field holds the value as it appears ON THE WIRE once it has been through Render: the creating caller hands raw text and Render escapes it, and a parsed event holds the escaped form, which Render leaves alone. So Parse(Render(e)).Render() == Render(e).
func Parse ¶
Parse reads one line of a board's log. It returns false for anything that is not exactly one of the five events — a blank line, a comment, prose, a line in the shape an earlier draft used — and the caller COUNTS those rather than guessing at them.
func (Event) CreationFields ¶
CreationFields is everything on a card line but the stamp: what an --id retry must match for the retry to be the same filing rather than a different one.
type Issue ¶
type Issue struct {
Reads int // how many times the thread was actually fetched, for the cache's test
// contains filtered or unexported fields
}
Issue is the issue-comment backend.
func (*Issue) Append ¶
Append posts one comment carrying one event line. The body goes in on STDIN rather than on the command line, so nothing an event holds is ever an argument to gh.
CREATION IS EXCLUSIVE HERE TOO. A card line is appended only after the thread is RE-READ — not from this run's cache — and the id looked for: the forge append is the only serial point this backend has, so this re-read is its half of the O_EXCL the directory backend gets from the filesystem. An id that already exists is a hand-made comment, a copied one, or a broken random source, and it is ErrExists with nothing posted, never a second card under one id folded silently into the first.
type Leg ¶
Leg is one leg of the owed ledger: the BOARD LEG row. Done means owed is zero, and the tool prints that number rather than a word.