ticket

package
v0.3.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package ticket implements the on-disk ticket format and store.

The file format IS the API: tickets are hand-edited and read in git diffs, so writing one field must leave every other byte of the file untouched. That constraint rules out the obvious implementation (unmarshal into a struct, mutate, marshal back), and it also rules out re-printing a YAML AST — both silently renormalize quoting, key order, and comment alignment on fields the caller never asked to change.

Instead, writes use the parser purely as a locator: it yields the line and column of the value being replaced, and the replacement is spliced into the original bytes. Everything outside the spliced span is preserved by construction rather than by the serializer's good behaviour.

Index

Constants

View Source
const (
	FieldID         = "id"
	FieldTitle      = "title"
	FieldStatus     = "status"
	FieldReady      = "ready"
	FieldCreator    = "creator"
	FieldAssignee   = "assignee"
	FieldGoal       = "goal"
	FieldContext    = "context"
	FieldDoD        = "definition-of-done"
	FieldBlockedBy  = "blocked-by"
	FieldCommits    = "commits"
	FieldModelTier  = "model-tier"
	FieldExecutedBy = "executed-by"
	FieldCreatedAt  = "created-at"
	FieldUpdatedAt  = "updated-at"
	// FieldUpdatedBy is who wrote the most recent change, beside when it
	// happened. Several people and several agent sessions write the same store,
	// and updated-at alone says a ticket moved under you without saying who
	// moved it — which is the thing you actually need before touching it.
	FieldUpdatedBy = "updated-by"
	FieldQuestion  = "question"
	FieldClaimedBy = "claimed-by"

	// FieldMode says how this ticket is to be worked, not what it contains.
	// Empty is the default: lanes run autonomously. "conversational" says the
	// dispatcher found open design decisions on it and a person is reading
	// along — the worker shows its diff after each definition-of-done item and,
	// when its lane changed code, hands back a commit line instead of
	// committing itself. A lane that changed none hands back no line: the mode
	// sits on the ticket, so critique, testing and review run in it too, and
	// there the ordinary rule still holds — no code, no commit.
	//
	// It sits on the ticket rather than in the line that starts the worker so
	// that it survives a killed session: a fresh worker reads the mode off
	// disk, where a typed argument would be gone and the run would silently be
	// autonomous again.
	//
	// Nothing clears it automatically. The mode is a statement about the
	// ticket ("decisions were open here"), not about one lane, so it holds
	// through critique and testing too; a person clears it with
	// 'jaira set <id> mode='.
	FieldMode      = "mode"
	FieldClaimedAt = "claimed-at"

	// The outcome is three flat keys rather than a nested mapping. Nesting would
	// force the writer to rewrite an indented block to change one field, and the
	// merge driver resolves per top-level key — flat keys keep both operations
	// single-line, which is the whole point of the format.
	FieldOutcomeWhat     = "outcome-what"
	FieldOutcomeWhy      = "outcome-why"
	FieldOutcomeResolves = "outcome-resolves"

	// Reserved for a future external tracker adapter. jaira never interprets it.
	FieldExternal = "external"

	// FieldFollows links a follow-up ticket back to the one whose review produced
	// it, so the trail from "this was not finished" to "this is the rest" is not
	// lost the moment the first ticket closes.
	FieldFollows = "follows"

	// FieldBlockedReason is why a ticket is parked in the blocked lane. blocked-by
	// covers the case where the blocker is another ticket; this covers everything
	// else — a vendor, a decision, an environment — which is the majority and
	// which the board otherwise records nowhere. A blocked ticket whose reason is
	// unrecorded is the one a board loses: it looks the same on day one and on
	// day ninety.
	FieldBlockedReason = "blocked-reason"

	// FieldReviewVerdict is the reviewer's conclusion, kept separate from
	// outcome-resolves. The implementer writes what it believes it achieved and
	// the reviewer judges the diff; storing both in one field destroys the pair
	// the sign-off view exists to show.
	FieldReviewVerdict = "review-verdict"

	// FieldReviewSummary is what the change actually does, read off the diff.
	// The verdict alone is a judgement with no account of what was judged; a
	// later reader needs the summary to know what shipped without re-reading
	// the diff themselves.
	FieldReviewSummary = "review-summary"

	// FieldReviewGaps is what the reviewer found missing or unconvincing. It
	// must be filled even when there is nothing to report — "none" is a valid
	// answer, an empty field is not — because an empty field must always mean
	// "nobody looked", never "nothing found".
	FieldReviewGaps = "review-gaps"

	// FieldReviewCheck is how to check the change by hand: run this, then that,
	// then look at this. Every other review field is an account of what
	// happened; this one is the only thing the reader can act on, and without it
	// signing off means either trusting the account or reconstructing the steps
	// from the diff. Written for someone who knows none of what the reviewer
	// knows — see the writing register the create and note helps carry.
	FieldReviewCheck = "review-check"

	// FieldParent is the ticket this one is a part of — the child names the
	// parent, never the other way round. A parent field on the child is the
	// only direction that can always be written: the parent may be in the
	// logbook, in the archive, or on a ref with no file here at all, and a
	// children list would mean editing a file that does not exist. It also
	// keeps one writer per file, which is what stops two sessions adding two
	// children from colliding on one line.
	//
	// Children and the whole tree below them are derived by reading, not
	// stored. Nesting is free that way, and there is no second copy of the
	// relation to drift.
	//
	// parent is deliberately not a gate: an unfinished child does not hold its
	// parent back. A dependency that must hold is blocked-by.
	FieldParent = "parent"

	// FieldRelated is a list of tickets this one merely has to do with —
	// neither blocking it nor containing it. It is stored on whichever side
	// somebody happened to edit and shown from both, because a relation that
	// had to be written twice would be half-written most of the time.
	FieldRelated = "related"

	// FieldTags is a plain list of topic labels — "ui", "backend" — so a
	// backlog can be read one subject at a time instead of one lane at a time.
	// A list rather than a comma-joined scalar because that is what every other
	// multi-valued field here is, which is what makes 'jaira set tags=a,b' and
	// the merge driver's union-instead-of-pick behaviour come for free.
	//
	// Colours are deliberately not here: they live once per board in .jaira/tags
	// (core/tag), because a colour is a fact about the tag rather than about the
	// ticket wearing it.
	FieldTags = "tags"
)

Frontmatter field names. These are the API — they appear in every ticket file and in git diffs, so they are stable and referenced by name everywhere rather than being spelled inline.

View Source
const (
	// DirName is the per-repo directory holding all jaira state.
	DirName = ".jaira"
	// TicketsSubdir holds one markdown file per ticket.
	TicketsSubdir = "tickets"
	// ArchiveSubdir holds tickets taken off the board. They are moved rather
	// than deleted: the whole point of the board is that you can still answer
	// what a task was for months later, and a deleted file on a private board
	// is not in git history to recover from.
	ArchiveSubdir = "archive"
	// SharedSubdir holds lanes deliberately published to teammates. Unlike
	// LanesSubdir it is committed: publishing a lane is an opt-in act through
	// the lane settings screen, not a side effect of sharing the board.
	SharedSubdir = "shared"
	// LogbookSubdir holds tickets whose work is finished and whose commit list
	// is final, grouped into dated folders by who took them off the board. A
	// folder under here is a readable record of one person's sweep — unlike
	// ArchiveSubdir, every ticket that lands here has already had its commits
	// stamped, because leaving the board is the point at which every commit
	// is finally known.
	LogbookSubdir = "logbook"
	// MilestonesSubdir holds one file per milestone, and names the folder a
	// filed milestone gets inside a logbook folder. It is spelled here rather
	// than taken from core/milestone because that package reads this one; the
	// constant there is defined from this one, so there is still one spelling.
	MilestonesSubdir = "milestones"

	// SessionsSubdir and locksSubdir live under the user's home directory rather
	// than inside the repository. tickets/, archive/ and shared/ are the parts of
	// .jaira/ meant to be committed; lanes/ (see core/lane.ProjectLanesDir) is
	// this machine's own scoping and stays gitignored even on a shared board
	// (see core/board.LanesIgnoreLine); sessions and locks stay out of the
	// repository entirely.
	SessionsSubdir = "sessions"
)
View Source
const ModeConversational = "conversational"

ModeConversational is the one non-empty value FieldMode takes. A worker compares against exactly this word, which is why 'jaira set' refuses anything else: "mode=chat" would be stored happily and do nothing, and a person believing they are in the mode while the agent commits is the failure this mode exists to prevent.

View Source
const NoteHeading = "## Progress"

NoteHeading is the body section progress notes live under.

Notes go in the ticket body rather than a sidecar file on purpose: a separate memory file would be one more thing to lose, would not travel with the ticket through git, and could drift out of sync with it.

View Source
const SourceWorktree = "worktree"

SourceWorktree is the token CommitsSource cannot produce, because it names something that is not a commit: work sitting in the working tree, judged alongside the commits. It joins a commit source with a "+", so a list built from git with uncommitted work beside it reads "git+worktree".

Variables

View Source
var (
	// ErrNoFrontmatter means the file does not open with a YAML frontmatter block.
	ErrNoFrontmatter = errors.New("ticket: file does not begin with '---'")
	// ErrUnterminated means the frontmatter block is never closed.
	ErrUnterminated = errors.New("ticket: frontmatter block is not terminated by '---'")
	// ErrUnsafeYAML means the frontmatter uses constructs this tool cannot
	// rewrite without risking corruption. Reported rather than mangled.
	ErrUnsafeYAML = errors.New("ticket: frontmatter uses YAML features jaira cannot safely rewrite")
)
View Source
var (
	// ErrNotFound means no ticket matched.
	ErrNotFound = errors.New("ticket: not found")
	// ErrAmbiguous means an ID prefix matched more than one ticket.
	ErrAmbiguous = errors.New("ticket: ambiguous id prefix")
	// ErrNoStore means no .jaira directory was found.
	ErrNoStore = errors.New("ticket: no .jaira directory found; run 'jaira init'")
)
View Source
var ErrNotRecorded = errors.New("ticket: the write was not recorded for the remote")

ErrNotRecorded means the local write went through but the recorder did not take it — the ticket is correct on this machine and has not left it.

It is a separate sentinel rather than a plain error because the two halves need opposite handling by the caller: the mutation must be reported as done (it is), while the failure to hand it on is worth a line to the user. A recorder that returns nothing but this is still a working board.

View Source
var ErrOnRefOnly = errors.New("ticket: on its ref and not on your disk; pull it first")

ErrOnRefOnly means the ticket exists for this board but not as a file here, so it cannot be written to until somebody pulls it.

It is separate from ErrNotFound because the two ask different things of the user: one means the id is wrong, the other means the ticket is real and one command away.

View Source
var SuppliedFields = []string{
	FieldTitle, FieldGoal, FieldContext, FieldDoD, FieldAssignee, "diff",

	"notes",
}

SuppliedFields names the lane input-requires values a ticket already carries — or that the tool assembling a lane's bounded input derives itself — from the moment of creation, independent of any lane's output-produces. "diff" is not a frontmatter scalar (it is assembled from git commits at request time), so it is named here rather than with a Field constant.

"plan" is deliberately not in this set, even though the Plan heading is seeded on every new ticket: an empty Plan checklist does not satisfy a lane's input-requires (see flow.go's showForLane), so a lane requiring "plan" genuinely depends on an earlier lane — pre-process — having produced one. Exempting it here would make it impossible for the load-order check below to ever catch a lane misordered relative to its own producer, which is the one case this check exists for.

core/lane's load-time contract check compares a lane's input-requires against this set plus every earlier lane's output-produces, so this is the one place both packages read rather than two lists that could drift apart.

Functions

func AddItem

func AddItem(body string, sec Section, text string) (string, error)

AddItem appends a checklist item to a section, creating the section if the ticket has no such heading.

This exists because there was no way to write a plan at all: frontmatter is set with SetScalar and the body was only editable by hand, so the lane whose entire output is a Plan checklist had no means to produce one. Appending to the end of the file is not the same thing — a checkbox only counts inside its heading, and the end of the file is usually some other section.

func AppendNote added in v0.1.3

func AppendNote(d *Doc, text string, now time.Time, who string) error

AppendNote adds one timestamped note under the Progress heading, creating the heading if the body has none.

It lives here rather than in the command that types notes because it now has two callers — a person writing one, and a take-over recording that it happened — and two copies of "where does a note go" would eventually disagree about the answer.

func CanonicalMode added in v0.3.0

func CanonicalMode(v string) (string, bool)

CanonicalMode reports whether v is a value FieldMode accepts — empty, meaning the default autonomous run, or ModeConversational — and returns the form to store. Every write path goes through here rather than comparing against the constant itself, so the CLI and the TUI cannot come to disagree about what a mode is. It returns the trimmed value rather than only judging it, because a path that accepts " conversational " and then stores it unchanged is the same silent failure as accepting "chat": the worker compares against exactly one word and would run on as if no mode were set.

func CommitsSource added in v0.3.1

func CommitsSource(derived, merged []string) string

CommitsSource names where a merged commit list came from: "git" when the derivation accounts for all of it, "ticket" when the derivation found nothing and the recorded field carries it, and "git+ticket" when the field contributed a sha git could not find — a rebase or a cherry-pick. Every screen that labels such a list shares this so two screens built on the same data cannot end up holding themselves to two standards of honesty.

func DoDHeadings

func DoDHeadings() []string

DoDHeadings and PlanHeadings expose the recognised section names so a renderer can tell which parts of a body it has already displayed as a checklist.

func Filename

func Filename(id, title string) string

Filename is the on-disk name for a ticket.

func FormatTime

func FormatTime(t time.Time) string

FormatTime renders a timestamp the way tickets store it.

func Handle

func Handle(id string) string

Handle is the short reference shown to people and printed by the CLI.

It is taken from the end of the ULID rather than the beginning, because the leading ten characters encode only the millisecond timestamp: tickets created in the same burst share them entirely. The trailing characters are the random component, so a tail is distinctive where a head is not.

func HeadingTitle

func HeadingTitle(body string) string

HeadingTitle returns the first level-one heading of a markdown body.

func IDFromFilename

func IDFromFilename(name string) string

IDFromFilename recovers the identifier from a ticket filename.

func LogbookFolderDay added in v0.3.2

func LogbookFolderDay(name string, loc *time.Location) (time.Time, bool)

LogbookFolderDay reads the day a logbook folder was filed on off its name, <initials>-<yyyymmdd>, and reports false for a name that does not end in one.

func LogbookWindowStart added in v0.3.4

func LogbookWindowStart(now time.Time, days int) time.Time

LogbookWindowStart is the first day a logbook window of days days reaches back to: midnight, days days before now. A folder dated that day or later is inside the window. Whole days, because a folder carries a day and nothing finer; time.Date rather than Add, because across a DST change a day is not 24 hours. 'jaira logbook' and the board cut at the same place through this.

func MergeCommits added in v0.3.1

func MergeCommits(derived, recorded []string) []string

MergeCommits unions a derived commit list with a recorded one. Derived shas come first, in git order; a recorded sha the derivation did not find is appended rather than dropped — a sha somebody wrote down deliberately, or one whose commit a rebase moved off the branch, is evidence this tool has no business discarding. Four callers share it and must not drift apart: StampCommits, which writes the union onto the ticket; 'move --out --commits' (internal/cli/flow.go), which folds the shas a lane hands back into the field; the lane payload (same file), which reads it to build the diff a review lane judges; and the signoff screen (internal/tui/signoff.go), the screen a person accepts work on, where drift costs most.

func NewBody

func NewBody(title string, dod []string, options []BodyOption) string

NewBody is the starting shape of a ticket's markdown, shared by the CLI and the TUI so a ticket created either way looks the same.

options is resolved by the caller (core/ticket must not import core/lane, which itself imports core/ticket) from the installed lanes' requires-option fields, with a per-user default board deciding which ones start ticked. Passing nil options reproduces today's two-option shape with nothing ticked — the regression baseline this function must not silently drift from.

func NewID

func NewID(now time.Time) string

NewID mints a ticket identifier.

ULIDs are used rather than sequential numbers (JAIRA-1, JAIRA-2, ...) because a sequential scheme needs a coordinator to hand out the next value. In a store with no server, that coordinator would be a counter file — the single line in the repository that every ticket creation touches, and therefore the worst possible merge-conflict hotspot. A ULID needs no coordinator, and it sorts lexicographically by creation time, so the tickets directory lists in order.

func NormalizeIDPrefix

func NormalizeIDPrefix(p string) string

NormalizeIDPrefix upper-cases a user-supplied prefix, since ULIDs are encoded in uppercase Crockford base32 but nobody types them that way.

func OptionHeadings

func OptionHeadings() []string

func PlanHeadings

func PlanHeadings() []string

func SetItemProof

func SetItemProof(body string, sec Section, i int, proof string) (string, error)

SetItemProof attaches evidence to the i-th checkbox (0-based) of one section, replacing any proof line already there, and returns the new body.

The proof rides directly beneath its item rather than living in its own section: that is what lets it survive SetItemState and AddItem acting on other items, since neither touches a line it does not address by index. The item's own line is left byte-identical, the same way SetItemState leaves it — the point of a one-character or one-line diff applies here too.

func SetItemState

func SetItemState(body string, sec Section, i int, st State) (string, error)

SetItemState rewrites the marker of the i-th checkbox (0-based) of one section and returns the new body.

Only the single marker character is replaced. The item's text, the surrounding prose, and every line ending are left exactly as they were, so a tick produces a one-character diff rather than a reflowed body — which is what keeps the file readable in a diff and cheap to merge.

Marking an item as in progress clears any other in-progress item in the same section: the marker answers "which one is being worked on", and that question has one answer.

func SetItemText added in v0.1.1

func SetItemText(body string, sec Section, i int, text string) (string, error)

SetItemText rewrites the words of the i-th checkbox (0-based) of one section, leaving its marker and its proof line exactly as they were.

Without this there was no way to reword a criterion at all: the only route was to tick the stale one with the proof "obsolete, replaced by item 6" and append a new one, which leaves ticked items that were never met. Rewording is the honest form of that edit; SetItemState with StateSuperseded is the other.

func SetReady

func SetReady(d *Doc, ready bool) error

SetReady records whether the promotion gate is currently satisfied. It is a derived convenience for rendering, never the authority: the gate is always recomputed before a move.

func Slug

func Slug(title string) string

Slug renders a title into a filename-safe breadcrumb. It is a static hint for humans running ls or grep, deliberately not kept in sync with later title edits: renaming files on every title change would churn git history for no benefit. The frontmatter title is the only field callers should trust.

func Touch

func Touch(d *Doc, now time.Time) error

Touch stamps updated-at. Every mutation path calls this so recency-based conflict resolution has something to work with.

func TouchBy added in v0.1.1

func TouchBy(d *Doc, actor string) error

TouchBy records who made a change, beside when Touch records that it happened. An empty actor writes nothing: a caller that does not know who it is must not claim the change was nobody's.

func ValidID

func ValidID(id string) bool

ValidID reports whether id is a well-formed ULID.

Every reference between tickets — dependencies, follow-ups, the handle shown on a card — resolves through the id, so an id that does not parse makes the ticket unreachable rather than merely untidy.

func WithWorktree added in v0.3.1

func WithWorktree(source string) string

WithWorktree appends SourceWorktree to a source token. The vocabulary lives here, beside CommitsSource, for the reason CommitsSource itself does: two screens inventing their own spelling of the same fact is how one of them ends up quietly telling a different truth.

func WriteAtomic added in v0.1.1

func WriteAtomic(path string, data []byte) error

WriteAtomic writes via a temporary file in the same directory and renames it into place, so a crash or a full disk leaves the previous file intact rather than a truncated one (STORE-06).

Exported because the ticket files are not the only thing in .jaira that two sessions write at once: the tag registry (core/tag) needs the same guarantee, and a second copy of this dance is a second place for it to be subtly wrong.

Types

type BodyOption

type BodyOption struct {
	Name   string
	Ticked bool
}

BodyOption is one entry in a new ticket's Options checklist.

type DoDItem

type DoDItem struct {
	Text  string
	State State
	// Proof is the evidence recorded for this item, written by SetItemProof and
	// read back off the plain indented line directly beneath the item's own —
	// never its own checkbox, since a checkbox there would be misread as
	// another criterion to satisfy rather than support for this one.
	Proof string
}

DoDItem is one checkbox of a checklist, in either the Plan or the definition of done.

func ParseDoDItems

func ParseDoDItems(body string) []DoDItem

ParseDoDItems extracts the checkboxes under a Definition of Done heading.

Only that section is read: checkboxes elsewhere in a ticket (an open-questions list, say) are not acceptance criteria and must not be mistaken for them.

func ParseOptions

func ParseOptions(body string) []DoDItem

ParseOptions extracts the checkboxes under an Options heading.

func ParsePlanItems

func ParsePlanItems(body string) []DoDItem

ParsePlanItems extracts the checkboxes under a Plan heading.

The Plan is how the work is being done — write the spec, design it, implement it — and carries the in-progress marker. It deliberately does not gate the terminal lane: following a method is not the same as having met the criteria.

func (DoDItem) Checked

func (i DoDItem) Checked() bool

Checked reports whether this item is finished. Only StateDone counts: an item in progress is outstanding work, and a superseded one was never done at all.

func (DoDItem) Settled added in v0.1.1

func (i DoDItem) Settled() bool

Settled reports whether this item is off the outstanding list — done, or retired as superseded. This is the question completion asks; Checked is the question "was this achieved", and the two are deliberately different for a superseded item.

type Doc

type Doc struct {
	// contains filtered or unexported fields
}

Doc is a ticket file split into the three zones that get different treatment: the opening delimiter, the frontmatter body (the only part YAML ever sees), and the markdown body (never parsed, only carried).

func NewDoc

func NewDoc(fields map[string]string, lists map[string][]string, body string) *Doc

NewDoc builds a fresh ticket document with fields in canonical order. Only non-empty fields are written, so a new ticket file stays readable rather than being padded with empty keys.

func ParseDoc

func ParseDoc(src []byte) (*Doc, error)

ParseDoc splits a ticket file into its zones without interpreting the YAML.

func (*Doc) Body

func (d *Doc) Body() string

Body returns the markdown body with the closing delimiter removed.

func (*Doc) Bytes

func (d *Doc) Bytes() []byte

Bytes reassembles the file exactly.

func (*Doc) Frontmatter

func (d *Doc) Frontmatter() string

Frontmatter returns the raw frontmatter zone, for callers that want to unmarshal it normally. Reads do not need byte fidelity, so unmarshalling here is safe; only writes must go through the splice path.

func (*Doc) Has

func (d *Doc) Has(key string) bool

Has reports whether a top-level key is present.

func (*Doc) Keys

func (d *Doc) Keys() []string

Keys lists the top-level keys in source order.

func (*Doc) List

func (d *Doc) List(key string) ([]string, error)

List reads a top-level list field. Absent or empty yields nil.

func (*Doc) Scalar

func (d *Doc) Scalar(key string) (string, bool, error)

Scalar reads a top-level scalar field.

func (*Doc) SetBody

func (d *Doc) SetBody(body string)

SetBody replaces the markdown body, leaving frontmatter untouched.

func (*Doc) SetList

func (d *Doc) SetList(key string, items []string) error

SetList sets a top-level list field. The whole block for that key is replaced (it necessarily spans multiple lines), but no other key is disturbed. A trailing comment on the key line is carried over.

func (*Doc) SetRaw

func (d *Doc) SetRaw(key, literal string) error

SetRaw sets a field to a literal YAML scalar without quoting it. Used for values that are unambiguous by construction — booleans and RFC3339 timestamps — so tickets read idiomatically instead of carrying `ready: "false"`. Callers are responsible for passing something valid; anything user-supplied should go through SetScalar, which quotes defensively.

func (*Doc) SetScalar

func (d *Doc) SetScalar(key, val string) error

SetScalar sets a top-level scalar field. An existing value is spliced in place, preserving the line's indentation, quoting style and trailing comment. A missing key is appended at the end of the frontmatter.

func (*Doc) String

func (d *Doc) String() string

String reassembles the file exactly.

func (*Doc) Validate

func (d *Doc) Validate() error

Validate rejects frontmatter containing constructs the splice writer cannot safely round-trip. Anchors and aliases are the realistic hazard: a hand-edited ticket using them could be silently restructured by a naive rewrite, so the tool refuses to touch such a file rather than risking corruption (STORE-10).

type Logged added in v0.3.4

type Logged struct {
	Ticket *Ticket
	Day    time.Time
}

Logged is a ticket lying in the logbook, with the day its folder was filed.

type Outcome

type Outcome struct {
	What     string
	Why      string
	Resolves string
}

Outcome is the three-part close-out: what changed, why it was needed, and the causal link back to the Definition of Done. The third field exists because "what changed" alone does not let a reviewer judge whether the work is done.

func (Outcome) Filled

func (o Outcome) Filled() bool

Filled reports whether the outcome has enough substance to close a ticket.

type PartialError

type PartialError struct{ Problems []string }

PartialError reports tickets that could not be read while others succeeded.

func (*PartialError) Error

func (e *PartialError) Error() string

type Section

type Section int

Section names one of the two checklists a ticket can carry.

const (
	// SectionDoD is the definition of done: the criteria that decide whether
	// the work is acceptable. Only this one gates the terminal lane.
	SectionDoD Section = iota
	// SectionPlan is the method being followed to get there.
	SectionPlan
	// SectionOptions names the steps this ticket does and does not need.
	SectionOptions
)

func (Section) String

func (s Section) String() string

type State

type State int

State is how far one checklist item has got.

const (
	// StateTodo is an item not started, or one whose marker is not recognised.
	// An unknown marker is read as unfinished rather than skipped: dropping it
	// would shorten the list and make the checklist easier to complete than the
	// author wrote it.
	StateTodo State = iota
	// StateDoing is the item currently being worked on, written as "[~]". It is
	// progress, not a completion claim, so it does not satisfy the criterion.
	StateDoing
	// StateDone is a finished item, written as "[x]".
	StateDone
	// StateSuperseded is an item that did not happen and will not, written as
	// "[-]". It is not a quieter [x]: the work was never done, so nothing may
	// report it as achieved. It exists because the only way to retire a
	// criterion used to be ticking it with the proof "obsolete, replaced by item
	// 6" — which leaves ticked items that were never met, and a tick that can
	// mean "never happened" means nothing anywhere on the board.
	StateSuperseded
)

func (State) Marker

func (s State) Marker() string

Marker is the character this state is written with inside the brackets.

func (State) String

func (s State) String() string

type Store

type Store struct {
	// Root is the directory containing .jaira.
	Root string

	// Actor is who this process writes as, recorded on every mutation as
	// updated-by. It is a field rather than a package-level value because a
	// process can hold two stores at once — the board switcher does — and it is
	// set by the caller that already knows the identity, so core/ticket does
	// not have to depend on core/identity. Empty records nothing.
	Actor string

	// Recorder is told about every ticket write, so a ticket can also travel
	// on a git ref of its own and reach someone who does not have the writer's
	// branch. It is an interface set by the caller for the same reason Actor
	// is a field: core/ticket must not know about git, refs or queues, and the
	// package that does (core/refsync) needs to read tickets. Nil records
	// nothing, which is the case for every board without a remote.
	Recorder WriteRecorder

	// Source supplies tickets this board can see but has no file for — a
	// ticket that is still travelling on its own git ref and has not been
	// pulled into work here.
	//
	// It is the mirror of Recorder, and it exists for the same reason: every
	// reader in this program goes through List and Load, and a reader that had
	// to remember to ask a second place would be a reader that silently shows
	// half the board. core/ticket stays unaware of git; the package that knows
	// (core/refsync) fills this in.
	Source TicketSource
	// contains filtered or unexported fields
}

Store is the ticket directory for one repository.

func At

func At(dir string) (*Store, error)

At returns a store rooted at dir without requiring it to exist yet.

func Discover

func Discover(dir string) (*Store, error)

Discover walks up from dir looking for an existing .jaira directory.

func (*Store) Archive

func (s *Store) Archive(id string) (string, error)

Archive moves a ticket out of the board, returning its new path.

The file is moved, never removed. Restoring is moving it back, which is why this returns the destination rather than swallowing it.

func (*Store) ArchiveDir

func (s *Store) ArchiveDir() string

ArchiveDir is where archived tickets live.

func (*Store) Create

func (s *Store) Create(fields map[string]string, lists map[string][]string, body string) (*Ticket, error)

Create writes a new ticket file and returns it.

func (*Store) Delete added in v0.1.1

func (s *Store) Delete(id string) (string, error)

Delete removes a ticket's file and returns the path it was at.

The only irreversible operation in the store, and it exists because archiving is the wrong answer for a ticket that should never have been written: a mistyped create or a throwaway probe leaves a file that otherwise only 'rm' removes, and 'rm' means the caller has to know the file layout. Whether the caller is sure is decided above this line — the CLI asks for the handle typed back, and the board asks for it again.

Symlinks are resolved for the same reason Archive resolves them: deleting the link alone would leave the ticket on disk but off the board. Both go, since a half-deleted ticket is worse than either outcome.

func (*Store) DuplicateIDs

func (s *Store) DuplicateIDs() []string

DuplicateIDs reports ids claimed by more than one file, discovered during the most recent lookup.

func (*Store) FileLane added in v0.1.1

func (s *Store) FileLane(lane, folder string, prepare func(*Ticket) error) ([]Trimmed, error)

FileLane empties a lane into the given logbook folder, oldest first. Two callers reach it: a doorway lane (logbook-on-entry), where the just-landed ticket goes along with anything still sitting there from before, and 'jaira logbook --all', the cut somebody makes by hand. Both take what filesInLane hands them, so ref-only tickets stay where they are. prepare runs on each ticket before its file moves (the callers stamp commits there); nil skips it. A ticket that cannot be filed — unreadable, a stamp failure, a name collision in the folder — is skipped and named rather than allowed to jam the doorway: a sweep aborts on nothing, because the same failure would repeat on every later landing and the lane would block for good. What moved and what was skipped are both always reported.

func (*Store) FiledAwayIDs added in v0.1.3

func (s *Store) FiledAwayIDs() map[string]string

FiledAwayIDs maps the ids of tickets filed away here — logged or archived — to the file that holds them.

Exported because two questions need it and neither belongs in this package: whether a ticket is on the board and filed away at once, and whether a finished ticket has arrived anywhere everybody can see it.

func (*Store) FiledMilestone added in v0.3.0

func (s *Store) FiledMilestone(name string) (string, bool)

FiledMilestone reports whether a milestone of this name lies filed in the logbook, and where, as a path relative to the repository root.

It looks in every folder Restore looks in — including the one the logbook was called before it was one — because a name Restore can still bring back is a name that is still taken. A hand-rolled walk over LogbookDir alone misses that older folder and hands the name out twice.

func (*Store) Init

func (s *Store) Init() (created bool, err error)

Init creates the store layout. Safe to run repeatedly.

func (*Store) List

func (s *Store) List() ([]*Ticket, error)

List loads every ticket, reading only as much of each file as the frontmatter requires.

func (*Store) Load

func (s *Store) Load(idOrPrefix string) (*Ticket, error)

Load reads one ticket in full, resolving an exact ID or unambiguous prefix.

func (*Store) LoadAnywhere added in v0.1.4

func (s *Store) LoadAnywhere(idOrPrefix string) (*Ticket, string, error)

LoadAnywhere reads one ticket from wherever it still exists: the board first, then the logbook and the archive.

Load deliberately sees only the board, because every write path goes through it and a filed ticket must not be writable by accident. Reading is the opposite case: a ticket the board points at — a blocker, a parent, a follow-up — keeps being asked about long after it was filed, and answering "not found" for a file sitting in plain sight under .jaira/logbook/ is what made every link rot the moment the work behind it finished.

The second result is the file it was read from, so a caller can say where it lives rather than implying it is still on the board.

func (*Store) Lock added in v0.1.1

func (s *Store) Lock(name string) (func(), error)

Lock takes the store's advisory lock under a caller-chosen name, for state in .jaira that is not one ticket.

The name shares one namespace with the per-ticket locks, which are keyed by id, so any name that is not a 26-character ULID is safe — "tags" is not one. A name that IS a ULID would silently take that ticket's lock, and nothing here checks for it: the callers are in this repository, not a plugin surface.

The caller must call the returned function, normally with defer. Failure to acquire within the timeout is an error, not a silent write.

func (*Store) Logbook added in v0.1.1

func (s *Store) Logbook(id, folder string) (string, error)

Logbook moves a ticket out of the board and into a dated logbook folder, returning its new path. Like Archive, the file is moved rather than deleted, and a name collision inside the destination folder is refused by name rather than silently overwritten — this only happens if the same ticket is logged into the same folder twice, which a caller should treat as a bug to look into, not a state to paper over.

func (*Store) LogbookDir added in v0.1.1

func (s *Store) LogbookDir() string

LogbookDir is where tickets taken off the board with their commits stamped live, grouped into dated per-person folders.

func (*Store) LogbookMilestone added in v0.3.0

func (s *Store) LogbookMilestone(name, folder string) (string, error)

LogbookMilestone moves a milestone's file off the board and into a dated logbook folder, returning its new path. The counterpart of Logbook, minus the refs: a milestone's ref deliberately stays up carrying its status, so nothing is recorded here.

It lands in a milestones/ subfolder rather than beside the tickets because a milestone's name is chosen freely and could be spelled like a ticket file. Restore has to know from where it found a file where the file belongs, and a folder says that without guessing at names.

func (*Store) LoggedPerDay added in v0.1.1

func (s *Store) LoggedPerDay(now time.Time, days int) []int

LoggedPerDay counts the logbook's tickets by the day of their folder, for the days days ending today: out[days-1] is today, out[0] the oldest. The folder name carries the date — <initials>-<yyyymmdd> — so no ticket is read, and a folder whose name does not end in a date is skipped. A folder under the logbook's old name counts too, the same way Restore finds it.

func (*Store) LoggedSince added in v0.3.4

func (s *Store) LoggedSince(now time.Time, days int) []Logged

LoggedSince reads the tickets filed into the logbook within the last days days, newest first: by the folder's day, then by the ticket's own updated_at, then by id so the order never flickers between reloads. Zero days reads nothing.

Only folders whose name ends in a date are read. The listing in 'jaira logbook' keeps an undated folder because it is the one place that names every file; the board shows what recently left it, and a folder with no day has no "recently". Filed milestones, one level deeper, are not tickets and are not read. A file that does not parse is skipped rather than reported — the board's warnings are about the board, and this is history.

func (*Store) MilestonesDir added in v0.3.0

func (s *Store) MilestonesDir() string

MilestonesDir is where the board's milestone files live.

func (*Store) Mutate

func (s *Store) Mutate(idOrPrefix string, fn func(*Ticket) error) (*Ticket, error)

Mutate applies fn to a ticket under an exclusive lock, then writes it back atomically. Read-modify-write is done inside the lock so two concurrent invocations cannot each read the same original and overwrite one another — having a single write path prevents schema drift but does nothing about interleaving, which is a distinct failure mode (STORE-07).

func (*Store) Overflow added in v0.1.1

func (s *Store) Overflow(lane string, keep int, newest string) ([]*Ticket, error)

Overflow returns the tickets of a lane beyond the newest keep, oldest first. "Newest" is measured by updated-at: a ticket in a terminal lane is normally never touched again, so updated-at is its acceptance time. newest names the ticket whose arrival is being accounted for — it is pinned to the top regardless of its stamp, so the ticket that triggered a trim is never the one trimmed. Ties in updated-at are routine (the stamp has second resolution) and break toward the newer ULID: among tickets touched in the same second, the most recently created counts as newer. keep <= 0 means the lane has no cap, so nothing ever overflows. A board with unreadable tickets refuses to answer rather than guess at which ticket is oldest.

func (*Store) Paths

func (s *Store) Paths() ([]string, error)

Paths lists ticket files in deterministic order. ULID filenames sort lexicographically by creation time, so this is chronological for free.

func (*Store) RepoStateDir added in v0.1.4

func (s *Store) RepoStateDir() string

RepoStateDir is the state directory every working tree of one clone shares, for state that belongs to the repository rather than to one checkout.

The background jobs' stamps live here and the per-tree state does not. When a snapshot last ran is a fact about the board, not about the checkout that happened to trigger it: keyed per tree, every new git worktree started its own clock at zero and took a full snapshot on its first command, which is how a three-day backup turned into a commit every twenty minutes.

Falls back to the per-tree directory when git cannot answer — a board outside a repository has one working tree anyway, so there the two are the same directory.

func (*Store) Restore

func (s *Store) Restore(name string) (string, error)

Restore moves an archived or logged ticket back onto the board, resolving name from the bare filename in either case — never from a caller-supplied path, so a name built to escape the store (e.g. "../../etc/passwd") cannot walk it anywhere but back into TicketsDir.

func (*Store) Save

func (s *Store) Save(t *Ticket) error

Save writes a ticket that the caller already mutated, without taking a lock. Used by the merge driver, which runs single-threaded under git.

func (*Store) SessionsDir

func (s *Store) SessionsDir() string

SessionsDir is where this working tree's session state lives, outside the repo.

func (*Store) SharedDir

func (s *Store) SharedDir() string

SharedDir is where lanes published to teammates live. Nothing creates this directory at init: an empty shared/ folder committed to every board that never publishes a lane would be its own kind of confusion.

func (*Store) StampCommits added in v0.1.1

func (s *Store) StampCommits(t *Ticket, derive func(*Ticket) []string) ([]string, error)

StampCommits writes the MergeCommits union onto the ticket and returns what was written. derive may be nil, the same "no derivation on offer" convention core/gate uses.

func (*Store) StateDir

func (s *Store) StateDir() string

StateDir is the per-working-tree directory this store's session and lock state lives under, exported so callers outside the package (the version stamp, in particular) can address it too.

func (*Store) TicketsDir

func (s *Store) TicketsDir() string

func (*Store) TrimLane added in v0.1.1

func (s *Store) TrimLane(lane string, keep int, folder, newest string) ([]Trimmed, error)

TrimLane enforces a lane's cap after something new landed in it: the tickets beyond the newest keep leave for the given logbook folder, oldest first. newest is passed through to Overflow so the just-landed ticket is never trimmed. It returns what moved — including on error, so a caller can still report the tickets that had already left before the failure.

type Ticket

type Ticket struct {
	ID         string
	Title      string
	Status     string
	Ready      bool
	Creator    string
	Assignee   string
	ExecutedBy string
	Goal       string
	Context    string
	DoD        string
	BlockedBy  []string
	Commits    []string
	ModelTier  string
	Question   string
	Mode       string
	ClaimedBy  string
	ClaimedAt  time.Time
	CreatedAt  time.Time
	UpdatedAt  time.Time
	// UpdatedBy is who made that most recent change. Empty on a ticket last
	// written by a version that did not record it, which reads as "unknown"
	// rather than as anybody.
	UpdatedBy string

	// Tags are the ticket's topic labels, normalized to lowercase kebab by the
	// write path. Read here as written: a hand-edited file may carry anything,
	// and a reader that silently rewrote it would hide the fact rather than fix
	// it.
	Tags []string

	// Follows is the ticket whose review produced this one.
	Follows string
	// Parent is the ticket this one is a part of. Children are never stored;
	// they are found by asking which tickets name this one here.
	Parent string
	// Related are tickets this one has to do with, in no particular order and
	// with no obligation in either direction.
	Related []string
	// BlockedReason is why the ticket is parked, when the blocker is not another
	// ticket.
	BlockedReason string

	// ReadOnly means this board can see the ticket but has no file for it: it
	// is still travelling on its own git ref and nobody here has pulled it into
	// work. It is shown like any other ticket and refused by every write, so a
	// reader never has half a board and a writer never half-applies a change.
	ReadOnly bool
	// ReviewVerdict is the reviewer's conclusion, distinct from the
	// implementer's own account in Outcome.
	ReviewVerdict string
	// ReviewSummary is what the change does, in the reviewer's own words.
	ReviewSummary string
	// ReviewGaps is what the reviewer found missing; "none" is a valid value.
	ReviewGaps string
	// ReviewCheck is the steps a person follows to check the change themselves.
	ReviewCheck string

	Outcome Outcome

	// DoDItems are the checkboxes under a "Definition of Done" heading in the
	// body. A checklist is how people actually write acceptance criteria, and
	// keeping it in the body means it stays readable and editable prose rather
	// than being flattened into a single frontmatter string.
	DoDItems []DoDItem

	// PlanItems are the checkboxes under a "Plan" heading: the method being
	// followed rather than the criteria for acceptance. They carry the
	// in-progress marker, which is what makes "designed but not yet built"
	// visible instead of collapsing into a single in-progress lane.
	PlanItems []DoDItem

	// Options are the checkboxes under an "Options" heading, each naming a step
	// this ticket does or does not need. A lane can require one, which is how a
	// ticket skips planning or review without those lanes being removed.
	Options []DoDItem

	// Body is the markdown following the frontmatter.
	Body string
	// Path is where this ticket was loaded from.
	Path string
	// contains filtered or unexported fields
}

Ticket is the decoded view of a ticket, used for reads, filtering and rendering. Writes never go through this struct — they go through Doc, so that unknown fields survive. Anything read into here is a projection, not the source of truth.

func Decode

func Decode(d *Doc, path string) (*Ticket, error)

Decode projects a parsed document into a Ticket.

func LoadHeader added in v0.1.4

func LoadHeader(path string) (*Ticket, error)

LoadHeader reads one ticket file's frontmatter and nothing more.

The board already reads itself this way — a ticket's body has no upper bound, and a reader that wanted only the fields would otherwise scale with total prose. Anything that has to sweep a whole directory of tickets it does not intend to display belongs here rather than on os.ReadFile: the logbook grows without end, and it is swept to answer questions as small as "who names this ticket as their parent".

The body is not populated. Everything in the frontmatter is.

func (*Ticket) DoDComplete

func (t *Ticket) DoDComplete() (complete bool, remaining []string)

DoDComplete reports whether every checklist item is finished.

An item in progress counts as remaining. It is a statement that work is under way, which is the opposite of the criterion being met, so treating it as anything else would let a ticket close with its own checklist saying otherwise.

func (*Ticket) Doc

func (t *Ticket) Doc() *Doc

Doc exposes the underlying document for mutation.

func (*Ticket) HasDoD

func (t *Ticket) HasDoD() bool

HasDoD reports whether a ticket declares a definition of done at all, in either supported form.

func (*Ticket) OptionSet

func (t *Ticket) OptionSet(name string) bool

OptionSet reports whether a named option is ticked on this ticket.

Matching is on the text, case-insensitively and ignoring surrounding space, so "- [x] planning" and "- [x] Planning " both select the planning step. An option that is absent is not selected: a step is opt-in, so a ticket that says nothing about planning does not get planned.

func (*Ticket) PlanComplete

func (t *Ticket) PlanComplete() (complete bool, remaining []string)

PlanComplete reports whether every step of the method has been carried out.

A ticket with no plan is vacuously complete: the plan is optional, and its absence is not evidence of unfinished work.

type TicketSource added in v0.1.3

type TicketSource interface {
	// Extra returns those tickets, skipping any id in have. Implementations
	// mark what they return as ReadOnly: there is no file to write to, so a
	// mutation has to be refused rather than half-applied.
	Extra(have map[string]bool) ([]*Ticket, error)
}

TicketSource hands over tickets that exist for this board without a file here.

type Trimmed added in v0.1.1

type Trimmed struct {
	ID    string
	Title string
	// Path is where the file now lives, under the logbook.
	Path string
}

Trimmed records one ticket a lane cap moved off the board, so the caller can say what happened — the cap is allowed to move files only because it never does so silently.

type WriteRecorder added in v0.1.3

type WriteRecorder interface {
	// Record is handed the ticket's id and its complete bytes. It is called
	// after the local write has succeeded, so it must never be understood as
	// a veto: by the time it runs, the ticket on disk has already changed.
	Record(id string, content []byte) error

	// RecordFiled says the ticket has been filed away here — logged or
	// archived — and hands over its final bytes.
	//
	// Filing is deliberately not a deletion. The ticket file is at that moment
	// only in the filer's own branch, and until that branch is merged nobody
	// else can see the ticket at all: taking it off the shared channel too
	// would open a window, as long as a review takes, in which somebody
	// notices the same problem and writes it down a second time. So the final
	// state goes onto the channel and stays there until the ticket has
	// arrived somewhere everybody can see.
	RecordFiled(id string, content []byte) error

	// RecordDelete says the ticket is gone for good. Only 'jaira delete' does
	// this: deleting is an intention, not a stage of finishing.
	RecordDelete(id string) error
}

WriteRecorder is told what a ticket now says, after the file has been written.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL