forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.1 KiB
1# Milestone 3: issues, labels and milestones (contract between workers)
2
3The global rules from `docs/m1-interfaces.md` apply: latest dependencies, no unwrap in non-test code, clippy `-D warnings`, fmt, no inline styles, no JS, and every page goes through `load_repo` and `authorize`. Times are shown with `tz::format_in_tz(.., ctx.viewer_tz())` plus `views::relative_time`. Markdown goes through `render_markdown` and is embedded only via `Unescaped::new_unchecked`.
4
5## Permissions (forge-core `authz.rs`)
6Add `Action::Triage`. It's granted to the owner, instance admins, and collaborators with role Triage, Write, Maintain or Admin. Archived repos are read-only for issues too: every issue write action returns Forbidden when `repo.archived` (handled in the pages).
7
8| What | Who |
9|---|---|
10| View issues, labels, milestones | `Action::Read` (anonymous OK on public repos) |
11| Open an issue, comment | signed in **and** `Action::Read` |
12| Edit/delete own comment, edit own issue title/body, close/reopen own issue | the author (signed in, still has Read) |
13| Edit anyone's issue/comment, close/reopen any, set labels, assignees, milestone | `Action::Triage` |
14| Create/edit/delete labels and milestones | `Action::Triage` |
15
16## Migration `0004_issues.sql`
17```sql
18ALTER TABLE repos ADD COLUMN next_issue_number INTEGER NOT NULL DEFAULT 1;
19
20CREATE TABLE milestones (
21 id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
22 title TEXT NOT NULL, title_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
23 due_on TEXT, -- 'YYYY-MM-DD' or NULL
24 state TEXT NOT NULL DEFAULT 'open',-- 'open' | 'closed'
25 created_at TEXT NOT NULL, closed_at TEXT,
26 UNIQUE (repo_id, title_folded));
27
28CREATE TABLE issues (
29 id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
30 number INTEGER NOT NULL,
31 kind TEXT NOT NULL DEFAULT 'issue', -- 'issue' now; 'pull' in M4 (shared numbering)
32 title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '',
33 state TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed'
34 state_reason TEXT, -- NULL | 'completed' | 'not_planned'
35 author_id TEXT NOT NULL REFERENCES users(id),
36 milestone_id TEXT REFERENCES milestones(id) ON DELETE SET NULL,
37 comment_count INTEGER NOT NULL DEFAULT 0,
38 created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT,
39 UNIQUE (repo_id, number));
40CREATE INDEX issues_repo_state ON issues(repo_id, kind, state, number);
41
42CREATE TABLE comments (
43 id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
44 author_id TEXT NOT NULL REFERENCES users(id),
45 body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
46 edited INTEGER NOT NULL DEFAULT 0);
47CREATE INDEX comments_issue ON comments(issue_id, created_at);
48
49-- Edit history: the previous version is saved on every edit.
50CREATE TABLE edit_history (
51 id TEXT PRIMARY KEY, target_kind TEXT NOT NULL, -- 'issue' | 'comment'
52 target_id TEXT NOT NULL, old_title TEXT, old_body TEXT NOT NULL,
53 editor_id TEXT NOT NULL REFERENCES users(id), edited_at TEXT NOT NULL);
54CREATE INDEX edit_history_target ON edit_history(target_kind, target_id, edited_at);
55
56-- Timeline events (everything except comments).
57CREATE TABLE issue_events (
58 id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
59 actor_id TEXT REFERENCES users(id), -- NULL = system
60 kind TEXT NOT NULL, -- closed reopened labeled unlabeled assigned unassigned milestoned demilestoned renamed referenced
61 data TEXT NOT NULL DEFAULT '{}', -- JSON, e.g. {"label":"bug"} {"from":"a","to":"b"} {"commit":"<oid>"} {"reason":"completed"}
62 created_at TEXT NOT NULL);
63CREATE INDEX issue_events_issue ON issue_events(issue_id, created_at);
64
65CREATE TABLE labels (
66 id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
67 name TEXT NOT NULL, name_folded TEXT NOT NULL,
68 color TEXT NOT NULL, -- '#rrggbb' lowercase
69 description TEXT NOT NULL DEFAULT '',
70 UNIQUE (repo_id, name_folded));
71
72CREATE TABLE issue_labels (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
73 label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (issue_id, label_id));
74CREATE TABLE issue_assignees (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
75 user_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY (issue_id, user_id));
76```
77`Store::delete_repo` must delete every row above for the repo in its single transaction, explicitly, child tables first. Don't rely on the engine's FK cascade. `create_repo` seeds no labels. A default label set can come later.
78
79**Number allocation:** in one transaction, read `next_issue_number`, insert the issue with it, then increment the counter. Numbers are never reused.
80
81## forge-core model (`src/issues.rs`, re-exported)
82```rust
83pub enum IssueState { Open, Closed } // as_str / parse: "open" | "closed"
84pub enum StateReason { Completed, NotPlanned } // "completed" | "not_planned"
85pub struct Issue { pub id: Id, pub repo_id: Id, pub number: i64, pub title: String, pub body: String,
86 pub state: IssueState, pub state_reason: Option<StateReason>, pub author_id: Id,
87 pub milestone_id: Option<Id>, pub comment_count: i64,
88 pub created_at: String, pub updated_at: String, pub closed_at: Option<String> }
89pub struct Comment { pub id: Id, pub issue_id: Id, pub author_id: Id, pub body: String,
90 pub created_at: String, pub updated_at: String, pub edited: bool }
91pub struct IssueEvent { pub id: Id, pub issue_id: Id, pub actor_id: Option<Id>, pub kind: String,
92 pub data: serde_json::Value, pub created_at: String }
93pub struct Label { pub id: Id, pub repo_id: Id, pub name: String, pub color: String, pub description: String }
94pub struct Milestone { pub id: Id, pub repo_id: Id, pub title: String, pub description: String,
95 pub due_on: Option<String>, pub state: IssueState, pub created_at: String, pub closed_at: Option<String>,
96 pub open_issues: i64, pub closed_issues: i64 } // counts filled by list/get queries
97pub struct IssueFilter { pub state: Option<IssueState>, pub label: Option<String> /*name*/,
98 pub milestone: Option<String> /*title*/, pub author: Option<String> /*username*/,
99 pub assignee: Option<String> /*username*/, pub query: Option<String> /*substring of title, case-insensitive*/,
100 pub sort: IssueSort, pub page: u32 /*1-based*/, pub per_page: u32 }
101pub enum IssueSort { Newest, Oldest, RecentlyUpdated }
102pub struct IssueListItem { pub issue: Issue, pub author: String /*display username*/,
103 pub labels: Vec<Label>, pub assignees: Vec<String> }
104/// Validation
105pub const MAX_TITLE: usize = 256; pub const MAX_BODY: usize = 65536;
106pub fn validate_title(t: &str) -> Result<String /*trimmed*/>; // non-empty after trim, at most MAX_TITLE chars, no control chars
107pub fn validate_body(b: &str) -> Result<()>; // at most MAX_BODY bytes
108pub fn validate_label(name: &str, color: &str) -> Result<(String, String)>; // name 1..=50 chars trimmed, color #rgb/#rrggbb, normalized to lowercase #rrggbb
109```
110
111## Store (forge-core). New files `store/issues.rs` and `store/labels.rs`, each an `impl Store` block
112`store/issues.rs`:
113```rust
114pub async fn create_issue(&self, repo_id: &str, author_id: &str, title: &str, body: &str) -> Result<Issue>;
115pub async fn issue_by_number(&self, repo_id: &str, number: i64) -> Result<Option<Issue>>;
116pub async fn list_issues(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec<IssueListItem>, u64 /*total matching*/)>;
117pub async fn issue_counts(&self, repo_id: &str) -> Result<(u64 /*open*/, u64 /*closed*/)>;
118pub async fn edit_issue(&self, issue_id: &str, editor_id: &str, title: &str, body: &str) -> Result<()>; // saves history; a title change also records a 'renamed' event
119pub async fn set_issue_state(&self, issue_id: &str, actor_id: Option<&str>, state: IssueState, reason: Option<StateReason>, data: serde_json::Value) -> Result<bool /*changed*/>; // records a closed/reopened event with data+reason; no-op if already in that state
120pub async fn add_comment(&self, issue_id: &str, author_id: &str, body: &str) -> Result<Comment>; // bumps comment_count and updated_at
121pub async fn comment_by_id(&self, issue_id: &str, comment_id: &str) -> Result<Option<Comment>>;
122pub async fn edit_comment(&self, comment_id: &str, editor_id: &str, body: &str) -> Result<()>; // saves history, edited = 1
123pub async fn delete_comment(&self, comment_id: &str) -> Result<()>; // decrements comment_count
124pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>>;
125pub async fn list_events(&self, issue_id: &str) -> Result<Vec<IssueEvent>>;
126pub async fn add_event(&self, issue_id: &str, actor_id: Option<&str>, kind: &str, data: serde_json::Value) -> Result<()>;
127pub async fn usernames_by_ids(&self, ids: &[String]) -> Result<std::collections::HashMap<String, (String /*display*/, Option<String> /*avatar_hash*/)>>;
128```
129`store/labels.rs`:
130```rust
131pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>>; // by name
132pub async fn create_label(&self, repo_id: &str, name: &str, color: &str, description: &str) -> Result<Label>; // a duplicate (folded) is Error::Conflict, or the existing unique-violation mapping
133pub async fn update_label(&self, repo_id: &str, label_id: &str, name: &str, color: &str, description: &str) -> Result<()>;
134pub async fn delete_label(&self, repo_id: &str, label_id: &str) -> Result<()>;
135pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>>;
136pub async fn set_issue_labels(&self, issue_id: &str, actor_id: &str, label_ids: &[String]) -> Result<()>; // diff, then labeled/unlabeled events; ids must belong to the issue's repo
137pub async fn issue_assignees(&self, issue_id: &str) -> Result<Vec<(Id, String /*display*/)>>;
138pub async fn set_issue_assignees(&self, issue_id: &str, actor_id: &str, usernames: &[String]) -> Result<()>; // max 10; each must be a user who can Read the repo (else Error::Invalid); assigned/unassigned events
139pub async fn list_milestones(&self, repo_id: &str, state: Option<IssueState>) -> Result<Vec<Milestone>>;
140pub async fn milestone_by_id(&self, repo_id: &str, id: &str) -> Result<Option<Milestone>>;
141pub async fn create_milestone(&self, repo_id: &str, title: &str, description: &str, due_on: Option<&str>) -> Result<Milestone>;
142pub async fn update_milestone(&self, repo_id: &str, id: &str, title: &str, description: &str, due_on: Option<&str>, state: IssueState) -> Result<()>;
143pub async fn delete_milestone(&self, repo_id: &str, id: &str) -> Result<()>; // issues keep existing, milestone_id becomes NULL
144pub async fn set_issue_milestone(&self, issue_id: &str, actor_id: &str, milestone_id: Option<&str>) -> Result<()>; // milestoned/demilestoned events
145```
146Every list query must be a bounded, indexed SQL query. No N+1 queries: list_issues fetches labels and assignees for the whole page in one or two extra queries.
147
148## Reference parsing (forge-core `src/issue_refs.rs`)
149```rust
150/// "#12" occurrences not preceded by an alphanumeric char, `&` or `/` and not followed by an alphanumeric char; 1..=9 digits.
151pub fn find_issue_refs(text: &str) -> Vec<i64>; // deduped, in order
152/// Closing keywords (case-insensitive): close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved,
153/// followed by optional ':' and whitespace, then "#N". Only same-repo refs.
154pub fn find_closing_refs(text: &str) -> Vec<i64>;
155```
156
157## Markdown linking (forge-web `render/markdown.rs`)
158`LinkContext` gains `pub issues_base: Option<String>` (e.g. `/Tahinli/forge/issues`). When it's set, `#N` in text nodes (not code or links) becomes `<a href="{issues_base}/N" class="issue-ref">#N</a>`, and `@username` (same rules as `validate_username`'s charset, preceded by start or whitespace) becomes `<a href="/username" class="user-mention">@username</a>`. Both are produced as AST link nodes before sanitizing, never by string-pasting HTML. Existing callers pass `issues_base: None`, except the README/blob renderers, which should pass the repo's issues base.
159
160## Web routes (forge-web pages)
161- `GET /{o}/{r}/issues` (`issues_list.rs`): query `?state=open|closed|all` (default open), `label`, `milestone`, `author`, `assignee`, `q`, `sort=newest|oldest|updated`, `page`.
162 - 25 per page, with Open N / Closed N toggles.
163 - Rows: state icon, title link, labels as colored chips (class `label-chip`, color via an inline CSS custom property is NOT allowed by the CSP, so use a generated per-repo stylesheet `GET /{o}/{r}/labels.css` with `.label-<id> { background:#..; color:<contrast> }`; the contrast color is black or white by luminance), `#N opened <time> by <user>`, comment count, assignee avatars, milestone.
164 - Filters are plain links and a GET form.
165 - A "New issue" button shows for signed-in readers.
166- `GET/POST /{o}/{r}/issues/new` (`issues_list.rs`): title and body. Redirect to the new issue.
167- `GET /{o}/{r}/issues/{n}` (`issue_view.rs`):
168 - Header: title, `#N`, state badge (Open green, Closed purple/completed or grey/not planned), "<author> opened this <time> · N comments".
169 - **Timeline:** body as the first entry, then comments and events merged and sorted by created_at. Each comment has an avatar, author, time, "edited" marker, and an Edit/Delete form if allowed. Events are one-liners ("Tahinli added the bug label 3 hours ago", "closed this as completed in 9270d63" linking the commit).
170 - **Comment form**, with buttons "Comment" and "Close with comment" / "Reopen with comment" when allowed. A **Close** dropdown isn't possible without JS, so use two submit buttons: "Close as completed" and "Close as not planned".
171 - **Sidebar** (Triage holders get forms; others read-only lists):
172 - Labels: checkbox list of every repo label plus a Save button → `POST /{o}/{r}/issues/{n}/labels`, field `label` repeated.
173 - Assignees: text input with comma-separated usernames → `POST /{o}/{r}/issues/{n}/assignees`, field `assignees`.
174 - Milestone: `<select>` → `POST /{o}/{r}/issues/{n}/milestone`, field `milestone_id` (empty = none).
175- `POST /{o}/{r}/issues/{n}/comment` (fields `body`, `action` = `comment` | `close_completed` | `close_not_planned` | `reopen`). The comment is added if the body is non-empty, then the state change is applied if permitted.
176- `GET/POST /{o}/{r}/issues/{n}/edit`: title and body (author or Triage).
177- `GET/POST /{o}/{r}/issues/{n}/comments/{id}/edit`, and `POST .../comments/{id}/delete`: author or Triage.
178- The labels, assignees and milestone POST handlers live in `issue_meta.rs`.
179- `GET /{o}/{r}/labels` and `POST .../labels` (create), `POST .../labels/{id}` (update), `POST .../labels/{id}/delete`, `GET /{o}/{r}/labels.css` (`labels_page.rs`).
180- `GET /{o}/{r}/milestones?state=` and `POST .../milestones` (create), `GET/POST .../milestones/{id}/edit` (title, description, due_on, state), `POST .../milestones/{id}/delete`. List rows show a progress bar (`<progress>` element, no inline style) and open/closed counts linking to the filtered issue list (`milestones_page.rs`).
181- Every POST has CSRF. Every page and handler 404s when the repo is hidden or the issue is missing, and returns 403 when visible but not permitted.
182- Repo header tabs gain **Issues** with the open count, between Code and Commits. That's in `views/mod.rs` or `pages/repo.rs` (wherever the tabs are); the list-page worker owns this change.
183
184## Closing via commits (forge-web `internal.rs` post-receive, in background)
185After a push that updates the **default branch** (old→new, new is not zero):
186- List the new commits with `git rev-list --max-count=500 <new> ^<old>` (only `^<old>` if old isn't zero; for a new branch, use `--max-count=50`), and read their messages via `history::log`-style CLI calls with full OIDs only.
187- For each `find_closing_refs` hit, call `set_issue_state(Closed, Completed, actor = pusher, data {"commit": oid})` if the issue exists, is open and is kind 'issue'.
188- For every other `find_issue_refs` hit, add a `referenced` event with `{"commit": oid}` (deduped per issue and commit).
189- Errors are logged and never fail the push. Runs in the same `tokio::spawn` background path as maintenance (but not coalesced away: every push's ranges must be processed).