Go · 4728 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package issues
4
5 import (
6 "context"
7 "errors"
8 "regexp"
9 "strconv"
10
11 "github.com/jackc/pgx/v5"
12 "github.com/jackc/pgx/v5/pgtype"
13
14 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
15 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
17 )
18
19 // Two patterns:
20 //
21 // #N — same-repo reference. Captured digits.
22 // owner/repo#N — cross-repo reference. Captured owner, repo, digits.
23 //
24 // Word-boundary on the leading side so we don't grab "abc#1". The N is
25 // limited to 1–9 leading digit + arbitrary digits, capped at 9 total to
26 // keep crazy parses bounded.
27 var (
28 reCrossRepoIssueRef = regexp.MustCompile(`(?:^|[^\w/])([A-Za-z0-9][A-Za-z0-9._-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)#([0-9]{1,9})\b`)
29 reSameRepoIssueRef = regexp.MustCompile(`(?:^|[^\w/])#([0-9]{1,9})\b`)
30 )
31
32 // insertReferencesFromBody parses `body` for cross-references and
33 // records each one as an `issue_references` row plus a `referenced`
34 // event on the *target* issue. Best-effort: malformed refs are skipped
35 // silently. Self-references (target == source) are skipped to avoid
36 // noise on the same issue's timeline.
37 //
38 // `source` is the source issue (for repo scoping when resolving #N).
39 // `srcKind` is the issue_ref_source enum value. `srcObjectID` is the
40 // ID of the comment / issue / push event that originated the ref.
41 func insertReferencesFromBody(
42 ctx context.Context,
43 tx pgx.Tx,
44 deps Deps,
45 source issuesdb.Issue,
46 body string,
47 srcKind string,
48 srcObjectID int64,
49 ) error {
50 if body == "" {
51 return nil
52 }
53
54 q := issuesdb.New()
55 repoQ := reposdb.New()
56 userQ := usersdb.New()
57
58 seen := map[int64]struct{}{}
59
60 insertRef := func(targetID int64) error {
61 if targetID == source.ID {
62 return nil
63 }
64 if _, dup := seen[targetID]; dup {
65 return nil
66 }
67 seen[targetID] = struct{}{}
68 if err := q.InsertIssueReference(ctx, tx, issuesdb.InsertIssueReferenceParams{
69 TargetIssueID: targetID,
70 SourceKind: issuesdb.IssueRefSource(srcKind),
71 SourceIssueID: pgtype.Int8{Int64: source.ID, Valid: true},
72 SourceObjectID: pgtype.Int8{Int64: srcObjectID, Valid: srcObjectID != 0},
73 }); err != nil {
74 return err
75 }
76 // Emit the timeline event on the *target* issue.
77 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
78 IssueID: targetID,
79 ActorUserID: source.AuthorUserID,
80 Kind: "referenced",
81 Meta: []byte(`{"source_issue_id":` + strconv.FormatInt(source.ID, 10) + `}`),
82 RefTargetID: pgtype.Int8{Int64: source.ID, Valid: true},
83 }); err != nil {
84 return err
85 }
86 return nil
87 }
88
89 // Cross-repo: owner/repo#N
90 for _, m := range reCrossRepoIssueRef.FindAllStringSubmatch(body, -1) {
91 owner, name, numStr := m[1], m[2], m[3]
92 num, err := strconv.ParseInt(numStr, 10, 64)
93 if err != nil {
94 continue
95 }
96 u, err := userQ.GetUserByUsername(ctx, tx, owner)
97 if err != nil {
98 continue // org owners (S31) and unknown users get silently dropped
99 }
100 repo, err := repoQ.GetRepoByOwnerUserAndName(ctx, tx, reposdb.GetRepoByOwnerUserAndNameParams{
101 OwnerUserID: pgtype.Int8{Int64: u.ID, Valid: true},
102 Name: name,
103 })
104 if err != nil {
105 continue
106 }
107 target, err := q.GetIssueByNumber(ctx, tx, issuesdb.GetIssueByNumberParams{
108 RepoID: repo.ID, Number: num,
109 })
110 if err != nil {
111 if errors.Is(err, pgx.ErrNoRows) {
112 continue
113 }
114 return err
115 }
116 if err := insertRef(target.ID); err != nil {
117 return err
118 }
119 }
120
121 // Same-repo: #N. Skip text positions already captured by the
122 // cross-repo regex by stripping owner/repo#N matches first.
123 stripped := reCrossRepoIssueRef.ReplaceAllString(body, " ")
124 for _, m := range reSameRepoIssueRef.FindAllStringSubmatch(stripped, -1) {
125 num, err := strconv.ParseInt(m[1], 10, 64)
126 if err != nil {
127 continue
128 }
129 target, err := q.GetIssueByNumber(ctx, tx, issuesdb.GetIssueByNumberParams{
130 RepoID: source.RepoID, Number: num,
131 })
132 if err != nil {
133 if errors.Is(err, pgx.ErrNoRows) {
134 continue
135 }
136 return err
137 }
138 if err := insertRef(target.ID); err != nil {
139 return err
140 }
141 }
142
143 return nil
144 }
145
146 // extractMentions returns deduplicated @username tokens from body.
147 // Used by handlers / future notifications. Bounded by username regex.
148 var reMention = regexp.MustCompile(`(?:^|[^\w])@([A-Za-z0-9][A-Za-z0-9_-]{0,38})\b`)
149
150 func extractMentions(body string) []string {
151 if body == "" {
152 return nil
153 }
154 seen := map[string]struct{}{}
155 out := []string{}
156 for _, m := range reMention.FindAllStringSubmatch(body, -1) {
157 name := m[1]
158 if _, dup := seen[name]; dup {
159 continue
160 }
161 seen[name] = struct{}{}
162 out = append(out, name)
163 }
164 return out
165 }
166