tenseleyflow/shithub / cd2786e

Browse files

S31: @org/team mention resolution in markdown renderer + Team resolver

Authored by espadonne
SHA
cd2786e083f5bb5405b9a65b7b152327712ececb
Parents
2550a7c
Tree
e50756b

4 changed files

StatusFile+-
M internal/markdown/extensions/extensions.go 65 25
M internal/markdown/markdown_test.go 26 0
M internal/markdown/opts.go 4 0
M internal/markdown/render.go 1 0
internal/markdown/extensions/extensions.gomodified
@@ -50,6 +50,11 @@ type Resolvers struct {
5050
 	// (a same-repo render) and the matched token is a 7-40 char
5151
 	// lowercase hex string at a word boundary.
5252
 	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)
5358
 }
5459
 
5560
 // Options is the per-render config consumed by the transformer.
@@ -82,26 +87,33 @@ type Mention struct {
8287
 }
8388
 
8489
 // 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.
8794
 //
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
94104
 var reCombined = regexp.MustCompile(`` +
95105
 	// 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.
99107
 	`(?:^|[^\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
101109
 	`|(?:^|[^\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
103115
 	`|(?:^|[^\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
105117
 	`|(?:^|[^\w/])([0-9a-f]{7,40})\b` +
106118
 	// or emoji shortcode: :smile:
107119
 	`|:([a-z0-9_+\-]+):`,
@@ -169,11 +181,12 @@ func (t *transformer) replaceText(txt *ast.Text, source []byte) {
169181
 		// content starts (excluding the regex-consumed boundary
170182
 		// char, if any).
171183
 		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
177190
 		)
178191
 		var contentStart int
179192
 		switch {
@@ -184,12 +197,14 @@ func (t *transformer) replaceText(txt *ast.Text, source []byte) {
184197
 			contentStart = m[2]
185198
 		case isSameRepo:
186199
 			contentStart = m[8] - 1 // include `#`
187
-		case isMention:
200
+		case isTeamMen:
188201
 			contentStart = m[10] - 1 // include `@`
202
+		case isMention:
203
+			contentStart = m[14] - 1 // include `@`
189204
 		case isCommit:
190
-			contentStart = m[12]
205
+			contentStart = m[16]
191206
 		case isEmoji:
192
-			contentStart = m[14] - 1 // include leading `:`
207
+			contentStart = m[18] - 1 // include leading `:`
193208
 		}
194209
 
195210
 		// Emit (a) any text between the previous cursor and the
@@ -218,18 +233,24 @@ func (t *transformer) replaceText(txt *ast.Text, source []byte) {
218233
 			if !t.appendIssueLink(parent, txt, "", "", numStr, display) {
219234
 				t.insertText(parent, txt, display)
220235
 			}
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
+			}
221242
 		case isMention:
222
-			name := string(body[m[10]:m[11]])
243
+			name := string(body[m[14]:m[15]])
223244
 			if !t.appendMentionLink(parent, txt, name, display) {
224245
 				t.insertText(parent, txt, display)
225246
 			}
226247
 		case isCommit:
227
-			sha := string(body[m[12]:m[13]])
248
+			sha := string(body[m[16]:m[17]])
228249
 			if !t.appendCommitLink(parent, txt, sha, display) {
229250
 				t.insertText(parent, txt, display)
230251
 			}
231252
 		case isEmoji:
232
-			name := string(body[m[14]:m[15]])
253
+			name := string(body[m[18]:m[19]])
233254
 			if uni, ok := lookupEmoji(name); ok {
234255
 				t.insertText(parent, txt, []byte(uni))
235256
 			} else {
@@ -289,6 +310,25 @@ func (t *transformer) appendIssueLink(parent, before ast.Node, owner, repo, numS
289310
 	return true
290311
 }
291312
 
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
+
292332
 // appendMentionLink resolves a @username and inserts a Link node.
293333
 func (t *transformer) appendMentionLink(parent, before ast.Node, username string, display []byte) bool {
294334
 	if t.opts.Resolvers.User == nil {
internal/markdown/markdown_test.gomodified
@@ -219,6 +219,32 @@ func TestRender_MentionResolution(t *testing.T) {
219219
 	}
220220
 }
221221
 
222
+// TestRender_TeamMentionResolution: @org/team renders via the Team
223
+// resolver and falls back to plain text when the resolver declines
224
+// (e.g. secret team invisible to viewer). S31.
225
+func TestRender_TeamMentionResolution(t *testing.T) {
226
+	t.Parallel()
227
+	teamResolver := func(_ context.Context, org, team string, _ int64) (string, bool) {
228
+		if org == "acme" && team == "eng" {
229
+			return "/acme/teams/eng", true
230
+		}
231
+		return "", false
232
+	}
233
+	out, _, _, err := Render(context.Background(), []byte("ping @acme/eng and @acme/secret here"), Options{
234
+		Resolvers: Resolvers{Team: teamResolver},
235
+	})
236
+	if err != nil {
237
+		t.Fatalf("render: %v", err)
238
+	}
239
+	s := string(out)
240
+	if !strings.Contains(s, `href="/acme/teams/eng"`) {
241
+		t.Errorf("expected @acme/eng link, got %q", s)
242
+	}
243
+	if strings.Contains(s, `href="/acme/teams/secret"`) {
244
+		t.Errorf("@acme/secret should not link, got %q", s)
245
+	}
246
+}
247
+
222248
 // TestRender_IssueRefResolution checks both same-repo and cross-repo
223249
 // refs, and that an unresolvable ref renders as plain text (no link).
224250
 func TestRender_IssueRefResolution(t *testing.T) {
internal/markdown/opts.gomodified
@@ -33,6 +33,10 @@ type Resolvers struct {
3333
 	// "/owner/repo/commit/<full_sha>". Only invoked when
3434
 	// Opts.Repo != nil.
3535
 	Commit func(ctx context.Context, repoOwner, repoName, shaPrefix string) (href, fullSHA string, ok bool)
36
+	// Team: @org/team → "/org/teams/team". Visibility-aware: a
37
+	// secret team the viewer can't see should return ok=false so
38
+	// the renderer falls back to plain text. (S31)
39
+	Team func(ctx context.Context, orgSlug, teamSlug string, viewerUserID int64) (href string, ok bool)
3640
 }
3741
 
3842
 // Options tunes a single Render call. Zero-value Options is valid
internal/markdown/render.gomodified
@@ -59,6 +59,7 @@ func Render(ctx context.Context, src []byte, opts Options) (rendered []byte, ref
5959
 			User:   opts.Resolvers.User,
6060
 			Issue:  opts.Resolvers.Issue,
6161
 			Commit: opts.Resolvers.Commit,
62
+			Team:   opts.Resolvers.Team,
6263
 		},
6364
 	}
6465
 	if opts.Repo != nil {