# Milestone 3: issues, labels and milestones (contract between workers) 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`. ## Permissions (forge-core `authz.rs`) 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). | What | Who | |---|---| | View issues, labels, milestones | `Action::Read` (anonymous OK on public repos) | | Open an issue, comment | signed in **and** `Action::Read` | | Edit/delete own comment, edit own issue title/body, close/reopen own issue | the author (signed in, still has Read) | | Edit anyone's issue/comment, close/reopen any, set labels, assignees, milestone | `Action::Triage` | | Create/edit/delete labels and milestones | `Action::Triage` | ## Migration `0004_issues.sql` ```sql ALTER TABLE repos ADD COLUMN next_issue_number INTEGER NOT NULL DEFAULT 1; CREATE TABLE milestones ( id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, title TEXT NOT NULL, title_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', due_on TEXT, -- 'YYYY-MM-DD' or NULL state TEXT NOT NULL DEFAULT 'open',-- 'open' | 'closed' created_at TEXT NOT NULL, closed_at TEXT, UNIQUE (repo_id, title_folded)); CREATE TABLE issues ( id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, number INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT 'issue', -- 'issue' now; 'pull' in M4 (shared numbering) title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed' state_reason TEXT, -- NULL | 'completed' | 'not_planned' author_id TEXT NOT NULL REFERENCES users(id), milestone_id TEXT REFERENCES milestones(id) ON DELETE SET NULL, comment_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, UNIQUE (repo_id, number)); CREATE INDEX issues_repo_state ON issues(repo_id, kind, state, number); CREATE TABLE comments ( id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, author_id TEXT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, edited INTEGER NOT NULL DEFAULT 0); CREATE INDEX comments_issue ON comments(issue_id, created_at); -- Edit history: the previous version is saved on every edit. CREATE TABLE edit_history ( id TEXT PRIMARY KEY, target_kind TEXT NOT NULL, -- 'issue' | 'comment' target_id TEXT NOT NULL, old_title TEXT, old_body TEXT NOT NULL, editor_id TEXT NOT NULL REFERENCES users(id), edited_at TEXT NOT NULL); CREATE INDEX edit_history_target ON edit_history(target_kind, target_id, edited_at); -- Timeline events (everything except comments). CREATE TABLE issue_events ( id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, actor_id TEXT REFERENCES users(id), -- NULL = system kind TEXT NOT NULL, -- closed reopened labeled unlabeled assigned unassigned milestoned demilestoned renamed referenced data TEXT NOT NULL DEFAULT '{}', -- JSON, e.g. {"label":"bug"} {"from":"a","to":"b"} {"commit":""} {"reason":"completed"} created_at TEXT NOT NULL); CREATE INDEX issue_events_issue ON issue_events(issue_id, created_at); CREATE TABLE labels ( id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, name TEXT NOT NULL, name_folded TEXT NOT NULL, color TEXT NOT NULL, -- '#rrggbb' lowercase description TEXT NOT NULL DEFAULT '', UNIQUE (repo_id, name_folded)); CREATE TABLE issue_labels (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (issue_id, label_id)); CREATE TABLE issue_assignees (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY (issue_id, user_id)); ``` `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. **Number allocation:** in one transaction, read `next_issue_number`, insert the issue with it, then increment the counter. Numbers are never reused. ## forge-core model (`src/issues.rs`, re-exported) ```rust pub enum IssueState { Open, Closed } // as_str / parse: "open" | "closed" pub enum StateReason { Completed, NotPlanned } // "completed" | "not_planned" pub struct Issue { pub id: Id, pub repo_id: Id, pub number: i64, pub title: String, pub body: String, pub state: IssueState, pub state_reason: Option, pub author_id: Id, pub milestone_id: Option, pub comment_count: i64, pub created_at: String, pub updated_at: String, pub closed_at: Option } pub struct Comment { pub id: Id, pub issue_id: Id, pub author_id: Id, pub body: String, pub created_at: String, pub updated_at: String, pub edited: bool } pub struct IssueEvent { pub id: Id, pub issue_id: Id, pub actor_id: Option, pub kind: String, pub data: serde_json::Value, pub created_at: String } pub struct Label { pub id: Id, pub repo_id: Id, pub name: String, pub color: String, pub description: String } pub struct Milestone { pub id: Id, pub repo_id: Id, pub title: String, pub description: String, pub due_on: Option, pub state: IssueState, pub created_at: String, pub closed_at: Option, pub open_issues: i64, pub closed_issues: i64 } // counts filled by list/get queries pub struct IssueFilter { pub state: Option, pub label: Option /*name*/, pub milestone: Option /*title*/, pub author: Option /*username*/, pub assignee: Option /*username*/, pub query: Option /*substring of title, case-insensitive*/, pub sort: IssueSort, pub page: u32 /*1-based*/, pub per_page: u32 } pub enum IssueSort { Newest, Oldest, RecentlyUpdated } pub struct IssueListItem { pub issue: Issue, pub author: String /*display username*/, pub labels: Vec