Tahinli/forge
publicMilestone 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
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":"<oid>"} {"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)
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<StateReason>, pub author_id: Id,
pub milestone_id: Option<Id>, pub comment_count: i64,
pub created_at: String, pub updated_at: String, pub closed_at: Option<String> }
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<Id>, 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<String>, pub state: IssueState, pub created_at: String, pub closed_at: Option<String>,
pub open_issues: i64, pub closed_issues: i64 } // counts filled by list/get queries
pub struct IssueFilter { pub state: Option<IssueState>, pub label: Option<String> /*name*/,
pub milestone: Option<String> /*title*/, pub author: Option<String> /*username*/,
pub assignee: Option<String> /*username*/, pub query: Option<String> /*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<Label>, pub assignees: Vec<String> }
/// Validation
pub const MAX_TITLE: usize = 256; pub const MAX_BODY: usize = 65536;
pub fn validate_title(t: &str) -> Result<String /*trimmed*/>; // non-empty after trim, at most MAX_TITLE chars, no control chars
pub fn validate_body(b: &str) -> Result<()>; // at most MAX_BODY bytes
pub fn validate_label(name: &str, color: &str) -> Result<(String, String)>; // name 1..=50 chars trimmed, color #rgb/#rrggbb, normalized to lowercase #rrggbb
Store (forge-core). New files store/issues.rs and store/labels.rs, each an impl Store block
store/issues.rs:
pub async fn create_issue(&self, repo_id: &str, author_id: &str, title: &str, body: &str) -> Result<Issue>;
pub async fn issue_by_number(&self, repo_id: &str, number: i64) -> Result<Option<Issue>>;
pub async fn list_issues(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec<IssueListItem>, u64 /*total matching*/)>;
pub async fn issue_counts(&self, repo_id: &str) -> Result<(u64 /*open*/, u64 /*closed*/)>;
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
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
pub async fn add_comment(&self, issue_id: &str, author_id: &str, body: &str) -> Result<Comment>; // bumps comment_count and updated_at
pub async fn comment_by_id(&self, issue_id: &str, comment_id: &str) -> Result<Option<Comment>>;
pub async fn edit_comment(&self, comment_id: &str, editor_id: &str, body: &str) -> Result<()>; // saves history, edited = 1
pub async fn delete_comment(&self, comment_id: &str) -> Result<()>; // decrements comment_count
pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>>;
pub async fn list_events(&self, issue_id: &str) -> Result<Vec<IssueEvent>>;
pub async fn add_event(&self, issue_id: &str, actor_id: Option<&str>, kind: &str, data: serde_json::Value) -> Result<()>;
pub async fn usernames_by_ids(&self, ids: &[String]) -> Result<std::collections::HashMap<String, (String /*display*/, Option<String> /*avatar_hash*/)>>;
store/labels.rs:
pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>>; // by name
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
pub async fn update_label(&self, repo_id: &str, label_id: &str, name: &str, color: &str, description: &str) -> Result<()>;
pub async fn delete_label(&self, repo_id: &str, label_id: &str) -> Result<()>;
pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>>;
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
pub async fn issue_assignees(&self, issue_id: &str) -> Result<Vec<(Id, String /*display*/)>>;
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
pub async fn list_milestones(&self, repo_id: &str, state: Option<IssueState>) -> Result<Vec<Milestone>>;
pub async fn milestone_by_id(&self, repo_id: &str, id: &str) -> Result<Option<Milestone>>;
pub async fn create_milestone(&self, repo_id: &str, title: &str, description: &str, due_on: Option<&str>) -> Result<Milestone>;
pub async fn update_milestone(&self, repo_id: &str, id: &str, title: &str, description: &str, due_on: Option<&str>, state: IssueState) -> Result<()>;
pub async fn delete_milestone(&self, repo_id: &str, id: &str) -> Result<()>; // issues keep existing, milestone_id becomes NULL
pub async fn set_issue_milestone(&self, issue_id: &str, actor_id: &str, milestone_id: Option<&str>) -> Result<()>; // milestoned/demilestoned events
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.
Reference parsing (forge-core src/issue_refs.rs)
/// "#12" occurrences not preceded by an alphanumeric char, `&` or `/` and not followed by an alphanumeric char; 1..=9 digits.
pub fn find_issue_refs(text: &str) -> Vec<i64>; // deduped, in order
/// Closing keywords (case-insensitive): close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved,
/// followed by optional ':' and whitespace, then "#N". Only same-repo refs.
pub fn find_closing_refs(text: &str) -> Vec<i64>;
Markdown linking (forge-web render/markdown.rs)
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.
Web routes (forge-web pages)
GET /{o}/{r}/issues(issues_list.rs): query?state=open|closed|all(default open),label,milestone,author,assignee,q,sort=newest|oldest|updated,page.- 25 per page, with Open N / Closed N toggles.
- 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 stylesheetGET /{o}/{r}/labels.csswith.label-<id> { background:#..; color:<contrast> }; the contrast color is black or white by luminance),#N opened <time> by <user>, comment count, assignee avatars, milestone. - Filters are plain links and a GET form.
- A "New issue" button shows for signed-in readers.
GET/POST /{o}/{r}/issues/new(issues_list.rs): title and body. Redirect to the new issue.GET /{o}/{r}/issues/{n}(issue_view.rs):- Header: title,
#N, state badge (Open green, Closed purple/completed or grey/not planned), " opened this · N comments". - 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).
- 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".
- Sidebar (Triage holders get forms; others read-only lists):
- Labels: checkbox list of every repo label plus a Save button →
POST /{o}/{r}/issues/{n}/labels, fieldlabelrepeated. - Assignees: text input with comma-separated usernames →
POST /{o}/{r}/issues/{n}/assignees, fieldassignees. - Milestone:
<select>→POST /{o}/{r}/issues/{n}/milestone, fieldmilestone_id(empty = none).
- Labels: checkbox list of every repo label plus a Save button →
- Header: title,
POST /{o}/{r}/issues/{n}/comment(fieldsbody,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.GET/POST /{o}/{r}/issues/{n}/edit: title and body (author or Triage).GET/POST /{o}/{r}/issues/{n}/comments/{id}/edit, andPOST .../comments/{id}/delete: author or Triage.- The labels, assignees and milestone POST handlers live in
issue_meta.rs. GET /{o}/{r}/labelsandPOST .../labels(create),POST .../labels/{id}(update),POST .../labels/{id}/delete,GET /{o}/{r}/labels.css(labels_page.rs).GET /{o}/{r}/milestones?state=andPOST .../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).- 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.
- Repo header tabs gain Issues with the open count, between Code and Commits. That's in
views/mod.rsorpages/repo.rs(wherever the tabs are); the list-page worker owns this change.
Closing via commits (forge-web internal.rs post-receive, in background)
After a push that updates the default branch (old→new, new is not zero):
- 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 viahistory::log-style CLI calls with full OIDs only. - For each
find_closing_refshit, callset_issue_state(Closed, Completed, actor = pusher, data {"commit": oid})if the issue exists, is open and is kind 'issue'. - For every other
find_issue_refshit, add areferencedevent with{"commit": oid}(deduped per issue and commit). - Errors are logged and never fail the push. Runs in the same
tokio::spawnbackground path as maintenance (but not coalesced away: every push's ranges must be processed).