forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.1 KiB

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

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 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.
    • 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, field label repeated.
      • Assignees: text input with comma-separated usernames → POST /{o}/{r}/issues/{n}/assignees, field assignees.
      • Milestone: <select> → POST /{o}/{r}/issues/{n}/milestone, field milestone_id (empty = none).
  • 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.
  • GET/POST /{o}/{r}/issues/{n}/edit: title and body (author or Triage).
  • GET/POST /{o}/{r}/issues/{n}/comments/{id}/edit, and POST .../comments/{id}/delete: author or Triage.
  • The labels, assignees and milestone POST handlers live in issue_meta.rs.
  • GET /{o}/{r}/labels and POST .../labels (create), POST .../labels/{id} (update), POST .../labels/{id}/delete, GET /{o}/{r}/labels.css (labels_page.rs).
  • 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).
  • 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.rs or pages/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 via history::log-style CLI calls with full OIDs only.
  • 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'.
  • For every other find_issue_refs hit, add a referenced event with {"commit": oid} (deduped per issue and commit).
  • 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).