Tahinli/forge
public0 watching
★ 0
| 1 | # Milestone 3: issues, labels and milestones (contract between workers) |
| 2 | |
| 3 | The 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`) |
| 6 | Add `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 |
| 18 | ALTER TABLE repos ADD COLUMN next_issue_number INTEGER NOT NULL DEFAULT 1; |
| 19 | |
| 20 | CREATE 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 | |
| 28 | CREATE 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)); |
| 40 | CREATE INDEX issues_repo_state ON issues(repo_id, kind, state, number); |
| 41 | |
| 42 | CREATE 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); |
| 47 | CREATE INDEX comments_issue ON comments(issue_id, created_at); |
| 48 | |
| 49 | -- Edit history: the previous version is saved on every edit. |
| 50 | CREATE 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); |
| 54 | CREATE INDEX edit_history_target ON edit_history(target_kind, target_id, edited_at); |
| 55 | |
| 56 | -- Timeline events (everything except comments). |
| 57 | CREATE 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); |
| 63 | CREATE INDEX issue_events_issue ON issue_events(issue_id, created_at); |
| 64 | |
| 65 | CREATE 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 | |
| 72 | CREATE 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)); |
| 74 | CREATE 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 |
| 83 | pub enum IssueState { Open, Closed } // as_str / parse: "open" | "closed" |
| 84 | pub enum StateReason { Completed, NotPlanned } // "completed" | "not_planned" |
| 85 | pub 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> } |
| 89 | pub 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 } |
| 91 | pub 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 } |
| 93 | pub struct Label { pub id: Id, pub repo_id: Id, pub name: String, pub color: String, pub description: String } |
| 94 | pub 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 |
| 97 | pub 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 } |
| 101 | pub enum IssueSort { Newest, Oldest, RecentlyUpdated } |
| 102 | pub struct IssueListItem { pub issue: Issue, pub author: String /*display username*/, |
| 103 | pub labels: Vec<Label>, pub assignees: Vec<String> } |
| 104 | /// Validation |
| 105 | pub const MAX_TITLE: usize = 256; pub const MAX_BODY: usize = 65536; |
| 106 | pub fn validate_title(t: &str) -> Result<String /*trimmed*/>; // non-empty after trim, at most MAX_TITLE chars, no control chars |
| 107 | pub fn validate_body(b: &str) -> Result<()>; // at most MAX_BODY bytes |
| 108 | pub 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 |
| 114 | pub async fn create_issue(&self, repo_id: &str, author_id: &str, title: &str, body: &str) -> Result<Issue>; |
| 115 | pub async fn issue_by_number(&self, repo_id: &str, number: i64) -> Result<Option<Issue>>; |
| 116 | pub async fn list_issues(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec<IssueListItem>, u64 /*total matching*/)>; |
| 117 | pub async fn issue_counts(&self, repo_id: &str) -> Result<(u64 /*open*/, u64 /*closed*/)>; |
| 118 | pub 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 |
| 119 | pub 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 |
| 120 | pub async fn add_comment(&self, issue_id: &str, author_id: &str, body: &str) -> Result<Comment>; // bumps comment_count and updated_at |
| 121 | pub async fn comment_by_id(&self, issue_id: &str, comment_id: &str) -> Result<Option<Comment>>; |
| 122 | pub async fn edit_comment(&self, comment_id: &str, editor_id: &str, body: &str) -> Result<()>; // saves history, edited = 1 |
| 123 | pub async fn delete_comment(&self, comment_id: &str) -> Result<()>; // decrements comment_count |
| 124 | pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>>; |
| 125 | pub async fn list_events(&self, issue_id: &str) -> Result<Vec<IssueEvent>>; |
| 126 | pub async fn add_event(&self, issue_id: &str, actor_id: Option<&str>, kind: &str, data: serde_json::Value) -> Result<()>; |
| 127 | pub 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 |
| 131 | pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>>; // by name |
| 132 | pub 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 |
| 133 | pub async fn update_label(&self, repo_id: &str, label_id: &str, name: &str, color: &str, description: &str) -> Result<()>; |
| 134 | pub async fn delete_label(&self, repo_id: &str, label_id: &str) -> Result<()>; |
| 135 | pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>>; |
| 136 | pub 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 |
| 137 | pub async fn issue_assignees(&self, issue_id: &str) -> Result<Vec<(Id, String /*display*/)>>; |
| 138 | pub 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 |
| 139 | pub async fn list_milestones(&self, repo_id: &str, state: Option<IssueState>) -> Result<Vec<Milestone>>; |
| 140 | pub async fn milestone_by_id(&self, repo_id: &str, id: &str) -> Result<Option<Milestone>>; |
| 141 | pub async fn create_milestone(&self, repo_id: &str, title: &str, description: &str, due_on: Option<&str>) -> Result<Milestone>; |
| 142 | pub async fn update_milestone(&self, repo_id: &str, id: &str, title: &str, description: &str, due_on: Option<&str>, state: IssueState) -> Result<()>; |
| 143 | pub async fn delete_milestone(&self, repo_id: &str, id: &str) -> Result<()>; // issues keep existing, milestone_id becomes NULL |
| 144 | pub async fn set_issue_milestone(&self, issue_id: &str, actor_id: &str, milestone_id: Option<&str>) -> Result<()>; // milestoned/demilestoned events |
| 145 | ``` |
| 146 | Every 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. |
| 151 | pub 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. |
| 154 | pub 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) |
| 185 | After 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). |