package api
import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
"craftdesign.group/quarry/internal/attach"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// The local-ticket tier (docs/local-tickets.md): tickets that live in
// the forge's own database and enter the artifact log only when an
// admin promotes them. Same fields, same views, one extra property the
// fossil can never offer — a discard that leaves nothing behind.
// RefusalAlreadyPromoted is ticket.promote's code for a local ticket
// another promotion has already written into the artifact log. Its
// field is to, the promoted ticket's id.
const RefusalAlreadyPromoted = "already_promoted"
// RefusalBeingPromoted is ticket.promote's code for a local ticket
// another promotion holds the claim on and has not finished. It has no
// fields: the ticket it will become does not exist yet.
const RefusalBeingPromoted = "being_promoted"
func alreadyPromoted(to string) error {
return &ops.Refusal{Code: RefusalAlreadyPromoted,
Message: fmt.Sprintf("already promoted to %.12s", to),
Fields: map[string]string{"to": to}}
}
// LocalTicketStore is what the tier needs from oltp.
type LocalTicketStore interface {
LocalTicketNew(repo, user, addr string, fields map[string]string) (string, error)
LocalTicketSet(repo, id, user string, fields map[string]string) error
LocalTicketByPrefix(repo, prefix string) (oltp.LocalTicket, bool, error)
LocalTickets(repo string) ([]oltp.LocalTicket, error)
LocalTicketDiscard(repo, id string) error
LocalTicketPromoted(repo, id, fossilID, token string) error
// A promotion claims the ticket before it writes the artifact, so
// two promotions at once write one ticket, not two. The token names
// the holder: only it may release or tombstone.
LocalTicketClaim(repo, id string) (token string, ok bool, err error)
LocalTicketRelease(repo, id, token string) error
LocalTicketRedirect(repo, prefix string) (string, bool)
// The files a local ticket holds until it is promoted (9e628be2):
// the stub each A card will carry, written to the log by
// ticket.promote.
LocalAttachmentAdd(a oltp.LocalAttachment) error
LocalAttachments(repo, ticket string) ([]oltp.LocalAttachment, error)
LocalAttachmentBySHA(repo, sha string) (oltp.LocalAttachment, bool, error)
LocalAttachmentRemove(repo, ticket, name string) error
LocalSHACount(repo, sha string) (int, error)
}
// ticketIntakeIn is ticket.new's shape pointed at the local tier. A
// separate op rather than a flag on ticket.new: the two doors are gated
// differently, and a caller must always know which log it writes.
type ticketIntakeIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Title string `json:"title" validate:"required,max=200"`
Status string `json:"status" validate:"max=40"`
Comment string `json:"comment" validate:"max=65536"`
Type string `json:"type" validate:"max=40"`
Severity string `json:"severity" validate:"max=40"`
Priority string `json:"priority" validate:"max=40"`
Subsystem string `json:"subsystem" validate:"max=80"`
FoundIn string `json:"foundin" validate:"max=80"`
// Fields carries the tracker's other columns, gated against the
// artifact tier's vocabulary — see localTicketSet for why a local
// ticket may not invent one.
Fields map[string]string `json:"fields" validate:"max=24"`
// IdempotencyKey: as on ticket.new, a retry returns the first ticket.
IdempotencyKey string `json:"idempotencyKey" validate:"max=128"`
}
type ticketIntakeOut struct {
ID string `json:"id"`
URL string `json:"url"`
// Number is the ticket's number in this repository (54c5175f).
Number int `json:"number,omitempty"`
}
type ticketPromoteIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,min=4,max=40"`
}
type ticketPromoteOut struct {
// ID is the FOSSIL ticket the local one became.
ID string `json:"id"`
Hash string `json:"hash"`
// Attached counts the files the ticket held on the forge tier that
// are now A cards on the artifact; AttachErrors names, one line
// each, any that could not be written. The ticket is promoted
// either way, and a file that failed stays held under the local id
// so nothing is lost.
Attached int `json:"attached,omitempty"`
AttachErrors []string `json:"attachErrors,omitempty"`
}
type ticketDiscardIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,min=4,max=40"`
}
type ticketDiscardOut struct {
OK bool `json:"ok"`
}
func (d Deps) registerLocalTickets(r *ops.Registry) error {
if err := ops.Register(r, ops.Op[ticketIntakeIn, ticketIntakeOut]{
Name: "ticket.intake",
Caps: "n",
Doc: "File a local ticket (docs/local-tickets.md): stored on this forge, promotable into the artifact log by an admin, deletable until then.",
Kind: ops.Command,
Auth: ops.Anonymous,
Route: "POST /api/v1/r/{repo}/ticket/intake",
Run: func(ctx context.Context, in ticketIntakeIn) (ticketIntakeOut, error) {
return idempotent(ctx, d, "ticket.intake", in.Repo, in.IdempotencyKey, in, func() (ticketIntakeOut, error) {
if d.Local == nil {
return ticketIntakeOut{}, fmt.Errorf("this forge keeps no local tickets: %w", ops.ErrUnavailable)
}
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return ticketIntakeOut{}, err
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return ticketIntakeOut{}, err
}
extra, err := extraTicketFields(authorableFields(repo), in.Fields)
if err != nil {
return ticketIntakeOut{}, err
}
status := in.Status
if status == "" {
status = "Open"
}
fields := map[string]string{"title": in.Title, "status": status}
for k, v := range map[string]string{
"type": in.Type, "severity": in.Severity,
"priority": in.Priority, "subsystem": in.Subsystem,
"foundin": in.FoundIn,
} {
if v != "" {
fields[k] = v
}
}
for k, v := range extra {
fields[k] = v
}
if in.Comment != "" {
fields["comment"] = in.Comment
fields["cmimetype"] = authoredMimetype
}
id, err := d.Local.LocalTicketNew(in.Repo, author(ctx), "", fields)
if err != nil {
return ticketIntakeOut{}, err
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-activity", To: "repo:" + in.Repo,
Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
"summary": "filed local ticket " + in.Title,
"link": d.link("/r/" + in.Repo + "/ticket/" + id)},
TxnID: "activity:ticket-intake:" + id,
})
return ticketIntakeOut{ID: id, URL: d.link("/r/" + in.Repo + "/ticket/" + id), Number: d.ticketNumbers(in.Repo, repo, id)[id]}, nil
})
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[ticketPromoteIn, ticketPromoteOut]{
Name: "ticket.promote",
Caps: "a",
Doc: "Promote a local ticket into the artifact log: one ticket artifact with the folded state and provenance, one more collating any discussion, and an A card for each file the ticket held on the forge tier. The local id becomes a redirect.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ticket/{id}/promote",
Run: func(ctx context.Context, in ticketPromoteIn) (ticketPromoteOut, error) {
if d.Local == nil {
return ticketPromoteOut{}, fmt.Errorf("this forge keeps no local tickets: %w", ops.ErrUnavailable)
}
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return ticketPromoteOut{}, err
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return ticketPromoteOut{}, err
}
lt, ok, err := d.Local.LocalTicketByPrefix(in.Repo, in.ID)
if err != nil {
return ticketPromoteOut{}, err
}
if !ok {
// A promoted id folds to nothing (the tombstone keeps no
// changes), so the redirect is the probe that can still
// tell "promoted" from "never existed". It is asked AFTER
// the read, never before: asked first, it could miss a
// tombstone that lands between the two, and the loser of
// two promotions would be told not-found instead of
// where the ticket went.
if to, ok := d.Local.LocalTicketRedirect(in.Repo, in.ID); ok {
return ticketPromoteOut{}, alreadyPromoted(to)
}
return ticketPromoteOut{}, fmt.Errorf("no local ticket %q: %w", in.ID, ops.ErrNotFound)
}
if repo.Zit == nil {
return ticketPromoteOut{}, fmt.Errorf("this repository cannot write tickets: %w", ops.ErrUnavailable)
}
// The redirect probe above is a read, and two promotions can
// both pass it; the claim is the write that picks one.
claim, claimed, err := d.Local.LocalTicketClaim(in.Repo, lt.ID)
if err != nil {
return ticketPromoteOut{}, err
}
if !claimed {
if to, ok := d.Local.LocalTicketRedirect(in.Repo, lt.ID); ok {
return ticketPromoteOut{}, alreadyPromoted(to)
}
return ticketPromoteOut{}, &ops.Refusal{Code: RefusalBeingPromoted,
Message: fmt.Sprintf("local ticket %.12s is being promoted already", lt.ID)}
}
// Distillation, not replay (docs/local-tickets.md): the
// folded state and description enter the log; the local
// change-by-change history is working noise and stays out.
fields := map[string]string{}
for k, v := range lt.Fields {
if k == "comment" || k == "cmimetype" || v == "" {
continue
}
fields[k] = v
}
prov := fmt.Sprintf("reported by %s on %s; promoted from local ticket %.12s by %s",
lt.Author, lt.Created, lt.ID, author(ctx))
if desc := lt.Fields["comment"]; desc != "" {
fields["comment"] = desc + "\n\n---\n" + prov
} else {
fields["comment"] = prov
}
if cm := lt.Fields["cmimetype"]; cm != "" {
fields["cmimetype"] = cm
} else {
fields["cmimetype"] = authoredMimetype
}
// The number it was published under goes into the artifact,
// and the forge's record moves to the new id: the name people
// used keeps naming the ticket, and a clone learns it.
number := d.ticketNumbers(in.Repo, repo)[lt.ID]
if number > 0 {
fields["number"] = strconv.Itoa(number)
}
var id, hash string
if err := repo.write(func(tok lease.Token) (err error) {
id, hash, err = repo.Zit.TicketNew(tok, author(ctx), fields)
return err
}); err != nil {
// Nothing was written, so the ticket may be tried again.
// Once an artifact exists the claim is not released, so a
// retry is refused while the lease lasts. It does not last
// for ever: if the tombstone below fails, a retry after
// the lease can write a second artifact, which is why that
// failure is logged with the artifact's id.
if rerr := d.Local.LocalTicketRelease(in.Repo, lt.ID, claim); rerr != nil {
log.Printf("tickets: %s: releasing the claim on %.12s: %v", in.Repo, lt.ID, rerr)
}
return ticketPromoteOut{}, err
}
if number > 0 {
if err := d.Numbers.TicketNumberRekey(in.Repo, lt.ID, id); err != nil {
log.Printf("tickets: %s: moving #%d to %.12s: %v", in.Repo, number, id, err)
}
}
// Tombstone BEFORE the discussion artifact: if the second
// write fails, the fossil ticket merely lacks its collated
// comments — where the reverse order could promote twice.
if err := d.Local.LocalTicketPromoted(in.Repo, lt.ID, id, claim); err != nil {
log.Printf("tickets: %s: local ticket %.12s was written as %.12s but not tombstoned: %v", in.Repo, lt.ID, id, err)
return ticketPromoteOut{}, err
}
attached, attachErrs := d.promoteAttachments(ctx, repo, in.Repo, lt.ID, id)
if len(lt.Comments) > 0 {
var b strings.Builder
for i, c := range lt.Comments {
if i > 0 {
b.WriteString("\n\n---\n\n")
}
fmt.Fprintf(&b, "%s on %s:\n\n%s", c.Login, c.Date, c.Body)
}
if err := repo.write(func(tok lease.Token) (err error) {
_, err = repo.Zit.TicketSet(tok, id, author(ctx),
map[string]string{"icomment": b.String(), "mimetype": authoredMimetype})
return err
}); err != nil {
return ticketPromoteOut{}, err
}
}
if err := repo.synced(); err != nil {
return ticketPromoteOut{}, err
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-activity", To: "repo:" + in.Repo,
Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
"summary": "promoted ticket " + lt.Fields["title"],
"link": d.link("/r/" + in.Repo + "/ticket/" + id)},
TxnID: "activity:ticket-promote:" + id,
})
return ticketPromoteOut{ID: id, Hash: hash, Attached: attached, AttachErrors: attachErrs}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[ticketDiscardIn, ticketDiscardOut]{
Name: "ticket.discard",
Caps: "a",
Doc: "Delete a local ticket outright — the ability the artifact log can never offer, and the whole answer to tracker spam.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ticket/{id}/discard",
Run: func(ctx context.Context, in ticketDiscardIn) (ticketDiscardOut, error) {
if d.Local == nil {
return ticketDiscardOut{}, fmt.Errorf("this forge keeps no local tickets: %w", ops.ErrUnavailable)
}
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return ticketDiscardOut{}, err
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ticketDiscardOut{}, err
}
if _, ok := d.Local.LocalTicketRedirect(in.Repo, in.ID); ok {
return ticketDiscardOut{}, fmt.Errorf("promoted; the redirect stays: %w", ops.ErrInvalid)
}
lt, ok, err := d.Local.LocalTicketByPrefix(in.Repo, in.ID)
if err != nil {
return ticketDiscardOut{}, err
}
if !ok {
return ticketDiscardOut{}, fmt.Errorf("no local ticket %q: %w", in.ID, ops.ErrNotFound)
}
if err := d.Local.LocalTicketDiscard(in.Repo, lt.ID); err != nil {
return ticketDiscardOut{}, err
}
return ticketDiscardOut{OK: true}, nil
},
})
}
// localSummary shapes one local ticket as a listing row.
func localSummary(lt oltp.LocalTicket) ticketSummary {
f := lt.Fields
row := ticketSummary{ID: lt.ID, Title: f["title"], Status: f["status"],
Updated: lt.Updated, Created: lt.Created, Type: f["type"],
Severity: f["severity"], Priority: f["priority"], Subsystem: f["subsystem"],
Resolution: f["resolution"], Author: lt.Author,
Comments: len(lt.Comments), Local: true}
if n := len(lt.Changes); n > 0 {
row.LastUser = lt.Changes[n-1].User
}
return row
}
// localClosed mirrors the index's done-words fold for rows the index
// never sees.
func localClosed(status string) bool {
s := strings.ToLower(status)
for _, w := range index.ClosedStatuses {
if s == w {
return true
}
}
return false
}
// localOpenTickets counts the repository's OPEN local tickets, for the
// Tickets tab badge.
//
// The badge counts open tickets (index.go's Counts), and the tracker
// page lists both tiers — so a badge drawn from the artifact index
// alone undercounts every repository with an intake queue. quarry's own
// read 42 beside a page saying 62 open. A number that disagrees with
// the page it labels is worse than no number, which is the same reason
// the forum badge folds in its companion's threads.
//
// Open is localClosed's fold, not a second opinion about the done
// words, and LocalTickets already drops promoted rows so a ticket that
// has become an artifact is counted once, by the index, not twice.
//
// Best-effort and never an error, like companionForumThreads: a count
// is decoration on a tab, and failing the counts op every repository
// page calls would turn a sick oltp store into a broken nav.
func (d Deps) localOpenTickets(repo string) int {
if d.Local == nil {
return 0
}
ls, err := d.Local.LocalTickets(repo)
if err != nil {
return 0
}
n := 0
for _, lt := range ls {
if !localClosed(lt.Fields["status"]) {
n++
}
}
return n
}
// localMatches applies ticket.list's report axes to a local row — the
// narrowing the index does in SQL, done in Go, because the intake queue
// is dozens of rows where the archive is thousands.
func localMatches(t ticketSummary, in ticketListIn) bool {
closed := localClosed(t.Status)
if in.State == "closed" && !closed {
return false
}
if (in.State == "" || in.State == "open") && closed {
return false
}
for _, p := range [][2]string{{in.Type, t.Type}, {in.Severity, t.Severity},
{in.Priority, t.Priority}, {in.Subsystem, t.Subsystem},
{in.Status, t.Status}, {in.Author, t.Author}} {
if p[0] != "" && !strings.EqualFold(p[0], p[1]) {
return false
}
}
if in.Title != "" && !strings.Contains(strings.ToLower(t.Title), strings.ToLower(in.Title)) {
return false
}
return true
}
// localFieldSet orders a change's fields for display — maps iterate
// randomly and a field table must not.
func localFieldSet(fields map[string]string, skipDescription bool) []index.TicketFieldSet {
names := make([]string, 0, len(fields))
for k := range fields {
if localPerChangeField(k) || (skipDescription && (k == "comment" || k == "cmimetype")) {
continue
}
names = append(names, k)
}
sort.Strings(names)
out := make([]index.TicketFieldSet, 0, len(names))
for _, k := range names {
out = append(out, index.TicketFieldSet{Name: k, Value: fields[k]})
}
return out
}
func localPerChangeField(name string) bool {
switch name {
case "icomment", "mimetype", "login", "username":
return true
}
return false
}
// localTicketGet answers ticket.get from the local tier when the
// artifact log has no such id. A promoted id answers with PromotedTo
// set and nothing else: the caller followed an old link, and the page
// it wants is the fossil ticket's.
func (d Deps) localTicketGet(repo *Repo, name, prefix string) (ticketGetOut, bool, error) {
if d.Local == nil {
return ticketGetOut{}, false, nil
}
if to, ok := d.Local.LocalTicketRedirect(name, prefix); ok {
return ticketGetOut{PromotedTo: to}, true, nil
}
lt, ok, err := d.Local.LocalTicketByPrefix(name, prefix)
if err != nil || !ok {
return ticketGetOut{}, false, err
}
st := index.TicketState{ID: lt.ID, Created: lt.Created, Updated: lt.Updated,
Description: lt.Fields["comment"], DescriptionMimetype: lt.Fields["cmimetype"],
Fields: localFieldSet(lt.Fields, true)}
for _, c := range lt.Comments {
st.Comments = append(st.Comments, index.TicketComment{
Date: c.Date, Login: c.Login, Mimetype: c.Mimetype, Body: c.Body})
}
for _, ch := range lt.Changes {
st.Changes = append(st.Changes, index.TicketChange{
Date: ch.Date, User: ch.User, Fields: localFieldSet(ch.Fields, false)})
}
out := ticketGetOut{Ticket: st, Local: true}
if out.Attachments, err = d.localAttachmentRows(name, lt.ID); err != nil {
return ticketGetOut{}, false, err
}
if st.Description != "" {
html, err := renderBody(repo.Index, name, st.DescriptionMimetype, []byte(st.Description))
if err != nil {
return ticketGetOut{}, false, err
}
out.DescriptionHTML = html
}
for _, c := range st.Comments {
html, err := renderBody(repo.Index, name, c.Mimetype, []byte(c.Body))
if err != nil {
return ticketGetOut{}, false, err
}
out.Comments = append(out.Comments, ticketComment{
Date: c.Date, Login: c.Login, Username: c.Username, HTML: html,
})
}
return out, true, nil
}
// localTicketSet records a ticket.set change against the local tier,
// with the same field semantics as the artifact path.
func (d Deps) localTicketSet(ctx context.Context, in ticketSetIn, lt oltp.LocalTicket) (ticketSetOut, error) {
fields := ticketSetFields(in)
// The local tier has no schema of its own — a promoted ticket lands
// in the same tracker — so the same vocabulary gates it. Asking the
// artifact tier's index for the names is the whole point: a local
// ticket must not be able to invent a column the tracker it will be
// promoted into does not have.
//
// The same reasoning covers the vocabulary: a local ticket is
// promoted into this tracker and must not arrive carrying a
// status it never allowed.
if repo, err := d.repoFor(ctx, in.Repo); err == nil {
extra, ferr := extraTicketFields(authorableFields(repo), in.Fields)
if ferr != nil {
return ticketSetOut{}, ferr
}
for k, v := range extra {
fields[k] = v
}
if vocab, _, _ := d.mergedTicketVocab(ctx, repo); vocab != nil {
if verr := refuseUnknownVocab(vocab, fields); verr != nil {
return ticketSetOut{}, verr
}
}
}
if len(fields) == 0 {
return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid)
}
if err := d.Local.LocalTicketSet(in.Repo, lt.ID, author(ctx), fields); err != nil {
return ticketSetOut{}, err
}
// No artifact was written, so there is no hash to return — the
// honest empty string, not an invented one. Files pasted while
// composing are held against the ticket on the forge tier.
out := ticketSetOut{Local: true}
if len(in.Attach) > 0 {
if err := checkAttachList(in.Attach); err != nil {
out.AttachErrors = []string{err.Error()}
} else if repo, err := d.repoFor(ctx, in.Repo); err != nil {
out.AttachErrors = []string{err.Error()}
} else {
out.AttachErrors = d.attachTickets(ctx, repo, in.Repo, lt.ID, in.Attach)
}
}
return out, nil
}
// promoteAttachments writes the A cards for what a local ticket held,
// now that it has an artifact to hang them on. Each is written as the
// uploader's, with the stub the commit recorded, and the held rule is
// applied to the uploader here — this is the moment the file first
// appears in the DAG. A card that cannot be written is reported and its
// row kept, so a retry of the promotion's tail is possible by hand;
// the ticket itself is already promoted.
func (d Deps) promoteAttachments(ctx context.Context, repo *Repo, name, local, id string) (int, []string) {
held, err := d.Local.LocalAttachments(name, local)
if err != nil {
return 0, []string{"reading held files: " + err.Error()}
}
if len(held) == 0 {
return 0, nil
}
if repo.Zit == nil {
return 0, []string{"this repository has no store to write attachments to"}
}
var n int
var errs []string
for _, a := range held {
stub := attach.Stub{SHA256: a.SHA256, Size: a.Size, Type: a.Type, Name: a.Name}
var artifact string
err := repo.write(func(tok lease.Token) (err error) {
artifact, _, err = repo.Zit.AttachAdd(tok, ctx, id, a.Name, a.Type, a.Who, "", stub.Format())
return err
})
if err != nil {
errs = append(errs, a.Name+": "+err.Error())
continue
}
n++
// The held rule (RFC 0016 §Trust), asked of the UPLOADER and not
// of the administrator promoting: without a capability plane
// every caller is the operator, as holds() already reads it.
if d.Attachments != nil && d.CapsFor != nil && !d.capsFor(ctx, a.Who, name).HasAny("qa") {
if err := d.Attachments.HoldAttachment(oltp.Hold{Repo: name, Artifact: artifact, Target: id, Name: a.Name, Who: a.Who}); err != nil {
log.Printf("attachments: %s: recording hold on %s: %v", name, artifact, err)
}
}
if err := d.Local.LocalAttachmentRemove(name, local, a.Name); err != nil {
log.Printf("attachments: %s: dropping held %s after promotion: %v", name, a.Name, err)
}
}
return n, errs
}
// ticketSetFields translates ticket.set's input into fossil's field
// vocabulary, shared by both tiers. A ticket CHANGE carries its own
// text under icomment with a plain "mimetype"; the description's is
// "comment" with "cmimetype". Two field names for the same question,
// which is fossil's schema and not ours to rename — and writing a
// reply into "comment" does not append it, it REPLACES the description.
func ticketSetFields(in ticketSetIn) map[string]string {
fields := map[string]string{}
for k, v := range map[string]string{
"title": in.Title, "status": in.Status,
"resolution": in.Resolution, "type": in.Type,
"severity": in.Severity, "priority": in.Priority,
"subsystem": in.Subsystem, "foundin": in.FoundIn,
"icomment": in.Comment,
} {
if v != "" {
fields[k] = v
}
}
if in.Comment != "" {
fields["mimetype"] = authoredMimetype
}
if in.Description != "" {
fields["comment"] = in.Description
fields["cmimetype"] = authoredMimetype
}
return fields
}
// openTickets is a repository's open work as its Tickets tab counts it:
// the tracker's open tickets the caller may see, plus the local tier's
// (localOpenTickets, the fold repo.counts uses). The dashboard counted
// the tracker alone and read 15 beside a tab reading 51.
func (d Deps) openTickets(ctx context.Context, name string, repo *Repo) (int, error) {
open, _, err := repo.Index.TicketCounts(index.TicketFilter{IncludePrivate: seesPrivate(ctx)})
if err != nil {
return 0, err
}
return open + d.localOpenTickets(name), nil
}