package api
import (
"context"
"sort"
"strings"
"unicode"
"craftdesign.group/quarry/internal/ops"
)
type ticketSimilarIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Title string `json:"title" validate:"max=200"`
Limit int `json:"limit" validate:"min=0,max=10,default=3"`
}
type similarTicket struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
URL string `json:"url"`
// Local is a ticket still on the forge tier, not yet promoted.
Local bool `json:"local,omitempty"`
}
type ticketSimilarOut struct {
Tickets []similarTicket `json:"tickets"`
}
// similarStop are words too common in ticket titles to say two tickets
// are about the same thing.
var similarStop = map[string]bool{
"the": true, "and": true, "for": true, "with": true, "not": true, "when": true,
"from": true, "that": true, "this": true, "into": true, "are": true, "was": true,
"but": true, "all": true, "can": true, "has": true, "have": true, "does": true,
"doesn": true, "don": true, "isn": true, "its": true, "one": true, "any": true,
"new": true, "page": true, "should": true, "shows": true, "show": true,
}
// titleWords are a title's significant words, lower-cased, in order,
// once each: what two titles about one thing tend to share.
func titleWords(title string) []string {
var out []string
seen := map[string]bool{}
for _, w := range strings.FieldsFunc(strings.ToLower(title), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
if len(w) < 3 || similarStop[w] || seen[w] {
continue
}
seen[w] = true
out = append(out, w)
if len(out) == 8 {
break
}
}
return out
}
// settledStatus are statuses a duplicate hint ranks after the rest: a
// match that is still open is the one the reporter should add to.
var settledStatus = map[string]bool{"closed": true, "fixed": true, "resolved": true, "rejected": true}
func (d Deps) registerTicketSimilar(r *ops.Registry) error {
return ops.Register(r, ops.Op[ticketSimilarIn, ticketSimilarOut]{
Name: "ticket.similar",
Caps: "r",
Doc: "Tickets whose titles look like this one, open ones first: the likely duplicates to check before filing. " +
"Searches both tiers (the history and this forge's local tickets); a match shares at least two significant words, or one when the title has only one or two.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ticket/similar",
Run: func(ctx context.Context, in ticketSimilarIn) (ticketSimilarOut, error) {
out := ticketSimilarOut{Tickets: []similarTicket{}}
if in.Limit <= 0 {
in.Limit = 3
}
words := titleWords(in.Title)
if len(words) == 0 {
return out, nil
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return out, err
}
need := 2
if len(words) <= 2 {
need = 1
}
type cand struct {
similarTicket
shared int
order int
}
var found []cand
shared := func(title string) int {
n := 0
for _, w := range titleWords(title) {
for _, x := range words {
if w == x {
n++
}
}
}
return n
}
if repo.Index != nil {
hits, err := repo.Index.SearchAny(words, "ticket", 20)
if err != nil {
return out, err
}
for i, h := range hits {
t, ok, terr := repo.Index.TicketByPrefixFor(h.Ref, false)
if terr != nil || !ok {
continue
}
title := ticketField(t, "title")
if n := shared(title); n >= need {
found = append(found, cand{similarTicket{ID: t.ID, Title: title, Status: ticketField(t, "status"),
URL: d.link("/r/" + in.Repo + "/ticket/" + t.ID)}, n, i})
}
}
}
if d.Local != nil {
if locals, lerr := d.Local.LocalTickets(in.Repo); lerr == nil {
for i, lt := range locals {
if lt.PromotedTo != "" {
continue
}
if n := shared(lt.Fields["title"]); n >= need {
found = append(found, cand{similarTicket{ID: lt.ID, Title: lt.Fields["title"], Status: lt.Fields["status"],
URL: d.link("/r/" + in.Repo + "/ticket/" + lt.ID), Local: true}, n, 100 + i})
}
}
}
}
sort.SliceStable(found, func(i, j int) bool {
si, sj := settledStatus[strings.ToLower(found[i].Status)], settledStatus[strings.ToLower(found[j].Status)]
if si != sj {
return !si
}
if found[i].shared != found[j].shared {
return found[i].shared > found[j].shared
}
return found[i].order < found[j].order
})
for _, c := range found {
if len(out.Tickets) == in.Limit {
break
}
out.Tickets = append(out.Tickets, c.similarTicket)
}
return out, nil
},
})
}