Skip to content
quarry Artifact [5546a9fb71]
Login

Artifact [5546a9fb71]

Artifact 5546a9fb71f978f83ab51a7562af94ed1994026d72caf83a95e3c4b434358805:


// 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
		},
	})
}