Go · 5126 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 "strings"
11
12 "github.com/jackc/pgx/v5"
13 "github.com/jackc/pgx/v5/pgtype"
14
15 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
16 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
18 )
19
20 // Two patterns:
21 //
22 // #N — same-repo reference. Captured digits.
23 // owner/repo#N — cross-repo reference. Captured owner, repo, digits.
24 //
25 // Word-boundary on the leading side so we don't grab "abc#1". The N is
26 // limited to 1–9 leading digit + arbitrary digits, capped at 9 total to
27 // keep crazy parses bounded.
28 var (
29 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`)
30 reSameRepoIssueRef = regexp.MustCompile(`(?:^|[^\w/])#([0-9]{1,9})\b`)
31 )
32
33 // insertReferencesFromBody parses `body` for cross-references and
34 // records each one as an `issue_references` row plus a `referenced`
35 // event on the *target* issue. Best-effort: malformed refs are skipped
36 // silently. Self-references (target == source) are skipped to avoid
37 // noise on the same issue's timeline.
38 //
39 // `source` is the source issue (for repo scoping when resolving #N).
40 // `srcKind` is the issue_ref_source enum value. `srcObjectID` is the
41 // ID of the comment / issue / push event that originated the ref.
42 func insertReferencesFromBody(
43 ctx context.Context,
44 tx pgx.Tx,
45 deps Deps,
46 source issuesdb.Issue,
47 body string,
48 srcKind string,
49 srcObjectID int64,
50 ) error {
51 if body == "" {
52 return nil
53 }
54
55 q := issuesdb.New()
56 repoQ := reposdb.New()
57 _ = usersdb.New // owner now resolved via principals table; kept import for future username-only cases.
58
59 seen := map[int64]struct{}{}
60
61 insertRef := func(targetID int64) error {
62 if targetID == source.ID {
63 return nil
64 }
65 if _, dup := seen[targetID]; dup {
66 return nil
67 }
68 seen[targetID] = struct{}{}
69 if err := q.InsertIssueReference(ctx, tx, issuesdb.InsertIssueReferenceParams{
70 TargetIssueID: targetID,
71 SourceKind: issuesdb.IssueRefSource(srcKind),
72 SourceIssueID: pgtype.Int8{Int64: source.ID, Valid: true},
73 SourceObjectID: pgtype.Int8{Int64: srcObjectID, Valid: srcObjectID != 0},
74 }); err != nil {
75 return err
76 }
77 // Emit the timeline event on the *target* issue.
78 if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
79 IssueID: targetID,
80 ActorUserID: source.AuthorUserID,
81 Kind: "referenced",
82 Meta: []byte(`{"source_issue_id":` + strconv.FormatInt(source.ID, 10) + `}`),
83 RefTargetID: pgtype.Int8{Int64: source.ID, Valid: true},
84 }); err != nil {
85 return err
86 }
87 return nil
88 }
89
90 // Cross-repo: owner/repo#N. S31 cleared the user-only deferral
91 // from S21: principals.Resolve dispatches the owner segment to
92 // the user-side or org-side repo lookup, so org-owned repos now
93 // resolve too.
94 for _, m := range reCrossRepoIssueRef.FindAllStringSubmatch(body, -1) {
95 owner, name, numStr := m[1], m[2], m[3]
96 num, err := strconv.ParseInt(numStr, 10, 64)
97 if err != nil {
98 continue
99 }
100 // Inline principals dispatch: resolve owner slug to (kind, id)
101 // against the principals table, then look up the repo on the
102 // matching owner column. We avoid importing the orgs package
103 // from issues (would form a cycle through repos/lifecycle).
104 var (
105 pKind string
106 pID int64
107 )
108 err = tx.QueryRow(
109 ctx,
110 `SELECT kind::text, id FROM principals WHERE slug = $1`,
111 strings.ToLower(owner),
112 ).Scan(&pKind, &pID)
113 if err != nil {
114 continue
115 }
116 var repoID int64
117 switch pKind {
118 case "user":
119 row, err := repoQ.GetRepoByOwnerUserAndName(ctx, tx, reposdb.GetRepoByOwnerUserAndNameParams{
120 OwnerUserID: pgtype.Int8{Int64: pID, Valid: true},
121 Name: name,
122 })
123 if err != nil {
124 continue
125 }
126 repoID = row.ID
127 case "org":
128 row, err := repoQ.GetRepoByOwnerOrgAndName(ctx, tx, reposdb.GetRepoByOwnerOrgAndNameParams{
129 OwnerOrgID: pgtype.Int8{Int64: pID, Valid: true},
130 Name: name,
131 })
132 if err != nil {
133 continue
134 }
135 repoID = row.ID
136 default:
137 continue
138 }
139 target, err := q.GetIssueByNumber(ctx, tx, issuesdb.GetIssueByNumberParams{
140 RepoID: repoID, Number: num,
141 })
142 if err != nil {
143 if errors.Is(err, pgx.ErrNoRows) {
144 continue
145 }
146 return err
147 }
148 if err := insertRef(target.ID); err != nil {
149 return err
150 }
151 }
152
153 // Same-repo: #N. Skip text positions already captured by the
154 // cross-repo regex by stripping owner/repo#N matches first.
155 stripped := reCrossRepoIssueRef.ReplaceAllString(body, " ")
156 for _, m := range reSameRepoIssueRef.FindAllStringSubmatch(stripped, -1) {
157 num, err := strconv.ParseInt(m[1], 10, 64)
158 if err != nil {
159 continue
160 }
161 target, err := q.GetIssueByNumber(ctx, tx, issuesdb.GetIssueByNumberParams{
162 RepoID: source.RepoID, Number: num,
163 })
164 if err != nil {
165 if errors.Is(err, pgx.ErrNoRows) {
166 continue
167 }
168 return err
169 }
170 if err := insertRef(target.ID); err != nil {
171 return err
172 }
173 }
174
175 return nil
176 }
177