ticket

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 15 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"
	FieldQuestion   = "question"
	FieldClaimedBy  = "claimed-by"
	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"
)

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"
	// 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"
)

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 SuppliedFields = []string{
	FieldTitle, FieldGoal, FieldContext, FieldDoD, FieldAssignee, "diff",
}

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 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 NewBody

func NewBody(title, 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 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 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.

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.

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 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
)

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
	// 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) DuplicateIDs

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

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

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) 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) 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) Restore

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

Restore moves an archived ticket back onto the board.

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) 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

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
	ClaimedBy  string
	ClaimedAt  time.Time
	CreatedAt  time.Time
	UpdatedAt  time.Time

	// Follows is the ticket whose review produced this one.
	Follows string
	// BlockedReason is why the ticket is parked, when the blocker is not another
	// ticket.
	BlockedReason string
	// 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

	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 (*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.

Jump to

Keyboard shortcuts

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