Tahinli/forge
public0 watching
★ 0
Milestone 4b: pull request reviews and line comments (contract between workers)
The global rules from docs/m1-interfaces.md, docs/m3-interfaces.md and docs/m4-interfaces.md apply:
- No JS and no inline styles.
- Full hex OIDs only, validated paths, and user values after
--end-of-options. - No unwrap in non-test code.
- Tests use
tempfile::TempDir. - Never put cargo target dirs or big files in /tmp.
Concepts
- A review belongs to a PR (
issue_id) and has an author. Its state is one of:pending: a draft, visible only to its author, with at most one per author per PR.commented,approvedorchanges_requested: submitted.dismissed: a Write holder dismissed an approval or a change request, giving a reason.
- A review comment is a comment on one line of the PR diff, identified by:
path: the new path; for a deleted file, the old path;side:newfor added and context lines,oldfor deleted lines;line: the line number on that side;commit_oid: the head OID when it was written. It belongs to a review. A comment whosereply_tois set is a reply in the root comment's thread; replies are submitted immediately, through an auto-createdcommentedreview without a body. A root carriesresolved.
- Outdated: after the head moves, a root comment is outdated when the blob at
pathdiffers between itscommit_oidand the new head (this is coarse on purpose; line remapping comes later). Outdated threads still show in the conversation, with an "Outdated" badge, and no longer show on the Files tab. diff_hunkstores the last up to 4 diff lines ending at the commented line, as plain text with a+,-or space prefix, captured at creation. The conversation shows it as the thread's context without running git again.- PR authors can't approve or request changes on their own PR; they can only comment. Anyone who can Read the base repo and is signed in can review. A review by a Read user counts in the summary, but merge rules don't enforce anything yet.
Schema (migration 0008_reviews.sql)
CREATE TABLE pull_reviews (
id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
state TEXT NOT NULL, -- pending | commented | approved | changes_requested | dismissed
body TEXT NOT NULL DEFAULT '',
commit_oid TEXT NOT NULL, -- head at submit (or at creation while pending)
dismissed_reason TEXT,
created_at TEXT NOT NULL, submitted_at TEXT);
CREATE INDEX pull_reviews_issue ON pull_reviews(issue_id, submitted_at);
CREATE TABLE review_comments (
id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
review_id TEXT NOT NULL REFERENCES pull_reviews(id) ON DELETE CASCADE,
author_id TEXT NOT NULL REFERENCES users(id),
path TEXT NOT NULL, side TEXT NOT NULL, line INTEGER NOT NULL,
commit_oid TEXT NOT NULL, diff_hunk TEXT NOT NULL DEFAULT '',
reply_to TEXT REFERENCES review_comments(id) ON DELETE CASCADE,
body TEXT NOT NULL, outdated INTEGER NOT NULL DEFAULT 0, resolved INTEGER NOT NULL DEFAULT 0,
resolved_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE INDEX review_comments_issue ON review_comments(issue_id, path, created_at);
Issue events gain the kinds review_dismissed (data {"review","reason"}), thread_resolved and thread_unresolved.
forge-core (src/reviews.rs model plus store/reviews.rs, an impl Store block)
pub enum ReviewState { Pending, Commented, Approved, ChangesRequested, Dismissed } // as_str / FromStr
pub enum Side { Old, New } // "old" | "new"
pub struct Review { pub id: Id, pub issue_id: Id, pub author_id: Id, pub author_name: String, pub state: ReviewState,
pub body: String, pub commit_oid: String, pub dismissed_reason: Option<String>, pub created_at: String, pub submitted_at: Option<String> }
pub struct ReviewComment { pub id: Id, pub review_id: Id, pub author_id: Id, pub author_name: String, pub path: String, pub side: Side,
pub line: u32, pub commit_oid: String, pub diff_hunk: String, pub reply_to: Option<Id>, pub body: String,
pub outdated: bool, pub resolved: bool, pub created_at: String, pub updated_at: String, pub pending: bool /* review is pending */ }
pub struct Thread { pub root: ReviewComment, pub replies: Vec<ReviewComment> }
// store/reviews.rs
pub async fn pending_review(&self, issue_id: &str, author_id: &str) -> Result<Option<Review>>;
pub async fn add_review_comment(&self, issue_id: &str, author_id: &str, pending: bool, path: &str, side: Side, line: u32,
commit_oid: &str, diff_hunk: &str, body: &str) -> Result<ReviewComment>;
// pending=true: put it in the author's pending review, creating that review if needed;
// pending=false: create a submitted `commented` review holding just this comment
pub async fn reply_review_comment(&self, issue_id: &str, author_id: &str, root_id: &str, body: &str) -> Result<ReviewComment>;
// root must be a submitted root comment of this PR; copies path/side/line/commit_oid
pub async fn submit_review(&self, issue_id: &str, author_id: &str, state: ReviewState /* not Pending/Dismissed */,
body: &str, head_oid: &str) -> Result<Review>;
// turns the pending review into `state` (or creates one with no comments); Commented with an empty body
// and no comments is Invalid; the PR author's Approved or ChangesRequested is Forbidden
pub async fn discard_pending_review(&self, issue_id: &str, author_id: &str) -> Result<()>;
pub async fn dismiss_review(&self, review_id: &str, actor_id: &str, reason: &str) -> Result<()>; // approved or changes_requested only; adds the event
pub async fn list_reviews(&self, issue_id: &str) -> Result<Vec<Review>>; // submitted ones, oldest first
pub async fn review_threads(&self, issue_id: &str, viewer_id: Option<&str>) -> Result<Vec<Thread>>;
// submitted comments plus the viewer's own pending ones; replies oldest first
pub async fn review_comment(&self, id: &str) -> Result<Option<ReviewComment>>;
pub async fn edit_review_comment(&self, id: &str, body: &str) -> Result<()>; // records edit_history target_kind 'review_comment'
pub async fn delete_review_comment(&self, id: &str) -> Result<()>; // deleting a root deletes its replies
pub async fn set_thread_resolved(&self, root_id: &str, actor_id: &str, resolved: bool) -> Result<()>; // adds an event
pub async fn mark_outdated(&self, issue_id: &str, ids: &[Id]) -> Result<()>;
pub async fn review_summary(&self, issue_id: &str) -> Result<ReviewSummary>;
// per reviewer, their latest non-commented, non-dismissed submitted state
pub struct ReviewSummary { pub approved_by: Vec<String>, pub changes_requested_by: Vec<String> }
Bodies use the same limits as issue comments. author_name comes from a join on users.
forge-git (added to src/pulls.rs)
pub async fn blob_at(env: &GitEnv, repo: &Path, commit: &str, path: &str) -> Result<Option<String>>; // `rev-parse <commit>:<path>` gives the blob oid; None if absent. Validate path: no NUL, not absolute, no `..` segments
Outdated marking (forge-web pulls_sync.rs, extending the head_updated path)
After update_pull_head, for each submitted root comment that isn't outdated, compare blob_at(old commit_oid, path) with blob_at(new_head, path). Mark it outdated if they differ (cache by (commit_oid, path)).
Web
- Files tab
/pulls/{n}/files:- Line anchors. Each diff line gets an id
L-<file_index>-<side>-<line>and a small "+" link to?comment=<file_index>:<side>:<line>#L-…. Usefile_index(the file's position in the diff), not the path, to keep URLs short. The server maps it back to the path and re-validates it against the current diff. - Inline comment form. When
comment=matches a line, render a form right under that line. It POSTs/pulls/{n}/review_commentswith csrf,file_index,side,line,head_oid(rejected if stale: "the pull request changed, reload"), body, and two buttons:action=single("Add single comment") oraction=review("Start a review" / "Add review comment" if one is pending). - Threads. Existing, non-outdated threads on the current diff render under their line: comments (markdown-rendered like issue comments), a reply form, a Resolve/Unresolve button (PR author or Write), and edit/delete for the author.
- Review bar at the top for signed-in viewers: "Finish your review" with a pending comment count, a body textarea, radio comment/approve/request_changes (approve and request changes hidden for the PR author), Submit and Discard. Posts to
/pulls/{n}/reviewsor/pulls/{n}/reviews/discard. - The
diff_sectioncomponent gains an optional slot. Implement it as an optional&HashMap<(usize, Side, u32), Vec<...>>of rendered thread data plus an optional open-form target; keepcommit.rsandcompare.rscalls working, passing none.
- Line anchors. Each diff line gets an id
- Conversation timeline: submitted reviews appear in created order:
- Avatar/name with the verb ("approved these changes" / "requested changes" / "reviewed"), the body, then its threads, each showing the
diff_hunkas a small code block, the path:line, an "Outdated" badge, comments and replies, and a "Resolved" collapsed marker using<details>. - Dismissed reviews show struck-through with the reason.
- Avatar/name with the verb ("approved these changes" / "requested changes" / "reviewed"), the body, then its threads, each showing the
- Merge box: a summary line: "Approved by a, b", "Changes requested by c". Write holders see a "Dismiss" form for each approve or request-changes review, with a reason.
- Routes (all POST with CSRF, all redirecting 303 back to the referring tab with an anchor):
/pulls/{n}/review_comments/pulls/{n}/review_comments/{id}/reply/pulls/{n}/review_comments/{id}/edit(GET form + POST)/pulls/{n}/review_comments/{id}/delete/pulls/{n}/review_comments/{id}/resolve(withresolved=1|0)/pulls/{n}/reviews,/pulls/{n}/reviews/discard,/pulls/{n}/reviews/{id}/dismiss
- Permissions:
- Read and signed in to comment or review.
- Author of the comment to edit or delete; Triage+ may also delete.
- PR author or Write to resolve; Write to dismiss.
- Archived repo: no writes. Merged or closed PR: comments and replies still allowed, new reviews not.