// teamboard.go — RFC 0018 §"Gathering tickets": one list across a
// team's repositories, grouped by assignee, with nobody last and
// pickable.
//
// Not new machinery. `ticket.across` already answers "every tracker the
// caller can reach, as one listing, with repository as one more browse
// axis"; this is that op scoped to the team's repositories rather than
// to the caller's granted set, plus the assignee axis the RFC asks for.
//
// Visibility is per repository, NOT membership of the team. A team's
// room is gated on membership because it mixes the team's own lines
// with its repositories' and the team's own are the team's business; a
// board has no team-own content — every row is a repository's ticket —
// so the honest gate is the one each repository already applies. A
// private repository's tickets stay invisible to a non-member because
// repoFor refuses it, not because the board asked about teams.
package api
import (
"context"
"fmt"
"sort"
"strings"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// summaryOf converts an index row to the wire shape. One function, so
// the board and ticket.across cannot describe a ticket two ways — this
// was written out by hand in each of them, and a field added to one
// copy is a field silently missing from the other.
func summaryOf(t index.TicketSummary) ticketSummary {
return ticketSummary{
ID: t.ID, Title: t.Title, Status: t.Status,
Updated: t.Updated, Created: t.Created,
Type: t.Type, Severity: t.Severity, Priority: t.Priority,
Subsystem: t.Subsystem, Resolution: t.Resolution,
Author: t.Author, LastUser: t.LastUser, Comments: t.Comments,
Assignee: t.Assignee, Refs: t.Refs,
}
}
type teamBoardIn struct {
Name string `json:"name" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
State string `json:"state" validate:"oneof=open|closed|all"`
// Repo narrows to one of the team's trackers; a facet, not
// addressing.
Repo string `json:"repo" validate:"max=64"`
// Assignee narrows to one person's column. Empty with Nobody set is
// the unassigned column, which is what a team opens this page for.
Assignee string `json:"assignee" validate:"max=64"`
Nobody bool `json:"nobody"`
Status string `json:"status" validate:"max=64"`
Title string `json:"title" validate:"max=128"`
Order string `json:"order" validate:"max=32"`
}
type teamBoardOut struct {
Team string `json:"team"`
Columns []boardColumn `json:"columns"`
Tickets []acrossTicket `json:"tickets"`
Open int `json:"open"`
Closed int `json:"closed"`
Rooms []string `json:"repos"`
// Assignable is the subset of Rooms whose tracker actually defines
// an assignee column, so the board offers reassignment only where
// it would work.
//
// ticket.set validates a tracker-defined field against the fields
// that tracker's own tickets carry — fossil creates a column on
// demand, so an unchecked write would make every edit a schema
// migration. On a tracker that has never named an assignee the
// write is refused, and a control that refuses is worse than one
// that is not there.
Assignable []string `json:"assignable"`
// Truncated says the merge hit its bound: there is more work than
// this shows, and no cursor reaches it. Carried from ticket.across's
// own honesty about the same limit.
Truncated bool `json:"truncated"`
}
// boardColumn is one assignee's share of the board. Nobody is a column
// like any other, with an empty Assignee — it is a real state and the
// one a team is usually looking for, not a missing value.
type boardColumn struct {
Assignee string `json:"assignee"`
Open int `json:"open"`
}
// Nobody reports the unassigned column, so a page can put it last and
// style it without comparing strings itself.
func (c boardColumn) Nobody() bool { return c.Assignee == "" }
func (d Deps) registerTeamBoard(r *ops.Registry) error {
return ops.Register(r, ops.Op[teamBoardIn, teamBoardOut]{
Name: "team.tickets",
Doc: "One ticket list across a team's repositories, with the assignee columns beside it and `nobody` last. " +
"ticket.across scoped to the team rather than to the caller's granted set. " +
"Each repository applies its own visibility, so a private one is absent for somebody who cannot read it — membership of the team is not what decides.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/teams/{name}/tickets",
Run: func(ctx context.Context, in teamBoardIn) (teamBoardOut, error) {
if d.Teams == nil {
return teamBoardOut{}, errNoTeams()
}
if _, found := d.Teams.TeamByName(in.Name); !found {
return teamBoardOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
}
rows, err := d.Teams.TeamRepos(in.Name)
if err != nil {
return teamBoardOut{}, err
}
out := teamBoardOut{Team: in.Name, Tickets: []acrossTicket{},
Columns: []boardColumn{}, Rooms: []string{}, Assignable: []string{}}
filter := index.TicketFilter{
IncludePrivate: seesPrivate(ctx),
State: in.State, Status: in.Status,
Title: in.Title, Order: in.Order,
}
if in.Nobody || in.Assignee != "" {
filter.Assignee, filter.AssigneeSet = in.Assignee, true
}
perRepo := in.Limit
if perRepo <= 0 || perRepo > 200 {
perRepo = 50
}
// The columns are counted WITHOUT the assignee narrowing, so
// picking a column does not make the other columns vanish
// from the strip that got you there.
wide := filter
wide.Assignee, wide.AssigneeSet = "", false
byAssignee := map[string]int{}
for _, tr := range rows {
if in.Repo != "" && tr.Repo != in.Repo {
continue
}
// The repository's own visibility, asked the way every
// other surface asks it.
if _, rerr := d.repoFor(ctx, tr.Repo); rerr != nil {
continue
}
repo, ok := d.Repos.Get(tr.Repo)
if !ok || repo.Index == nil {
continue
}
out.Rooms = append(out.Rooms, tr.Repo)
// Does this tracker name an assignee at all? Asked once
// per repository, not once per ticket.
if names, ferr := repo.Index.TicketFieldNames(); ferr == nil {
for _, f := range names {
if strings.EqualFold(f.Name, "assignee") {
out.Assignable = append(out.Assignable, tr.Repo)
break
}
}
}
got, more, terr := repo.Index.TicketSummaries(perRepo, "", filter)
if terr != nil {
continue // one broken index must not empty the board
}
out.Truncated = out.Truncated || more
for _, t := range got {
out.Tickets = append(out.Tickets, acrossTicket{
Repo: tr.Repo, ticketSummary: summaryOf(t)})
}
if open, closed, cerr := repo.Index.TicketCounts(filter); cerr == nil {
out.Open, out.Closed = out.Open+open, out.Closed+closed
}
// One pass per repository for the columns, over the
// unnarrowed filter.
all, _, aerr := repo.Index.TicketSummaries(perRepo, "", wide)
if aerr != nil {
continue
}
for _, t := range all {
byAssignee[t.Assignee]++
}
}
// Newest first across the whole board, so a merge of several
// trackers reads as one list.
sort.SliceStable(out.Tickets, func(i, j int) bool {
return out.Tickets[i].Updated > out.Tickets[j].Updated
})
named := make([]string, 0, len(byAssignee))
for who := range byAssignee {
if who != "" {
named = append(named, who)
}
}
sort.Strings(named)
for _, who := range named {
out.Columns = append(out.Columns, boardColumn{Assignee: who, Open: byAssignee[who]})
}
// Nobody LAST and always present when there is anything
// unassigned: RFC 0018 asks for it last and pickable, and a
// column that appears only sometimes is one nobody learns
// to look for.
if n := byAssignee[""]; n > 0 {
out.Columns = append(out.Columns, boardColumn{Open: n})
}
return out, nil
},
})
}