Skip to content
quarry Artifact [58b01f84bc]
Login

Artifact [58b01f84bc]

Artifact 58b01f84bcb119407e74e0eb13e19416417aa562d3f7bb8a770577ded0e3e92a:


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
}