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