@@ -50,6 +50,11 @@ type Resolvers struct { |
| 50 | 50 | // (a same-repo render) and the matched token is a 7-40 char |
| 51 | 51 | // lowercase hex string at a word boundary. |
| 52 | 52 | Commit func(ctx context.Context, repoOwner, repoName, shaPrefix string) (href, fullSHA string, ok bool) |
| 53 | + // Team resolves an `@org/team` mention to the team page link. |
| 54 | + // Visibility-aware: a secret team the viewer can't see should |
| 55 | + // return `ok=false` so the renderer falls back to plain text |
| 56 | + // (no existence leak). |
| 57 | + Team func(ctx context.Context, orgSlug, teamSlug string, viewerUserID int64) (href string, ok bool) |
| 53 | 58 | } |
| 54 | 59 | |
| 55 | 60 | // Options is the per-render config consumed by the transformer. |
@@ -82,26 +87,33 @@ type Mention struct { |
| 82 | 87 | } |
| 83 | 88 | |
| 84 | 89 | // reCombined matches every pattern in one pass. Order in the |
| 85 | | -// alternation is by how they appear in source after parsing — left |
| 86 | | -// to right. Capture groups: |
| 90 | +// alternation matters because the `@org/team` branch is more |
| 91 | +// specific than `@user` and must come first — otherwise `@org` is |
| 92 | +// captured by the user branch and the trailing `/team` is left |
| 93 | +// behind as unstructured text. |
| 87 | 94 | // |
| 88 | | -// (?:^|[^\w/]) leading boundary (consumed but reattached as text) |
| 89 | | -// #1 cross-repo: owner / repo / number |
| 90 | | -// #4 same-repo: number |
| 91 | | -// #5 mention: username |
| 92 | | -// #6 commit prefix |
| 93 | | -// #7 emoji name |
| 95 | +// Capture-index map (each MatchAllSubmatchIndex hit is a flat slice; |
| 96 | +// indices below are the START of the named group): |
| 97 | +// |
| 98 | +// #2 / #4 / #6 cross-repo: owner / repo / number |
| 99 | +// #8 same-repo: number |
| 100 | +// #10 / #12 team mention: org / team (S31) |
| 101 | +// #14 user mention: username |
| 102 | +// #16 commit prefix |
| 103 | +// #18 emoji name |
| 94 | 104 | var reCombined = regexp.MustCompile(`` + |
| 95 | 105 | // cross-repo: alice/proj#3 — left boundary required so we don't |
| 96 | | - // chew into a preceding word (e.g. `xfoo/bar#3` should not be a |
| 97 | | - // match of `foo/bar#3`). Mirrors the same-repo / mention shape; |
| 98 | | - // the audit caught the asymmetry (S00-S25, M). |
| 106 | + // chew into a preceding word. |
| 99 | 107 | `(?:^|[^\w/])([A-Za-z0-9][A-Za-z0-9._-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)#([0-9]{1,9})\b` + |
| 100 | | - // or same-repo: #3 — must have non-word non-/ boundary on the left |
| 108 | + // or same-repo: #3 |
| 101 | 109 | `|(?:^|[^\w/])#([0-9]{1,9})\b` + |
| 102 | | - // or mention: @alice — must have non-word boundary on the left |
| 110 | + // or team mention: @org/team — comes BEFORE @user so the |
| 111 | + // trailing `/team` doesn't get split off as text. Slug shape |
| 112 | + // matches users.username + teams.slug. |
| 113 | + `|(?:^|[^\w])@([a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?)/([a-z0-9](?:[a-z0-9._-]{0,48}[a-z0-9])?)\b` + |
| 114 | + // or user mention: @alice |
| 103 | 115 | `|(?:^|[^\w])@([A-Za-z0-9][A-Za-z0-9_-]{0,38})\b` + |
| 104 | | - // or commit SHA: 7–40 lowercase hex, word-boundary on both sides |
| 116 | + // or commit SHA: 7–40 lowercase hex |
| 105 | 117 | `|(?:^|[^\w/])([0-9a-f]{7,40})\b` + |
| 106 | 118 | // or emoji shortcode: :smile: |
| 107 | 119 | `|:([a-z0-9_+\-]+):`, |
@@ -169,11 +181,12 @@ func (t *transformer) replaceText(txt *ast.Text, source []byte) { |
| 169 | 181 | // content starts (excluding the regex-consumed boundary |
| 170 | 182 | // char, if any). |
| 171 | 183 | var ( |
| 172 | | - isCrossRepo = m[2] >= 0 |
| 173 | | - isSameRepo = m[8] >= 0 |
| 174 | | - isMention = m[10] >= 0 |
| 175 | | - isCommit = m[12] >= 0 |
| 176 | | - isEmoji = m[14] >= 0 |
| 184 | + isCrossRepo = m[2] >= 0 |
| 185 | + isSameRepo = m[8] >= 0 |
| 186 | + isTeamMen = m[10] >= 0 |
| 187 | + isMention = m[14] >= 0 |
| 188 | + isCommit = m[16] >= 0 |
| 189 | + isEmoji = m[18] >= 0 |
| 177 | 190 | ) |
| 178 | 191 | var contentStart int |
| 179 | 192 | switch { |
@@ -184,12 +197,14 @@ func (t *transformer) replaceText(txt *ast.Text, source []byte) { |
| 184 | 197 | contentStart = m[2] |
| 185 | 198 | case isSameRepo: |
| 186 | 199 | contentStart = m[8] - 1 // include `#` |
| 187 | | - case isMention: |
| 200 | + case isTeamMen: |
| 188 | 201 | contentStart = m[10] - 1 // include `@` |
| 202 | + case isMention: |
| 203 | + contentStart = m[14] - 1 // include `@` |
| 189 | 204 | case isCommit: |
| 190 | | - contentStart = m[12] |
| 205 | + contentStart = m[16] |
| 191 | 206 | case isEmoji: |
| 192 | | - contentStart = m[14] - 1 // include leading `:` |
| 207 | + contentStart = m[18] - 1 // include leading `:` |
| 193 | 208 | } |
| 194 | 209 | |
| 195 | 210 | // Emit (a) any text between the previous cursor and the |
@@ -218,18 +233,24 @@ func (t *transformer) replaceText(txt *ast.Text, source []byte) { |
| 218 | 233 | if !t.appendIssueLink(parent, txt, "", "", numStr, display) { |
| 219 | 234 | t.insertText(parent, txt, display) |
| 220 | 235 | } |
| 236 | + case isTeamMen: |
| 237 | + orgSlug := string(body[m[10]:m[11]]) |
| 238 | + teamSlug := string(body[m[12]:m[13]]) |
| 239 | + if !t.appendTeamMentionLink(parent, txt, orgSlug, teamSlug, display) { |
| 240 | + t.insertText(parent, txt, display) |
| 241 | + } |
| 221 | 242 | case isMention: |
| 222 | | - name := string(body[m[10]:m[11]]) |
| 243 | + name := string(body[m[14]:m[15]]) |
| 223 | 244 | if !t.appendMentionLink(parent, txt, name, display) { |
| 224 | 245 | t.insertText(parent, txt, display) |
| 225 | 246 | } |
| 226 | 247 | case isCommit: |
| 227 | | - sha := string(body[m[12]:m[13]]) |
| 248 | + sha := string(body[m[16]:m[17]]) |
| 228 | 249 | if !t.appendCommitLink(parent, txt, sha, display) { |
| 229 | 250 | t.insertText(parent, txt, display) |
| 230 | 251 | } |
| 231 | 252 | case isEmoji: |
| 232 | | - name := string(body[m[14]:m[15]]) |
| 253 | + name := string(body[m[18]:m[19]]) |
| 233 | 254 | if uni, ok := lookupEmoji(name); ok { |
| 234 | 255 | t.insertText(parent, txt, []byte(uni)) |
| 235 | 256 | } else { |
@@ -289,6 +310,25 @@ func (t *transformer) appendIssueLink(parent, before ast.Node, owner, repo, numS |
| 289 | 310 | return true |
| 290 | 311 | } |
| 291 | 312 | |
| 313 | +// appendTeamMentionLink resolves an @org/team and inserts a Link |
| 314 | +// node. Returns false on any failure (unknown org, secret team |
| 315 | +// invisible to viewer, no resolver wired) — the caller renders the |
| 316 | +// matched text as-is. |
| 317 | +func (t *transformer) appendTeamMentionLink(parent, before ast.Node, orgSlug, teamSlug string, display []byte) bool { |
| 318 | + if t.opts.Resolvers.Team == nil { |
| 319 | + return false |
| 320 | + } |
| 321 | + href, ok := t.opts.Resolvers.Team(t.opts.Ctx, orgSlug, teamSlug, t.opts.ViewerUserID) |
| 322 | + if !ok { |
| 323 | + return false |
| 324 | + } |
| 325 | + link := ast.NewLink() |
| 326 | + link.Destination = []byte(href) |
| 327 | + link.AppendChild(link, ast.NewString(append([]byte(nil), display...))) |
| 328 | + parent.InsertBefore(parent, before, link) |
| 329 | + return true |
| 330 | +} |
| 331 | + |
| 292 | 332 | // appendMentionLink resolves a @username and inserts a Link node. |
| 293 | 333 | func (t *transformer) appendMentionLink(parent, before ast.Node, username string, display []byte) bool { |
| 294 | 334 | if t.opts.Resolvers.User == nil { |