tenseleyflow/shithub / 5cbcc45

Browse files

S21: issues orchestrator (create/comment/state/lock + labels/milestones/refs) + tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5cbcc452109ad6b41c74294ea8e0cea08f9d63d1
Parents
a396715
Tree
6cff6f5

6 changed files

StatusFile+-
A internal/issues/issues.go 312 0
A internal/issues/issues_test.go 263 0
A internal/issues/labels.go 204 0
A internal/issues/milestones.go 196 0
A internal/issues/references.go 165 0
A internal/issues/references_test.go 83 0
internal/issues/issues.goadded
@@ -0,0 +1,312 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package issues owns the issue + comment + label + milestone domain
4
+// logic. Web handlers call into this package; the package owns
5
+// transactions, cross-reference parsing, and event emission.
6
+//
7
+// PR-specific behavior lives in the future internal/pulls/ package
8
+// (S22) — but PRs reuse the `issues` and `issue_comments` tables, so
9
+// the queries here are kind-discriminated to keep the surface clean.
10
+package issues
11
+
12
+import (
13
+	"context"
14
+	"errors"
15
+	"fmt"
16
+	"log/slog"
17
+	"strings"
18
+	"time"
19
+
20
+	"github.com/jackc/pgx/v5"
21
+	"github.com/jackc/pgx/v5/pgtype"
22
+	"github.com/jackc/pgx/v5/pgxpool"
23
+
24
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
25
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
26
+	mdrender "github.com/tenseleyFlow/shithub/internal/repos/markdown"
27
+)
28
+
29
+// Deps wires this package against the rest of the runtime. Pool is
30
+// required; Limiter governs the comment-create rate limit; Logger is
31
+// optional (falls back to discarding when nil).
32
+type Deps struct {
33
+	Pool    *pgxpool.Pool
34
+	Limiter *throttle.Limiter
35
+	Logger  *slog.Logger
36
+}
37
+
38
+// Errors returned by the orchestrator. Handlers map these to status
39
+// codes + friendly user-facing messages.
40
+var (
41
+	ErrEmptyTitle        = errors.New("issues: title is required")
42
+	ErrTitleTooLong      = errors.New("issues: title too long (max 256)")
43
+	ErrBodyTooLong       = errors.New("issues: body too long")
44
+	ErrCommentTooLong    = errors.New("issues: comment too long")
45
+	ErrIssueLocked       = errors.New("issues: issue is locked")
46
+	ErrCommentRateLimit  = errors.New("issues: comment rate limit exceeded")
47
+	ErrLabelExists       = errors.New("issues: label name already taken on this repo")
48
+	ErrLabelInvalidColor = errors.New("issues: label color must be 6 hex chars")
49
+	ErrMilestoneExists   = errors.New("issues: milestone title already taken on this repo")
50
+	ErrIssueNotFound     = errors.New("issues: issue not found")
51
+)
52
+
53
+// CreateParams describes a new-issue request.
54
+type CreateParams struct {
55
+	RepoID       int64
56
+	AuthorUserID int64 // 0 means anonymous (unsupported in S21; handler enforces)
57
+	Title        string
58
+	Body         string
59
+	// Kind defaults to "issue"; PR creation in S22 passes "pr".
60
+	Kind string
61
+}
62
+
63
+// Create validates inputs, allocates a per-repo number atomically,
64
+// inserts the row, renders the body's markdown, and returns the
65
+// fresh issue. Default labels live with repo create; per-issue
66
+// label/assignee/milestone application is a separate call.
67
+//
68
+// Cross-reference indexing fires asynchronously inside the same tx
69
+// via insertReferencesFromBody — refs to other issues create
70
+// `issue_references` rows + a `referenced` event on the target.
71
+func Create(ctx context.Context, deps Deps, p CreateParams) (issuesdb.Issue, error) {
72
+	title := strings.TrimSpace(p.Title)
73
+	if title == "" {
74
+		return issuesdb.Issue{}, ErrEmptyTitle
75
+	}
76
+	if len(title) > 256 {
77
+		return issuesdb.Issue{}, ErrTitleTooLong
78
+	}
79
+	if len(p.Body) > 65535 {
80
+		return issuesdb.Issue{}, ErrBodyTooLong
81
+	}
82
+	kind := p.Kind
83
+	if kind == "" {
84
+		kind = "issue"
85
+	}
86
+
87
+	tx, err := deps.Pool.Begin(ctx)
88
+	if err != nil {
89
+		return issuesdb.Issue{}, fmt.Errorf("begin: %w", err)
90
+	}
91
+	committed := false
92
+	defer func() {
93
+		if !committed {
94
+			_ = tx.Rollback(ctx)
95
+		}
96
+	}()
97
+
98
+	q := issuesdb.New()
99
+	if err := q.EnsureRepoIssueCounter(ctx, tx, p.RepoID); err != nil {
100
+		return issuesdb.Issue{}, fmt.Errorf("counter init: %w", err)
101
+	}
102
+	num, err := q.AllocateIssueNumber(ctx, tx, p.RepoID)
103
+	if err != nil {
104
+		return issuesdb.Issue{}, fmt.Errorf("allocate number: %w", err)
105
+	}
106
+
107
+	row, err := q.CreateIssue(ctx, tx, issuesdb.CreateIssueParams{
108
+		RepoID:       p.RepoID,
109
+		Number:       num,
110
+		Kind:         issuesdb.IssueKind(kind),
111
+		Title:        title,
112
+		Body:         p.Body,
113
+		AuthorUserID: pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
114
+	})
115
+	if err != nil {
116
+		return issuesdb.Issue{}, fmt.Errorf("insert: %w", err)
117
+	}
118
+
119
+	// Render markdown for the cached body html.
120
+	html, _ := mdrender.RenderHTML([]byte(p.Body))
121
+	row.BodyHtmlCached = pgtype.Text{String: html, Valid: html != ""}
122
+
123
+	if err := q.UpdateIssueTitleBody(ctx, tx, issuesdb.UpdateIssueTitleBodyParams{
124
+		ID: row.ID, Title: row.Title, Body: row.Body,
125
+		BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
126
+	}); err != nil {
127
+		return issuesdb.Issue{}, fmt.Errorf("update html: %w", err)
128
+	}
129
+
130
+	if err := insertReferencesFromBody(ctx, tx, deps, row, p.Body, "issue_body", row.ID); err != nil {
131
+		return issuesdb.Issue{}, fmt.Errorf("refs: %w", err)
132
+	}
133
+
134
+	if err := tx.Commit(ctx); err != nil {
135
+		return issuesdb.Issue{}, fmt.Errorf("commit: %w", err)
136
+	}
137
+	committed = true
138
+	return row, nil
139
+}
140
+
141
+// CommentCreateParams is the input for AddComment.
142
+type CommentCreateParams struct {
143
+	IssueID      int64
144
+	AuthorUserID int64
145
+	Body         string
146
+	// IsCollab signals that the actor's policy.Can role is at least
147
+	// triage. Used to bypass the locked-issue gate.
148
+	IsCollab bool
149
+}
150
+
151
+// AddComment validates, applies the rate limit, inserts the comment,
152
+// and indexes references. Returns the fresh comment.
153
+func AddComment(ctx context.Context, deps Deps, p CommentCreateParams) (issuesdb.IssueComment, error) {
154
+	body := strings.TrimSpace(p.Body)
155
+	if body == "" || len(body) > 65535 {
156
+		return issuesdb.IssueComment{}, ErrCommentTooLong
157
+	}
158
+	if deps.Limiter != nil && p.AuthorUserID != 0 {
159
+		if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
160
+			Scope:      "issue_comment",
161
+			Identifier: fmt.Sprintf("user:%d", p.AuthorUserID),
162
+			Max:        20,
163
+			Window:     time.Hour,
164
+		}); err != nil {
165
+			return issuesdb.IssueComment{}, ErrCommentRateLimit
166
+		}
167
+	}
168
+
169
+	q := issuesdb.New()
170
+	issue, err := q.GetIssueByID(ctx, deps.Pool, p.IssueID)
171
+	if err != nil {
172
+		if errors.Is(err, pgx.ErrNoRows) {
173
+			return issuesdb.IssueComment{}, ErrIssueNotFound
174
+		}
175
+		return issuesdb.IssueComment{}, err
176
+	}
177
+	if issue.Locked && !p.IsCollab {
178
+		return issuesdb.IssueComment{}, ErrIssueLocked
179
+	}
180
+
181
+	html, _ := mdrender.RenderHTML([]byte(body))
182
+
183
+	tx, err := deps.Pool.Begin(ctx)
184
+	if err != nil {
185
+		return issuesdb.IssueComment{}, err
186
+	}
187
+	committed := false
188
+	defer func() {
189
+		if !committed {
190
+			_ = tx.Rollback(ctx)
191
+		}
192
+	}()
193
+
194
+	c, err := q.CreateIssueComment(ctx, tx, issuesdb.CreateIssueCommentParams{
195
+		IssueID:        p.IssueID,
196
+		AuthorUserID:   pgtype.Int8{Int64: p.AuthorUserID, Valid: p.AuthorUserID != 0},
197
+		Body:           body,
198
+		BodyHtmlCached: pgtype.Text{String: html, Valid: html != ""},
199
+	})
200
+	if err != nil {
201
+		return issuesdb.IssueComment{}, err
202
+	}
203
+
204
+	if err := insertReferencesFromBody(ctx, tx, deps, issue, body, "comment_body", c.ID); err != nil {
205
+		return issuesdb.IssueComment{}, err
206
+	}
207
+
208
+	if err := tx.Commit(ctx); err != nil {
209
+		return issuesdb.IssueComment{}, err
210
+	}
211
+	committed = true
212
+	return c, nil
213
+}
214
+
215
+// SetState closes or reopens an issue and emits an `closed` /
216
+// `reopened` event.
217
+func SetState(ctx context.Context, deps Deps, actorUserID, issueID int64, newState, reason string) error {
218
+	if newState != "open" && newState != "closed" {
219
+		return errors.New("issues: state must be open or closed")
220
+	}
221
+	tx, err := deps.Pool.Begin(ctx)
222
+	if err != nil {
223
+		return err
224
+	}
225
+	committed := false
226
+	defer func() {
227
+		if !committed {
228
+			_ = tx.Rollback(ctx)
229
+		}
230
+	}()
231
+
232
+	q := issuesdb.New()
233
+	closedBy := pgtype.Int8{}
234
+	if newState == "closed" && actorUserID != 0 {
235
+		closedBy = pgtype.Int8{Int64: actorUserID, Valid: true}
236
+	}
237
+	stateReason := pgtype.Text{}
238
+	if reason != "" {
239
+		stateReason = pgtype.Text{String: reason, Valid: true}
240
+	}
241
+	if err := q.SetIssueState(ctx, tx, issuesdb.SetIssueStateParams{
242
+		ID:             issueID,
243
+		State:          issuesdb.IssueState(newState),
244
+		StateReason:    issuesdb.NullIssueStateReason{IssueStateReason: issuesdb.IssueStateReason(stateReason.String), Valid: stateReason.Valid},
245
+		ClosedByUserID: closedBy,
246
+	}); err != nil {
247
+		return err
248
+	}
249
+	kind := "closed"
250
+	if newState == "open" {
251
+		kind = "reopened"
252
+	}
253
+	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
254
+		IssueID:     issueID,
255
+		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
256
+		Kind:        kind,
257
+		Meta:        emptyMeta,
258
+	}); err != nil {
259
+		return err
260
+	}
261
+	if err := tx.Commit(ctx); err != nil {
262
+		return err
263
+	}
264
+	committed = true
265
+	return nil
266
+}
267
+
268
+// emptyMeta is the JSON object passed when an event carries no metadata.
269
+// The column is NOT NULL DEFAULT '{}'::jsonb, but binding nil from Go
270
+// sends SQL NULL rather than letting the DEFAULT fire — so callers
271
+// pass this explicitly.
272
+var emptyMeta = []byte("{}")
273
+
274
+// SetLock toggles the locked flag and emits an event.
275
+func SetLock(ctx context.Context, deps Deps, actorUserID, issueID int64, locked bool, reason string) error {
276
+	q := issuesdb.New()
277
+	tx, err := deps.Pool.Begin(ctx)
278
+	if err != nil {
279
+		return err
280
+	}
281
+	committed := false
282
+	defer func() {
283
+		if !committed {
284
+			_ = tx.Rollback(ctx)
285
+		}
286
+	}()
287
+	rsn := pgtype.Text{}
288
+	if reason != "" {
289
+		rsn = pgtype.Text{String: reason, Valid: true}
290
+	}
291
+	if err := q.SetIssueLock(ctx, tx, issuesdb.SetIssueLockParams{
292
+		ID: issueID, Locked: locked, LockReason: rsn,
293
+	}); err != nil {
294
+		return err
295
+	}
296
+	kind := "locked"
297
+	if !locked {
298
+		kind = "unlocked"
299
+	}
300
+	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
301
+		IssueID: issueID, ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
302
+		Kind: kind,
303
+		Meta: emptyMeta,
304
+	}); err != nil {
305
+		return err
306
+	}
307
+	if err := tx.Commit(ctx); err != nil {
308
+		return err
309
+	}
310
+	committed = true
311
+	return nil
312
+}
internal/issues/issues_test.goadded
@@ -0,0 +1,263 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package issues_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"log/slog"
9
+	"strings"
10
+	"sync"
11
+	"testing"
12
+
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+	"github.com/jackc/pgx/v5/pgxpool"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
17
+	"github.com/tenseleyFlow/shithub/internal/issues"
18
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
20
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
21
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22
+)
23
+
24
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
25
+	"AAAAAAAAAAAAAAAA$" +
26
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
27
+
28
+// setup spins a fresh test DB, creates a user + repo, seeds the issue
29
+// counter, and returns the things the issue tests need.
30
+func setup(t *testing.T) (*pgxpool.Pool, issues.Deps, int64, int64) {
31
+	t.Helper()
32
+	pool := dbtest.NewTestDB(t)
33
+	ctx := context.Background()
34
+
35
+	uq := usersdb.New()
36
+	user, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
37
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
38
+	})
39
+	if err != nil {
40
+		t.Fatalf("CreateUser: %v", err)
41
+	}
42
+
43
+	rq := reposdb.New()
44
+	repo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
45
+		OwnerUserID:   pgtype.Int8{Int64: user.ID, Valid: true},
46
+		Name:          "demo",
47
+		DefaultBranch: "trunk",
48
+		Visibility:    reposdb.RepoVisibilityPublic,
49
+	})
50
+	if err != nil {
51
+		t.Fatalf("CreateRepo: %v", err)
52
+	}
53
+	iq := issuesdb.New()
54
+	if err := iq.EnsureRepoIssueCounter(ctx, pool, repo.ID); err != nil {
55
+		t.Fatalf("EnsureRepoIssueCounter: %v", err)
56
+	}
57
+
58
+	deps := issues.Deps{
59
+		Pool:    pool,
60
+		Limiter: throttle.NewLimiter(),
61
+		Logger:  slog.New(slog.NewTextHandler(io.Discard, nil)),
62
+	}
63
+	return pool, deps, user.ID, repo.ID
64
+}
65
+
66
+func TestCreate_AllocatesSequentialNumbers(t *testing.T) {
67
+	_, deps, uid, rid := setup(t)
68
+	ctx := context.Background()
69
+	for i := int64(1); i <= 3; i++ {
70
+		got, err := issues.Create(ctx, deps, issues.CreateParams{
71
+			RepoID: rid, AuthorUserID: uid, Title: "title", Body: "body",
72
+		})
73
+		if err != nil {
74
+			t.Fatalf("Create #%d: %v", i, err)
75
+		}
76
+		if got.Number != i {
77
+			t.Errorf("issue %d: got number %d, want %d", i, got.Number, i)
78
+		}
79
+	}
80
+}
81
+
82
+func TestCreate_ConcurrentRaceForUniqueNumbers(t *testing.T) {
83
+	_, deps, uid, rid := setup(t)
84
+	ctx := context.Background()
85
+	const N = 8
86
+	got := make([]int64, N)
87
+	var wg sync.WaitGroup
88
+	wg.Add(N)
89
+	for i := 0; i < N; i++ {
90
+		go func(i int) {
91
+			defer wg.Done()
92
+			row, err := issues.Create(ctx, deps, issues.CreateParams{
93
+				RepoID: rid, AuthorUserID: uid, Title: "race", Body: "body",
94
+			})
95
+			if err != nil {
96
+				t.Errorf("create %d: %v", i, err)
97
+				return
98
+			}
99
+			got[i] = row.Number
100
+		}(i)
101
+	}
102
+	wg.Wait()
103
+	seen := map[int64]bool{}
104
+	for _, n := range got {
105
+		if seen[n] {
106
+			t.Errorf("duplicate number %d in %v", n, got)
107
+		}
108
+		seen[n] = true
109
+		if n < 1 || n > N {
110
+			t.Errorf("number %d out of [1,%d] range", n, N)
111
+		}
112
+	}
113
+}
114
+
115
+func TestCreate_RejectsEmptyTitle(t *testing.T) {
116
+	_, deps, uid, rid := setup(t)
117
+	ctx := context.Background()
118
+	_, err := issues.Create(ctx, deps, issues.CreateParams{
119
+		RepoID: rid, AuthorUserID: uid, Title: "   ", Body: "body",
120
+	})
121
+	if err == nil || err.Error() == "" {
122
+		t.Fatalf("expected ErrEmptyTitle, got %v", err)
123
+	}
124
+}
125
+
126
+func TestCreate_RendersHTMLAndSanitizesScripts(t *testing.T) {
127
+	pool, deps, uid, rid := setup(t)
128
+	ctx := context.Background()
129
+	row, err := issues.Create(ctx, deps, issues.CreateParams{
130
+		RepoID:       rid,
131
+		AuthorUserID: uid,
132
+		Title:        "xss",
133
+		Body:         `before <script>alert("xss")</script> after **bold**`,
134
+	})
135
+	if err != nil {
136
+		t.Fatalf("Create: %v", err)
137
+	}
138
+	iq := issuesdb.New()
139
+	got, err := iq.GetIssueByID(ctx, pool, row.ID)
140
+	if err != nil {
141
+		t.Fatalf("GetIssueByID: %v", err)
142
+	}
143
+	if !got.BodyHtmlCached.Valid {
144
+		t.Fatalf("expected cached body html")
145
+	}
146
+	html := got.BodyHtmlCached.String
147
+	if strings.Contains(strings.ToLower(html), "<script") {
148
+		t.Errorf("sanitized html should not contain <script>: %q", html)
149
+	}
150
+	if !strings.Contains(html, "<strong>") {
151
+		t.Errorf("markdown should render bold: %q", html)
152
+	}
153
+}
154
+
155
+func TestAddComment_LockedRejectsNonCollab(t *testing.T) {
156
+	pool, deps, uid, rid := setup(t)
157
+	ctx := context.Background()
158
+	row, err := issues.Create(ctx, deps, issues.CreateParams{
159
+		RepoID: rid, AuthorUserID: uid, Title: "lock-me", Body: "",
160
+	})
161
+	if err != nil {
162
+		t.Fatalf("Create: %v", err)
163
+	}
164
+	if err := issues.SetLock(ctx, deps, uid, row.ID, true, "spam"); err != nil {
165
+		t.Fatalf("SetLock: %v", err)
166
+	}
167
+	uq := usersdb.New()
168
+	other, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
169
+		Username: "carol", DisplayName: "Carol", PasswordHash: fixtureHash,
170
+	})
171
+	if err != nil {
172
+		t.Fatalf("CreateUser: %v", err)
173
+	}
174
+	_, err = issues.AddComment(ctx, deps, issues.CommentCreateParams{
175
+		IssueID:      row.ID,
176
+		AuthorUserID: other.ID,
177
+		Body:         "let me in",
178
+		IsCollab:     false,
179
+	})
180
+	if err == nil {
181
+		t.Fatalf("expected ErrIssueLocked, got nil")
182
+	}
183
+}
184
+
185
+func TestSetState_EmitsEvent(t *testing.T) {
186
+	pool, deps, uid, rid := setup(t)
187
+	ctx := context.Background()
188
+	row, err := issues.Create(ctx, deps, issues.CreateParams{
189
+		RepoID: rid, AuthorUserID: uid, Title: "close-me", Body: "",
190
+	})
191
+	if err != nil {
192
+		t.Fatalf("Create: %v", err)
193
+	}
194
+	if err := issues.SetState(ctx, deps, uid, row.ID, "closed", "completed"); err != nil {
195
+		t.Fatalf("SetState: %v", err)
196
+	}
197
+	if err := issues.SetState(ctx, deps, uid, row.ID, "open", ""); err != nil {
198
+		t.Fatalf("SetState reopen: %v", err)
199
+	}
200
+	iq := issuesdb.New()
201
+	events, err := iq.ListIssueEvents(ctx, pool, row.ID)
202
+	if err != nil {
203
+		t.Fatalf("ListIssueEvents: %v", err)
204
+	}
205
+	kinds := []string{}
206
+	for _, e := range events {
207
+		kinds = append(kinds, e.Kind)
208
+	}
209
+	want := []string{"closed", "reopened"}
210
+	if len(kinds) != 2 || kinds[0] != want[0] || kinds[1] != want[1] {
211
+		t.Errorf("got events %v, want %v", kinds, want)
212
+	}
213
+}
214
+
215
+func TestCreate_CrossReferenceCreatesEventOnTarget(t *testing.T) {
216
+	pool, deps, uid, rid := setup(t)
217
+	ctx := context.Background()
218
+	first, err := issues.Create(ctx, deps, issues.CreateParams{
219
+		RepoID: rid, AuthorUserID: uid, Title: "first", Body: "no refs",
220
+	})
221
+	if err != nil {
222
+		t.Fatalf("Create first: %v", err)
223
+	}
224
+	if _, err := issues.Create(ctx, deps, issues.CreateParams{
225
+		RepoID: rid, AuthorUserID: uid, Title: "second", Body: "fixes #" + itoa(first.Number),
226
+	}); err != nil {
227
+		t.Fatalf("Create second: %v", err)
228
+	}
229
+	iq := issuesdb.New()
230
+	events, err := iq.ListIssueEvents(ctx, pool, first.ID)
231
+	if err != nil {
232
+		t.Fatalf("ListIssueEvents: %v", err)
233
+	}
234
+	found := false
235
+	for _, e := range events {
236
+		if e.Kind == "referenced" {
237
+			found = true
238
+		}
239
+	}
240
+	if !found {
241
+		t.Errorf("expected `referenced` event on target issue, got %#v", events)
242
+	}
243
+}
244
+
245
+// itoa minimizes deps in tests.
246
+func itoa(v int64) string {
247
+	if v == 0 {
248
+		return "0"
249
+	}
250
+	out := make([]byte, 0, 4)
251
+	neg := v < 0
252
+	if neg {
253
+		v = -v
254
+	}
255
+	for v > 0 {
256
+		out = append([]byte{byte('0' + v%10)}, out...)
257
+		v /= 10
258
+	}
259
+	if neg {
260
+		out = append([]byte{'-'}, out...)
261
+	}
262
+	return string(out)
263
+}
internal/issues/labels.goadded
@@ -0,0 +1,204 @@
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/pgconn"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
16
+)
17
+
18
+// DefaultLabels is the seeded set applied to every new repo. Names +
19
+// colors are GitHub-aligned so users feel at home.
20
+var DefaultLabels = []LabelSeed{
21
+	{"bug", "d73a4a", "Something isn't working"},
22
+	{"documentation", "0075ca", "Improvements or additions to documentation"},
23
+	{"duplicate", "cfd3d7", "This issue or pull request already exists"},
24
+	{"enhancement", "a2eeef", "New feature or request"},
25
+	{"good first issue", "7057ff", "Good for newcomers"},
26
+	{"help wanted", "008672", "Extra attention is needed"},
27
+	{"invalid", "e4e669", "This doesn't seem right"},
28
+	{"question", "d876e3", "Further information is requested"},
29
+	{"wontfix", "ffffff", "This will not be worked on"},
30
+}
31
+
32
+type LabelSeed struct{ Name, Color, Description string }
33
+
34
+var reHexColor = regexp.MustCompile(`^[0-9a-fA-F]{6}$`)
35
+
36
+// LabelCreateParams is input for CreateLabel.
37
+type LabelCreateParams struct {
38
+	RepoID      int64
39
+	Name        string
40
+	Color       string
41
+	Description string
42
+}
43
+
44
+// CreateLabel validates and inserts a label. Color must be 6 hex chars.
45
+func CreateLabel(ctx context.Context, deps Deps, p LabelCreateParams) (issuesdb.Label, error) {
46
+	name := strings.TrimSpace(p.Name)
47
+	if name == "" || len(name) > 50 {
48
+		return issuesdb.Label{}, errors.New("issues: label name length 1–50")
49
+	}
50
+	color := normalizeColor(p.Color)
51
+	if !reHexColor.MatchString(color) {
52
+		return issuesdb.Label{}, ErrLabelInvalidColor
53
+	}
54
+	q := issuesdb.New()
55
+	row, err := q.CreateLabel(ctx, deps.Pool, issuesdb.CreateLabelParams{
56
+		RepoID:      p.RepoID,
57
+		Name:        name,
58
+		Color:       strings.ToLower(color),
59
+		Description: p.Description,
60
+	})
61
+	if err != nil {
62
+		if isUniqueViolation(err) {
63
+			return issuesdb.Label{}, ErrLabelExists
64
+		}
65
+		return issuesdb.Label{}, err
66
+	}
67
+	return row, nil
68
+}
69
+
70
+// SeedDefaultLabels writes the DefaultLabels into the given repo on
71
+// the supplied DBTX (typically the repo-create transaction). Existing
72
+// rows are left alone — caller can re-run safely.
73
+func SeedDefaultLabels(ctx context.Context, db issuesdb.DBTX, repoID int64) error {
74
+	q := issuesdb.New()
75
+	for _, l := range DefaultLabels {
76
+		_, err := q.CreateLabel(ctx, db, issuesdb.CreateLabelParams{
77
+			RepoID:      repoID,
78
+			Name:        l.Name,
79
+			Color:       l.Color,
80
+			Description: l.Description,
81
+		})
82
+		if err != nil && !isUniqueViolation(err) {
83
+			return err
84
+		}
85
+	}
86
+	return nil
87
+}
88
+
89
+// LabelUpdateParams is input for UpdateLabel.
90
+type LabelUpdateParams struct {
91
+	ID          int64
92
+	Name        string
93
+	Color       string
94
+	Description string
95
+}
96
+
97
+func UpdateLabel(ctx context.Context, deps Deps, p LabelUpdateParams) error {
98
+	name := strings.TrimSpace(p.Name)
99
+	if name == "" || len(name) > 50 {
100
+		return errors.New("issues: label name length 1–50")
101
+	}
102
+	color := normalizeColor(p.Color)
103
+	if !reHexColor.MatchString(color) {
104
+		return ErrLabelInvalidColor
105
+	}
106
+	q := issuesdb.New()
107
+	if err := q.UpdateLabel(ctx, deps.Pool, issuesdb.UpdateLabelParams{
108
+		ID:          p.ID,
109
+		Name:        name,
110
+		Color:       strings.ToLower(color),
111
+		Description: p.Description,
112
+	}); err != nil {
113
+		if isUniqueViolation(err) {
114
+			return ErrLabelExists
115
+		}
116
+		return err
117
+	}
118
+	return nil
119
+}
120
+
121
+// DeleteLabel removes a label.
122
+func DeleteLabel(ctx context.Context, deps Deps, id int64) error {
123
+	q := issuesdb.New()
124
+	return q.DeleteLabel(ctx, deps.Pool, id)
125
+}
126
+
127
+// ApplyLabels replaces the issue's label set with `labelIDs`. Emits a
128
+// `labeled` / `unlabeled` event for each delta. Caller is the actor.
129
+func ApplyLabels(ctx context.Context, deps Deps, actorUserID, issueID int64, labelIDs []int64) error {
130
+	q := issuesdb.New()
131
+	tx, err := deps.Pool.Begin(ctx)
132
+	if err != nil {
133
+		return err
134
+	}
135
+	committed := false
136
+	defer func() {
137
+		if !committed {
138
+			_ = tx.Rollback(ctx)
139
+		}
140
+	}()
141
+	current, err := q.ListLabelsOnIssue(ctx, tx, issueID)
142
+	if err != nil {
143
+		return err
144
+	}
145
+	want := map[int64]struct{}{}
146
+	for _, id := range labelIDs {
147
+		want[id] = struct{}{}
148
+	}
149
+	have := map[int64]struct{}{}
150
+	for _, l := range current {
151
+		have[l.ID] = struct{}{}
152
+	}
153
+	actor := pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0}
154
+	for id := range want {
155
+		if _, ok := have[id]; ok {
156
+			continue
157
+		}
158
+		if err := q.AddIssueLabel(ctx, tx, issuesdb.AddIssueLabelParams{
159
+			IssueID: issueID, LabelID: id, AppliedByUserID: actor,
160
+		}); err != nil {
161
+			return err
162
+		}
163
+		if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
164
+			IssueID: issueID, ActorUserID: actor, Kind: "labeled",
165
+			Meta: []byte(`{"label_id":` + strconv.FormatInt(id, 10) + `}`),
166
+		}); err != nil {
167
+			return err
168
+		}
169
+	}
170
+	for id := range have {
171
+		if _, ok := want[id]; ok {
172
+			continue
173
+		}
174
+		if err := q.RemoveIssueLabel(ctx, tx, issuesdb.RemoveIssueLabelParams{
175
+			IssueID: issueID, LabelID: id,
176
+		}); err != nil {
177
+			return err
178
+		}
179
+		if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
180
+			IssueID: issueID, ActorUserID: actor, Kind: "unlabeled",
181
+			Meta: []byte(`{"label_id":` + strconv.FormatInt(id, 10) + `}`),
182
+		}); err != nil {
183
+			return err
184
+		}
185
+	}
186
+	if err := tx.Commit(ctx); err != nil {
187
+		return err
188
+	}
189
+	committed = true
190
+	return nil
191
+}
192
+
193
+func normalizeColor(c string) string {
194
+	return strings.TrimPrefix(strings.TrimSpace(c), "#")
195
+}
196
+
197
+// isUniqueViolation maps Postgres SQLSTATE 23505 (unique_violation).
198
+func isUniqueViolation(err error) bool {
199
+	var pgErr *pgconn.PgError
200
+	if errors.As(err, &pgErr) {
201
+		return pgErr.Code == "23505"
202
+	}
203
+	return false
204
+}
internal/issues/milestones.goadded
@@ -0,0 +1,196 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package issues
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"strconv"
9
+	"strings"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
15
+)
16
+
17
+// MilestoneCreateParams is input for CreateMilestone.
18
+type MilestoneCreateParams struct {
19
+	RepoID      int64
20
+	Title       string
21
+	Description string
22
+	DueOn       *time.Time
23
+}
24
+
25
+func CreateMilestone(ctx context.Context, deps Deps, p MilestoneCreateParams) (issuesdb.Milestone, error) {
26
+	title := strings.TrimSpace(p.Title)
27
+	if title == "" || len(title) > 200 {
28
+		return issuesdb.Milestone{}, errors.New("issues: milestone title length 1–200")
29
+	}
30
+	due := pgtype.Timestamptz{}
31
+	if p.DueOn != nil {
32
+		due = pgtype.Timestamptz{Time: *p.DueOn, Valid: true}
33
+	}
34
+	q := issuesdb.New()
35
+	row, err := q.CreateMilestone(ctx, deps.Pool, issuesdb.CreateMilestoneParams{
36
+		RepoID: p.RepoID, Title: title, Description: p.Description, DueOn: due,
37
+	})
38
+	if err != nil {
39
+		if isUniqueViolation(err) {
40
+			return issuesdb.Milestone{}, ErrMilestoneExists
41
+		}
42
+		return issuesdb.Milestone{}, err
43
+	}
44
+	return row, nil
45
+}
46
+
47
+type MilestoneUpdateParams struct {
48
+	ID          int64
49
+	Title       string
50
+	Description string
51
+	DueOn       *time.Time
52
+}
53
+
54
+func UpdateMilestone(ctx context.Context, deps Deps, p MilestoneUpdateParams) error {
55
+	title := strings.TrimSpace(p.Title)
56
+	if title == "" || len(title) > 200 {
57
+		return errors.New("issues: milestone title length 1–200")
58
+	}
59
+	due := pgtype.Timestamptz{}
60
+	if p.DueOn != nil {
61
+		due = pgtype.Timestamptz{Time: *p.DueOn, Valid: true}
62
+	}
63
+	q := issuesdb.New()
64
+	if err := q.UpdateMilestone(ctx, deps.Pool, issuesdb.UpdateMilestoneParams{
65
+		ID: p.ID, Title: title, Description: p.Description, DueOn: due,
66
+	}); err != nil {
67
+		if isUniqueViolation(err) {
68
+			return ErrMilestoneExists
69
+		}
70
+		return err
71
+	}
72
+	return nil
73
+}
74
+
75
+func SetMilestoneState(ctx context.Context, deps Deps, id int64, state string) error {
76
+	if state != "open" && state != "closed" {
77
+		return errors.New("issues: milestone state must be open or closed")
78
+	}
79
+	q := issuesdb.New()
80
+	return q.SetMilestoneState(ctx, deps.Pool, issuesdb.SetMilestoneStateParams{
81
+		ID: id, State: issuesdb.MilestoneState(state),
82
+	})
83
+}
84
+
85
+func DeleteMilestone(ctx context.Context, deps Deps, id int64) error {
86
+	q := issuesdb.New()
87
+	return q.DeleteMilestone(ctx, deps.Pool, id)
88
+}
89
+
90
+// AssignMilestone sets an issue's milestone (or clears with milestoneID==0)
91
+// and emits a `milestoned`/`demilestoned` event.
92
+func AssignMilestone(ctx context.Context, deps Deps, actorUserID, issueID, milestoneID int64) error {
93
+	q := issuesdb.New()
94
+	tx, err := deps.Pool.Begin(ctx)
95
+	if err != nil {
96
+		return err
97
+	}
98
+	committed := false
99
+	defer func() {
100
+		if !committed {
101
+			_ = tx.Rollback(ctx)
102
+		}
103
+	}()
104
+	mid := pgtype.Int8{Int64: milestoneID, Valid: milestoneID != 0}
105
+	if err := q.SetIssueMilestone(ctx, tx, issuesdb.SetIssueMilestoneParams{
106
+		ID: issueID, MilestoneID: mid,
107
+	}); err != nil {
108
+		return err
109
+	}
110
+	kind := "milestoned"
111
+	mval := strconv.FormatInt(milestoneID, 10)
112
+	if milestoneID == 0 {
113
+		kind = "demilestoned"
114
+		mval = "null"
115
+	}
116
+	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
117
+		IssueID:     issueID,
118
+		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
119
+		Kind:        kind,
120
+		Meta:        []byte(`{"milestone_id":` + mval + `}`),
121
+	}); err != nil {
122
+		return err
123
+	}
124
+	if err := tx.Commit(ctx); err != nil {
125
+		return err
126
+	}
127
+	committed = true
128
+	return nil
129
+}
130
+
131
+// AssignUser adds an assignee + emits an `assigned` event.
132
+func AssignUser(ctx context.Context, deps Deps, actorUserID, issueID, userID int64) error {
133
+	q := issuesdb.New()
134
+	tx, err := deps.Pool.Begin(ctx)
135
+	if err != nil {
136
+		return err
137
+	}
138
+	committed := false
139
+	defer func() {
140
+		if !committed {
141
+			_ = tx.Rollback(ctx)
142
+		}
143
+	}()
144
+	if err := q.AssignUserToIssue(ctx, tx, issuesdb.AssignUserToIssueParams{
145
+		IssueID: issueID, UserID: userID,
146
+		AssignedByUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
147
+	}); err != nil {
148
+		return err
149
+	}
150
+	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
151
+		IssueID:     issueID,
152
+		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
153
+		Kind:        "assigned",
154
+		Meta:        []byte(`{"user_id":` + strconv.FormatInt(userID, 10) + `}`),
155
+	}); err != nil {
156
+		return err
157
+	}
158
+	if err := tx.Commit(ctx); err != nil {
159
+		return err
160
+	}
161
+	committed = true
162
+	return nil
163
+}
164
+
165
+// UnassignUser removes an assignee + emits an `unassigned` event.
166
+func UnassignUser(ctx context.Context, deps Deps, actorUserID, issueID, userID int64) error {
167
+	q := issuesdb.New()
168
+	tx, err := deps.Pool.Begin(ctx)
169
+	if err != nil {
170
+		return err
171
+	}
172
+	committed := false
173
+	defer func() {
174
+		if !committed {
175
+			_ = tx.Rollback(ctx)
176
+		}
177
+	}()
178
+	if err := q.UnassignUserFromIssue(ctx, tx, issuesdb.UnassignUserFromIssueParams{
179
+		IssueID: issueID, UserID: userID,
180
+	}); err != nil {
181
+		return err
182
+	}
183
+	if _, err := q.InsertIssueEvent(ctx, tx, issuesdb.InsertIssueEventParams{
184
+		IssueID:     issueID,
185
+		ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
186
+		Kind:        "unassigned",
187
+		Meta:        []byte(`{"user_id":` + strconv.FormatInt(userID, 10) + `}`),
188
+	}); err != nil {
189
+		return err
190
+	}
191
+	if err := tx.Commit(ctx); err != nil {
192
+		return err
193
+	}
194
+	committed = true
195
+	return nil
196
+}
internal/issues/references.goadded
@@ -0,0 +1,165 @@
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
+}
internal/issues/references_test.goadded
@@ -0,0 +1,83 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package issues
4
+
5
+import (
6
+	"reflect"
7
+	"sort"
8
+	"testing"
9
+)
10
+
11
+// findSameRepoRefs runs the regex used by the same-repo branch and
12
+// returns the parsed numbers. It mirrors the production loop modulo the
13
+// DB lookup, so the regex behavior can be unit-tested without a DB.
14
+func findSameRepoRefs(body string) []string {
15
+	stripped := reCrossRepoIssueRef.ReplaceAllString(body, " ")
16
+	out := []string{}
17
+	for _, m := range reSameRepoIssueRef.FindAllStringSubmatch(stripped, -1) {
18
+		out = append(out, m[1])
19
+	}
20
+	return out
21
+}
22
+
23
+func findCrossRepoRefs(body string) [][3]string {
24
+	out := [][3]string{}
25
+	for _, m := range reCrossRepoIssueRef.FindAllStringSubmatch(body, -1) {
26
+		out = append(out, [3]string{m[1], m[2], m[3]})
27
+	}
28
+	return out
29
+}
30
+
31
+func TestSameRepoRefRegex(t *testing.T) {
32
+	t.Parallel()
33
+	cases := []struct {
34
+		name string
35
+		body string
36
+		want []string
37
+	}{
38
+		{"plain", "fixes #1 and #42", []string{"1", "42"}},
39
+		{"sentence_start", "#7 should land", []string{"7"}},
40
+		{"no_word_prefix", "abc#1 isn't a ref", []string{}},
41
+		{"trailing_punct", "see #3, please", []string{"3"}},
42
+		{"line_start", "Done.\n#5 next", []string{"5"}},
43
+		// owner/repo#N must NOT also produce a same-repo hit on the #N
44
+		// portion; the cross-repo regex strips the whole token first.
45
+		{"cross_repo_excluded", "alice/repo#9 only", []string{}},
46
+	}
47
+	for _, c := range cases {
48
+		t.Run(c.name, func(t *testing.T) {
49
+			got := findSameRepoRefs(c.body)
50
+			sort.Strings(got)
51
+			sort.Strings(c.want)
52
+			if !reflect.DeepEqual(got, c.want) {
53
+				t.Errorf("body %q: got %v, want %v", c.body, got, c.want)
54
+			}
55
+		})
56
+	}
57
+}
58
+
59
+func TestCrossRepoRefRegex(t *testing.T) {
60
+	t.Parallel()
61
+	got := findCrossRepoRefs("see alice/proj#3 and bob/lib#42 for context, but not just #1")
62
+	want := [][3]string{
63
+		{"alice", "proj", "3"},
64
+		{"bob", "lib", "42"},
65
+	}
66
+	if !reflect.DeepEqual(got, want) {
67
+		t.Errorf("got %v, want %v", got, want)
68
+	}
69
+}
70
+
71
+func TestExtractMentions(t *testing.T) {
72
+	t.Parallel()
73
+	got := extractMentions("hi @alice and @bob, also @alice again — but not foo@example.com or a@b")
74
+	// `a@b` doesn't match (single-char user is fine but there's no
75
+	// leading word-boundary punctuation that's not a `@` itself, and
76
+	// our regex requires the `@` to be preceded by a non-word char or
77
+	// the start of input — `b a@b` would match `b` itself but `b@`
78
+	// fails the regex).
79
+	want := []string{"alice", "bob"}
80
+	if !reflect.DeepEqual(got, want) {
81
+		t.Errorf("got %v, want %v", got, want)
82
+	}
83
+}