1package domain
2
3import (
4 "regexp"
5 "slices"
6 "strconv"
7)
8
9// MaxReferenceTextBytes bounds each title, description, note and commit message scanned.
10const MaxReferenceTextBytes = 16 << 10
11
12var numberRef = regexp.MustCompile(`(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
13var closingRef = regexp.MustCompile(`(?i)(?:^|[^0-9A-Za-z_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::[ \t]*|[ \t]+)#([1-9][0-9]{0,9})\b((?:[ \t]*,[ \t]*#[1-9][0-9]{0,9}\b|[ \t]+and[ \t]+#[1-9][0-9]{0,9}\b)*)`)
14
15// FindReferences returns the positive #N numbers in text, sorted and unique.
16// Numbers glued to words, entities or another # are excluded, as are zero,
17// leading zeroes and numbers longer than ten digits. Code is scanned as text;
18// cross-repository references and !N are not supported.
19func FindReferences(text string) []int64 {
20 numbers := make(map[int64]struct{})
21 // Resume at the end of the number, retaining its following delimiter for the
22 // next match (for example, both references in "#1,#2").
23 for offset := 0; offset < len(text); {
24 match := numberRef.FindStringSubmatchIndex(text[offset:])
25 if match == nil {
26 break
27 }
28 start, end := offset+match[4], offset+match[5]
29 // A sliced search must not manufacture a beginning-of-text boundary.
30 if start < 2 || !referenceWord(text[start-2]) {
31 n, _ := strconv.ParseInt(text[start:end], 10, 64)
32 numbers[n] = struct{}{}
33 }
34 offset = end
35 }
36 return sortedReferences(numbers)
37}
38
39func referenceWord(b byte) bool {
40 return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b == '_' || b == '&' || b == '#'
41}
42
43// FindClosingReferences returns sorted, unique references following close,
44// closes, closed, fix, fixes, fixed, resolve, resolves or resolved (case
45// insensitive). A colon is optional; lists use commas or "and", not spaces.
46// Like FindReferences, this scans code spans and fences as ordinary text.
47func FindClosingReferences(text string) []int64 {
48 numbers := make(map[int64]struct{})
49 for _, match := range closingRef.FindAllString(text, -1) {
50 for _, n := range FindReferences(match) {
51 numbers[n] = struct{}{}
52 }
53 }
54 return sortedReferences(numbers)
55}
56
57func sortedReferences(numbers map[int64]struct{}) []int64 {
58 var result []int64
59 for n := range numbers {
60 result = append(result, n)
61 }
62 slices.Sort(result)
63 return result
64}
65
66// MergeRequestReferences describes the issues named by a merge request.
67type MergeRequestReferences struct {
68 Mentions []int64
69 Closes []int64
70}
71
72// ScanMergeRequest scans titles, descriptions, notes and commit messages for
73// references. Notes can mention issues but cannot close them. Each individual
74// text is truncated to MaxReferenceTextBytes, without a trailing partial token, before scanning; no Markdown or
75// cross-repository interpretation is performed.
76func ScanMergeRequest(title, description string, notes, commitMessages []string) MergeRequestReferences {
77 mentions, closes := make(map[int64]struct{}), make(map[int64]struct{})
78 scan := func(text string, closing bool) {
79 if len(text) > MaxReferenceTextBytes {
80 // Drop the trailing partial token: back off to the last ASCII
81 // whitespace at or before the limit.
82 cut := -1
83 for i := MaxReferenceTextBytes; i >= 0; i-- {
84 if i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') {
85 cut = i
86 break
87 }
88 }
89 if cut < 0 {
90 return
91 }
92 text = text[:cut]
93 }
94 for _, n := range FindReferences(text) {
95 mentions[n] = struct{}{}
96 }
97 if closing {
98 for _, n := range FindClosingReferences(text) {
99 closes[n] = struct{}{}
100 }
101 }
102 }
103 scan(title, true)
104 scan(description, true)
105 for _, text := range notes {
106 scan(text, false)
107 }
108 for _, text := range commitMessages {
109 scan(text, true)
110 }
111 return MergeRequestReferences{Mentions: sortedReferences(mentions), Closes: sortedReferences(closes)}
112}