markdown · 8773 bytes Raw Blame History

Issues, comments, labels, milestones

S21 ships the issues subsystem: issues + threaded comments, labels, milestones, the polymorphic timeline event log, and the cross-reference index. The same tables are reused by S22 for pull requests via the issue_kind discriminator.

Schema (migration 0022)

repo_issue_counter   — per-repo monotonic numbering (issues + PRs share)
issues               — the row; kind discriminator covers PR rows in S22
issue_comments       — per-issue threaded comments
issue_assignees      — many-to-many issue ↔ user
labels               — repo-scoped labels
issue_labels         — many-to-many issue ↔ label
milestones           — repo-scoped milestones
issue_events         — generic timeline events (closed, locked, labeled, …)
issue_references     — cross-reference index (comment / issue / commit → issue)

The kind column on issues is in from day one ('issue' | 'pr') so S22's PR rows are first-class without a schema split. PR-specific fields land in a pull_requests table keyed on issues.id.

Per-repo numbering

repo_issue_counter (repo_id, next_number) is allocated lazily at first issue creation and on every repo create (S16's repos.Create calls EnsureRepoIssueCounter inside the create tx). Allocation is one UPDATE … RETURNING inside the same tx as the issue insert:

UPDATE repo_issue_counter
SET next_number = next_number + 1
WHERE repo_id = $1
RETURNING (next_number - 1)::bigint AS allocated;

Postgres serializes concurrent updaters on the row lock, so concurrent issues.Create calls on the same repo end up with distinct numbers. The race test (TestCreate_ConcurrentRaceForUniqueNumbers) fans out 8 goroutines and asserts no duplicates.

Default labels

Repo create seeds the GitHub-aligned set:

bug · d73a4a · Something isn't working
documentation · 0075ca · Improvements or additions to documentation
duplicate · cfd3d7 · This issue or pull request already exists
enhancement · a2eeef · New feature or request
good first issue · 7057ff · Good for newcomers
help wanted · 008672 · Extra attention is needed
invalid · e4e669 · This doesn't seem right
question · d876e3 · Further information is requested
wontfix · ffffff · This will not be worked on

Seeding runs inside the repo-create transaction via issues.SeedDefaultLabels(ctx, tx, repoID). It swallows unique-violations so re-runs are no-ops; backfill for repos created before S21 is a one-off SQL INSERT … ON CONFLICT DO NOTHING.

Routes

Route Auth
GET /{owner}/{repo}/issues?state=open|closed public
GET /{owner}/{repo}/issues/{number} public
GET /{owner}/{repo}/labels public
GET /{owner}/{repo}/milestones public
GET /{owner}/{repo}/issues/new RequireUser
POST /{owner}/{repo}/issues RequireUser
POST /{owner}/{repo}/issues/{number}/comments RequireUser
POST /{owner}/{repo}/issues/{number}/state RequireUser
POST /{owner}/{repo}/issues/{number}/lock RequireUser
POST /{owner}/{repo}/issues/{number}/labels RequireUser
POST /{owner}/{repo}/issues/{number}/milestone RequireUser
POST /{owner}/{repo}/issues/{number}/assignees RequireUser
POST /{owner}/{repo}/labels + /{id}/update + /{id}/delete RequireUser
POST /{owner}/{repo}/milestones + /{id}/update RequireUser
POST /{owner}/{repo}/milestones/{id}/state + /{id}/delete RequireUser

Public reads still pass through policy.Can(ActionIssueRead) so private repos return the existence-leak 404. RequireUser on writes gives anonymous browsers a /login redirect instead of the same 404. Issue creation and commenting follow GitHub's public-participation model: any logged-in user may open or comment on issues in a public repo, while private repos require read access.

Cross-reference indexing

internal/issues/references.go::insertReferencesFromBody parses #N and owner/repo#N from the comment / issue body inside the creating transaction. Each parsed reference produces:

  1. an issue_references row pointing source → target,
  2. a referenced event emitted on the target issue's timeline.

The cross-repo regex runs first; matched ranges are stripped before the same-repo regex sweeps so alice/proj#3 doesn't also trip the #3 matcher. Self-references skip silently. Unknown owners / repos / issue numbers are best-effort dropped — malformed references shouldn't fail the create.

Commit-message references (S14's push:process) call into the same helper with srcKind = "commit_message" and srcObjectID = push_event_id once that worker job ships.

Markdown + sanitization

Issue bodies and comments use the existing S17 markdown pipeline (internal/repos/markdown.RenderHTML): Goldmark for parsing, bluemonday's UGCPolicy for sanitization. Rendered HTML is cached on body_html_cached; md_pipeline_version lets the worker re-render on pipeline upgrades (post-MVP).

<script> tags are stripped — the sanitization test (TestCreate_RendersHTMLAndSanitizesScripts) injects one and asserts the cached HTML doesn't contain <script while still rendering **bold** as <strong>bold</strong>.

Locked issues

SetLock flips locked + emits a locked/unlocked event. Comments hit the locked gate inside AddComment: non-collaborators get ErrIssueLocked. The IsCollab flag is the caller's responsibility; the web handler resolves it via policy.HasRoleAtLeast(..., RoleTriage), so owners, direct collaborators, and team-derived triage+ roles can comment through a lock.

Rate limit

Comment creation hits the existing throttle bucket (scope=issue_comment, identifier=user:N) at 20 per hour. Issue creation isn't independently rate-limited; the broader repo_create bucket and account anti-abuse measures cover the spam-floor case for now.

Timeline events

issue_events is intentionally polymorphic: kind is a free string, meta is a jsonb blob, and ref_target_id is a denormalized pointer for events that target another issue (referenced, marked_as_duplicate_of, etc.). Known kinds today:

closed, reopened, locked, unlocked,
labeled, unlabeled,
assigned, unassigned,
milestoned, demilestoned,
referenced

meta always carries an empty '{}'::jsonb when no metadata is applicable — Go's nil byte slice would bind as SQL NULL and trip the NOT NULL constraint, so the orchestrator passes []byte("{}") explicitly.

Tests

File What it covers
internal/issues/references_test.go regex behaviour for #N and owner/repo#N
internal/issues/issues_test.go::Sequential… per-repo number allocation
internal/issues/issues_test.go::ConcurrentRace… 8-goroutine fan-out, no duplicates
internal/issues/issues_test.go::Sanitizes… XSS + markdown render
internal/issues/issues_test.go::LockedRejects… locked-issue gate for non-collab
internal/issues/issues_test.go::EmitsEvent close → reopen event ordering
internal/issues/issues_test.go::CrossRef… #N in body emits referenced on target

Deferred

  • Mention notificationsextractMentions is in place, but the notifications surface (S29-area) hasn't shipped, so parsed @username tokens currently don't emit anything.
  • Commit-message references — the helper accepts srcKind = "commit_message" already; wiring lives with S14's worker job (push:process).
  • Cross-org references — the cross-repo regex resolves only user-owned repos; org owners arrive in S31.
  • Required signing / status checks on comments — out of scope.
  • Reactions, edit history, comment edits across users — post-MVP UI polish (S30+).
  • Server-side label / assignee / milestone filtering — current list query filters by state + kind only; richer filtering is the perf-pass item in S36.
  • Issue templates / forms — post-MVP.
  • Reactions — post-MVP (own table; doesn't touch the schema here).
View source
1 # Issues, comments, labels, milestones
2
3 S21 ships the issues subsystem: issues + threaded comments, labels,
4 milestones, the polymorphic timeline event log, and the cross-reference
5 index. The same tables are reused by S22 for pull requests via the
6 `issue_kind` discriminator.
7
8 ## Schema (migration 0022)
9
10 ```
11 repo_issue_counter — per-repo monotonic numbering (issues + PRs share)
12 issues — the row; kind discriminator covers PR rows in S22
13 issue_comments — per-issue threaded comments
14 issue_assignees — many-to-many issue ↔ user
15 labels — repo-scoped labels
16 issue_labels — many-to-many issue ↔ label
17 milestones — repo-scoped milestones
18 issue_events — generic timeline events (closed, locked, labeled, …)
19 issue_references — cross-reference index (comment / issue / commit → issue)
20 ```
21
22 The `kind` column on `issues` is in from day one (`'issue' | 'pr'`) so
23 S22's PR rows are first-class without a schema split. PR-specific
24 fields land in a `pull_requests` table keyed on `issues.id`.
25
26 ### Per-repo numbering
27
28 `repo_issue_counter (repo_id, next_number)` is allocated lazily at first
29 issue creation and on every repo create (S16's `repos.Create` calls
30 `EnsureRepoIssueCounter` inside the create tx). Allocation is one
31 `UPDATE … RETURNING` inside the same tx as the issue insert:
32
33 ```sql
34 UPDATE repo_issue_counter
35 SET next_number = next_number + 1
36 WHERE repo_id = $1
37 RETURNING (next_number - 1)::bigint AS allocated;
38 ```
39
40 Postgres serializes concurrent updaters on the row lock, so concurrent
41 `issues.Create` calls on the same repo end up with distinct numbers.
42 The race test (`TestCreate_ConcurrentRaceForUniqueNumbers`) fans out 8
43 goroutines and asserts no duplicates.
44
45 ### Default labels
46
47 Repo create seeds the GitHub-aligned set:
48
49 ```
50 bug · d73a4a · Something isn't working
51 documentation · 0075ca · Improvements or additions to documentation
52 duplicate · cfd3d7 · This issue or pull request already exists
53 enhancement · a2eeef · New feature or request
54 good first issue · 7057ff · Good for newcomers
55 help wanted · 008672 · Extra attention is needed
56 invalid · e4e669 · This doesn't seem right
57 question · d876e3 · Further information is requested
58 wontfix · ffffff · This will not be worked on
59 ```
60
61 Seeding runs inside the repo-create transaction via
62 `issues.SeedDefaultLabels(ctx, tx, repoID)`. It swallows unique-violations
63 so re-runs are no-ops; backfill for repos created before S21 is a one-off
64 SQL `INSERT … ON CONFLICT DO NOTHING`.
65
66 ## Routes
67
68 | Route | Auth |
69 | -------------------------------------------------------------- | ------------- |
70 | `GET /{owner}/{repo}/issues?state=open\|closed` | public |
71 | `GET /{owner}/{repo}/issues/{number}` | public |
72 | `GET /{owner}/{repo}/labels` | public |
73 | `GET /{owner}/{repo}/milestones` | public |
74 | `GET /{owner}/{repo}/issues/new` | RequireUser |
75 | `POST /{owner}/{repo}/issues` | RequireUser |
76 | `POST /{owner}/{repo}/issues/{number}/comments` | RequireUser |
77 | `POST /{owner}/{repo}/issues/{number}/state` | RequireUser |
78 | `POST /{owner}/{repo}/issues/{number}/lock` | RequireUser |
79 | `POST /{owner}/{repo}/issues/{number}/labels` | RequireUser |
80 | `POST /{owner}/{repo}/issues/{number}/milestone` | RequireUser |
81 | `POST /{owner}/{repo}/issues/{number}/assignees` | RequireUser |
82 | `POST /{owner}/{repo}/labels` + `/{id}/update` + `/{id}/delete` | RequireUser |
83 | `POST /{owner}/{repo}/milestones` + `/{id}/update` | RequireUser |
84 | `POST /{owner}/{repo}/milestones/{id}/state` + `/{id}/delete` | RequireUser |
85
86 Public reads still pass through `policy.Can(ActionIssueRead)` so
87 private repos return the existence-leak 404. RequireUser on writes
88 gives anonymous browsers a `/login` redirect instead of the same 404.
89 Issue creation and commenting follow GitHub's public-participation
90 model: any logged-in user may open or comment on issues in a public
91 repo, while private repos require `read` access.
92
93 ## Cross-reference indexing
94
95 `internal/issues/references.go::insertReferencesFromBody` parses
96 `#N` and `owner/repo#N` from the comment / issue body inside the
97 creating transaction. Each parsed reference produces:
98
99 1. an `issue_references` row pointing source → target,
100 2. a `referenced` event emitted on the *target* issue's timeline.
101
102 The cross-repo regex runs first; matched ranges are stripped before
103 the same-repo regex sweeps so `alice/proj#3` doesn't also trip the
104 `#3` matcher. Self-references skip silently. Unknown owners / repos /
105 issue numbers are best-effort dropped — malformed references shouldn't
106 fail the create.
107
108 Commit-message references (S14's `push:process`) call into the same
109 helper with `srcKind = "commit_message"` and `srcObjectID = push_event_id`
110 once that worker job ships.
111
112 ## Markdown + sanitization
113
114 Issue bodies and comments use the existing S17 markdown pipeline
115 (`internal/repos/markdown.RenderHTML`): Goldmark for parsing,
116 bluemonday's `UGCPolicy` for sanitization. Rendered HTML is cached on
117 `body_html_cached`; `md_pipeline_version` lets the worker re-render on
118 pipeline upgrades (post-MVP).
119
120 `<script>` tags are stripped — the sanitization test
121 (`TestCreate_RendersHTMLAndSanitizesScripts`) injects one and asserts
122 the cached HTML doesn't contain `<script` while still rendering
123 `**bold**` as `<strong>bold</strong>`.
124
125 ## Locked issues
126
127 `SetLock` flips `locked` + emits a `locked`/`unlocked` event. Comments
128 hit the locked gate inside `AddComment`: non-collaborators get
129 `ErrIssueLocked`. The `IsCollab` flag is the caller's responsibility;
130 the web handler resolves it via `policy.HasRoleAtLeast(..., RoleTriage)`,
131 so owners, direct collaborators, and team-derived triage+ roles can
132 comment through a lock.
133
134 ## Rate limit
135
136 Comment creation hits the existing throttle bucket
137 (`scope=issue_comment`, `identifier=user:N`) at **20 per hour**.
138 Issue creation isn't independently rate-limited; the broader
139 `repo_create` bucket and account anti-abuse measures cover the
140 spam-floor case for now.
141
142 ## Timeline events
143
144 `issue_events` is intentionally polymorphic: `kind` is a free string,
145 `meta` is a `jsonb` blob, and `ref_target_id` is a denormalized
146 pointer for events that target another issue (`referenced`,
147 `marked_as_duplicate_of`, etc.). Known kinds today:
148
149 ```
150 closed, reopened, locked, unlocked,
151 labeled, unlabeled,
152 assigned, unassigned,
153 milestoned, demilestoned,
154 referenced
155 ```
156
157 `meta` always carries an empty `'{}'::jsonb` when no metadata is
158 applicable — Go's `nil` byte slice would bind as SQL NULL and trip
159 the NOT NULL constraint, so the orchestrator passes `[]byte("{}")`
160 explicitly.
161
162 ## Tests
163
164 | File | What it covers |
165 | ------------------------------------------------- | -------------------------------------- |
166 | `internal/issues/references_test.go` | regex behaviour for #N and owner/repo#N |
167 | `internal/issues/issues_test.go::Sequential…` | per-repo number allocation |
168 | `internal/issues/issues_test.go::ConcurrentRace…` | 8-goroutine fan-out, no duplicates |
169 | `internal/issues/issues_test.go::Sanitizes…` | XSS + markdown render |
170 | `internal/issues/issues_test.go::LockedRejects…` | locked-issue gate for non-collab |
171 | `internal/issues/issues_test.go::EmitsEvent` | close → reopen event ordering |
172 | `internal/issues/issues_test.go::CrossRef…` | #N in body emits `referenced` on target |
173
174 ## Deferred
175
176 - **Mention notifications** — `extractMentions` is in place, but the
177 notifications surface (S29-area) hasn't shipped, so parsed `@username`
178 tokens currently don't emit anything.
179 - **Commit-message references** — the helper accepts
180 `srcKind = "commit_message"` already; wiring lives with S14's worker
181 job (push:process).
182 - **Cross-org references** — the cross-repo regex resolves only
183 user-owned repos; org owners arrive in S31.
184 - **Required signing / status checks on comments** — out of scope.
185 - **Reactions, edit history, comment edits across users** — post-MVP UI
186 polish (S30+).
187 - **Server-side label / assignee / milestone filtering** — current
188 list query filters by state + kind only; richer filtering is the
189 perf-pass item in S36.
190 - **Issue templates / forms** — post-MVP.
191 - **Reactions** — post-MVP (own table; doesn't touch the schema here).