Skip to content
quarry Artifact [7f4d0869fc]
Login

Artifact [7f4d0869fc]

Artifact 7f4d0869fcb2d8de103be33cad1c961f366abf2cfb4e5bc36edd87311f8123a7:


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