feat(pulls): support per-commit review (#39326)

Opening a single commit of a PR let you look, but not review. The Review
button was disabled and there was no way to reach the next or previous
commit. This finishes https://github.com/go-gitea/gitea/pull/37475,
which only reached the head commit.

- Reviews can be submitted from a single commit, a commit range stays
disabled
- A bar above a single-commit diff shows the commit, its position in the
PR and prev/next buttons
- The commit dropdown marks the commits on screen
- Timeline commits open the PR's commit view, commits force-pushed out
of the PR redirect to the repository commit page

Assisted-by: Claude Code (Fable 5.1)
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
authored and GitHub committed 2026-10-30 20:18:13 +00:00
1 parent cbb563b080
commit fa41fc94d4
12 files changed
+155 -35

No files matched your search

+41 -4
View File
@@ -6,11 +6,13 @@
package repo
import (
"bytes"
"errors"
"fmt"
"html"
"html/template"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -676,6 +678,26 @@ func indexCommit(commits []*git.Commit, commitID string) *git.Commit {
return nil
}
// setSingleCommitNavContext exposes the reviewed commit and its neighbors.
func setSingleCommitNavContext(ctx *context.Context, commits []*git.Commit, commit *git.Commit) {
idx := slices.IndexFunc(commits, func(c *git.Commit) bool { return bytes.Equal(c.ID.RawValue(), commit.ID.RawValue()) })
if idx < 0 {
return
}
ctx.Data["SingleCommit"] = commit
ctx.Data["SingleCommitIndex"] = len(commits) - idx
ctx.Data["SingleCommitCount"] = len(commits)
// The commit list is newest-first while a reviewer walks it oldest-first, so the indexes are mirrored.
if idx+1 < len(commits) {
ctx.Data["SingleCommitPrevID"] = commits[idx+1].ID.String()
}
if idx > 0 {
ctx.Data["SingleCommitNextID"] = commits[idx-1].ID.String()
}
}
// ViewPullFiles render pull request changed files list page
func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
var err error
@@ -703,11 +725,17 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
}
headCommitID := prCompareInfo.HeadCommitID
isSingleCommit := beforeCommitID == "" && afterCommitID != ""
// FIXME: when afterCommitID==headCommitID, isSingleCommit and isShowAllCommits can be both true, which doesn't seem right
isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID)
// show all commits when:
// * both before and after commit IDs are empty (default view)
// * before commit ID is the PR's base commit and after commit ID is the PR's head commit (default view)
// * there is only one commit in the PR and after commit ID is the PR's head commit (single-commit view)
isShowAllCommits := (beforeCommitID == "" && afterCommitID == "") ||
(beforeCommitID == prCompareInfo.CompareBase && afterCommitID == headCommitID) ||
(beforeCommitID == "" && afterCommitID == headCommitID && len(prCompareInfo.Commits) == 1)
isSingleCommit := beforeCommitID == "" && afterCommitID != "" && !isShowAllCommits
ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit
// it's questionable why single commit review can be submitted, but the range commits review cannot be submitted
ctx.Data["CanSubmitReview"] = isShowAllCommits || isSingleCommit
ctx.Data["IsShowingAllCommits"] = isShowAllCommits
// "commits list" is half-open, half-closed: (base, head]
@@ -723,6 +751,10 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
return
}
}
if afterCommit == nil && isSingleCommit {
ctx.Redirect(ctx.Repo.RepoLink + "/commit/" + afterCommitID) // timeline commits outlive force pushes
return
}
if afterCommit == nil {
ctx.NotFound(nil)
return
@@ -756,6 +788,11 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
ctx.Data["CompareInfo"] = prCompareInfo
ctx.Data["AfterCommitID"] = afterCommitID
ctx.Data["BeforeCommitID"] = beforeCommitID
ctx.Data["ReviewCommitID"] = afterCommitID // the review happens on the "after commit" (the latest commit in the range)
if isSingleCommit {
setSingleCommitNavContext(ctx, prCompareInfo.Commits, afterCommit)
}
maxLines, maxFiles := setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffFiles
files := ctx.FormStrings("files")
+2
View File
@@ -56,6 +56,7 @@ func RenderNewCodeCommentForm(ctx *context.Context) {
return
}
ctx.Data["AfterCommitID"] = pullHeadCommitID
ctx.Data["ReviewCommitID"] = pullHeadCommitID // it's questionable why it only needs the head commit ID but not correct "AfterCommitID" (single commit review)
upload.AddUploadContext(ctx, "comment")
ctx.HTML(http.StatusOK, tplNewComment)
}
@@ -209,6 +210,7 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori
return
}
ctx.Data["AfterCommitID"] = pullHeadCommitID
ctx.Data["ReviewCommitID"] = pullHeadCommitID // it's questionable why it only needs the head commit ID but not correct "AfterCommitID" (single commit review)
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}