| 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 |