Add web branch and pull request workflows

This commit is contained in:
2026-06-08 15:03:41 -05:00
parent 66a8242980
commit 2059bd028d
5 changed files with 737 additions and 4 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ Open the minimal web UI at the server URL, for example:
http://localhost:8080/repos http://localhost:8080/repos
``` ```
Web UI routes include `/repos`, `/login`, `/register`, `/repos/new`, and `/{username}/{repo}`. Web UI routes include `/repos`, `/login`, `/register`, `/repos/new`, `/{username}`, `/{username}/{repo}`, `/{username}/{repo}/branches`, and pull request pages.
## Storage ## Storage
@@ -48,7 +48,7 @@ CLI auth config defaults to:
## Web UI ## Web UI
The web UI is intentionally minimal and server-rendered. It supports repository search, login/register/logout, repository creation, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing for users with write access, and forking public repositories. Private repositories cannot be forked. The web UI is intentionally minimal and server-rendered. It supports repository search, user profile pages with public repositories, login/register/logout, repository creation, branch selection, a branches page for branch creation/deletion, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing for users with write access, pull request list/create/view/comment/close/merge workflows, and forking public repositories. Private repositories cannot be forked. Branch renaming is intentionally unsupported in both the API and web UI.
## Development ## Development
+579 -1
View File
@@ -55,6 +55,7 @@ type webRepoData struct {
Branches []RefInfo Branches []RefInfo
CloneURL string CloneURL string
ParentPath string ParentPath string
PRs []PullRequest
} }
type webBlobData struct { type webBlobData struct {
@@ -84,6 +85,35 @@ type webTreeEntry struct {
Size string Size string
} }
type webBranchesData struct {
Repo Repository
Branches []RefInfo
CanWrite bool
}
type webPRListData struct {
Repo Repository
PRs []PullRequest
}
type webPRData struct {
Repo Repository
PR PullRequest
Comments []PRComment
CanManage bool
CanComment bool
}
type webPRNewData struct {
Repo Repository
Branches []RefInfo
}
type webProfileData struct {
Profile User
Repos []Repository
}
var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
"urlquery": url.QueryEscape, "urlquery": url.QueryEscape,
}).Parse(`{{define "layout"}}<!doctype html> }).Parse(`{{define "layout"}}<!doctype html>
@@ -160,7 +190,19 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
{{if $d.Repo.Description}}<p>{{$d.Repo.Description}}</p>{{end}} {{if $d.Repo.Description}}<p>{{$d.Repo.Description}}</p>{{end}}
<p>Clone: <code>{{$d.CloneURL}}</code></p> <p>Clone: <code>{{$d.CloneURL}}</code></p>
{{if $d.CanFork}}<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/fork" style="margin-bottom: 1rem"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">fork</button></form>{{end}} {{if $d.CanFork}}<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/fork" style="margin-bottom: 1rem"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">fork</button></form>{{end}}
{{if $d.CanWrite}}<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}">create/edit file</a></p>{{end}} <p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/branches">branches</a> | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls">pull requests</a> {{if .Authed}}| <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/new">new pull request</a>{{end}}</p>
<form method="get" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree" style="margin-bottom: 1rem">
<label>Branch
<select name="ref">
{{range $d.Branches}}<option value="{{.Name}}" {{if eq .Name $d.Ref}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<input type="hidden" name="path" value="{{$d.Path}}">
<button type="submit">switch</button>
</form>
{{if $d.CanWrite}}
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}">create/edit file</a></p>
{{end}}
<p>Branch: {{$d.Ref}} {{if $d.Path}} Path: {{$d.Path}}{{end}}</p> <p>Branch: {{$d.Ref}} {{if $d.Path}} Path: {{$d.Path}}{{end}}</p>
{{if $d.ParentPath}}<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery $d.ParentPath}}">..</a></p>{{end}} {{if $d.ParentPath}}<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery $d.ParentPath}}">..</a></p>{{end}}
{{if $d.Entries}} {{if $d.Entries}}
@@ -177,6 +219,32 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
{{else}}<p>No files yet.</p>{{end}} {{else}}<p>No files yet.</p>{{end}}
{{end}} {{end}}
{{define "Branches"}}
{{$d := .Data}}
<h1>Branches for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}">repo</a></p>
{{if $d.Branches}}
<table cellpadding="6"><tr><th align="left">Branch</th><th align="left">Commit</th><th></th></tr>
{{range $d.Branches}}
<tr>
<td><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery .Name}}">{{.Name}}</a>{{if eq .Name $d.Repo.DefaultBranch}} (default){{end}}</td>
<td><code>{{.Commit}}</code></td>
<td>{{if and $d.CanWrite (ne .Name $d.Repo.DefaultBranch)}}<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/branches/delete" style="display:inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="name" value="{{.Name}}"><button type="submit">delete</button></form>{{end}}</td>
</tr>
{{end}}
</table>
{{else}}<p>No branches yet.</p>{{end}}
{{if $d.CanWrite}}
<h2>New branch</h2>
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/branches">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<p><label>Name<br><input name="name" placeholder="branch-name"></label></p>
<p><label>From<br><select name="from"><option value="">empty branch</option>{{range $d.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label></p>
<p><button type="submit">create branch</button></p>
</form>
{{end}}
{{end}}
{{define "File"}} {{define "File"}}
{{$d := .Data}} {{$d := .Data}}
<h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}</h1> <h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}</h1>
@@ -200,6 +268,62 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
<p><label>Content<br><textarea name="content" rows="24" cols="100">{{$d.Content}}</textarea></label></p> <p><label>Content<br><textarea name="content" rows="24" cols="100">{{$d.Content}}</textarea></label></p>
<p><button type="submit">commit changes</button></p> <p><button type="submit">commit changes</button></p>
</form> </form>
{{end}}
{{define "Pull Requests"}}
{{$d := .Data}}
<h1>Pull requests for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}">repo</a> {{if .Authed}}| <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/new">new pull request</a>{{end}}</p>
{{if $d.PRs}}
<table cellpadding="6"><tr><th align="left">#</th><th align="left">Title</th><th align="left">Status</th><th align="left">Branches</th></tr>
{{range $d.PRs}}<tr><td>#{{.Number}}</td><td><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{.Number}}">{{.Title}}</a></td><td>{{.Status}}</td><td>{{.SourceOwner}}/{{.SourceRepo}}:{{.SourceBranch}} → {{.TargetBranch}}</td></tr>{{end}}
</table>
{{else}}<p>No pull requests.</p>{{end}}
{{end}}
{{define "New Pull Request"}}
{{$d := .Data}}
<h1>New pull request for {{$d.Repo.Owner}}/{{$d.Repo.Name}}</h1>
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/new">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<p><label>Source owner<br><input name="source_owner" value="{{.User.Username}}"></label></p>
<p><label>Source repo<br><input name="source_repo" value="{{$d.Repo.Name}}"></label></p>
<p><label>Source branch<br><input name="source_branch"></label></p>
<p><label>Target branch<br><select name="target_branch">{{range $d.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></label></p>
<p><label>Title<br><input name="title" size="80"></label></p>
<p><label>Description<br><textarea name="description" rows="6" cols="80"></textarea></label></p>
<p><button type="submit">create pull request</button></p>
</form>
{{end}}
{{define "Pull Request"}}
{{$d := .Data}}
<h1>#{{$d.PR.Number}} {{$d.PR.Title}}</h1>
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls">pull requests</a> | {{$d.PR.Status}} | {{$d.PR.SourceOwner}}/{{$d.PR.SourceRepo}}:{{$d.PR.SourceBranch}} → {{$d.PR.TargetBranch}}</p>
{{if $d.PR.Description}}<p>{{$d.PR.Description}}</p>{{end}}
{{if $d.CanManage}}{{if eq $d.PR.Status "open"}}
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{$d.PR.Number}}/merge" style="display:inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">merge</button></form>
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{$d.PR.Number}}/close" style="display:inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit">close</button></form>
{{end}}{{end}}
<h2>Comments</h2>
{{range $d.Comments}}<div style="margin-bottom: 1rem"><strong>{{.Author}}</strong><br><pre style="white-space: pre-wrap">{{.Body}}</pre></div>{{else}}<p>No comments.</p>{{end}}
{{if $d.CanComment}}
<form method="post" action="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/pulls/{{$d.PR.Number}}/comments">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<p><textarea name="body" rows="5" cols="80"></textarea></p>
<p><button type="submit">comment</button></p>
</form>
{{end}}
{{end}}
{{define "Profile"}}
{{$d := .Data}}
<h1>{{$d.Profile.Username}}</h1>
<p>{{$d.Profile.Email}}</p>
<h2>Public repositories</h2>
{{if $d.Repos}}
<table cellpadding="6"><tr><th align="left">Repository</th><th align="left">Description</th></tr>{{range $d.Repos}}<tr><td><a href="/{{.Owner}}/{{.Name}}">{{.Owner}}/{{.Name}}</a></td><td>{{.Description}}</td></tr>{{end}}</table>
{{else}}<p>No public repositories.</p>{{end}}
{{end}}`)) {{end}}`))
func (s *Server) handleWeb(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWeb(w http.ResponseWriter, r *http.Request) {
@@ -422,6 +546,14 @@ func (s *Server) webRepoNewPost(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []string) { func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []string) {
if len(parts) == 1 {
if r.Method != http.MethodGet {
webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
s.webProfile(w, r, strings.ToLower(parts[0]))
return
}
if len(parts) < 2 { if len(parts) < 2 {
webError(w, r, http.StatusNotFound, "not found") webError(w, r, http.StatusNotFound, "not found")
return return
@@ -466,6 +598,22 @@ func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []st
return return
} }
webError(w, r, http.StatusMethodNotAllowed, "method not allowed") webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
case "branches":
if len(parts) == 3 && r.Method == http.MethodGet {
s.webBranches(w, r, owner, name, "")
return
}
if len(parts) == 3 && r.Method == http.MethodPost {
s.webBranchCreatePost(w, r, owner, name)
return
}
if len(parts) == 4 && parts[3] == "delete" && r.Method == http.MethodPost {
s.webBranchDeletePost(w, r, owner, name)
return
}
webError(w, r, http.StatusNotFound, "not found")
case "pulls":
s.webPullsRoute(w, r, owner, name, parts[3:])
case "fork": case "fork":
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
webError(w, r, http.StatusMethodNotAllowed, "method not allowed") webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
@@ -512,6 +660,93 @@ func (s *Server) webRepoTree(w http.ResponseWriter, r *http.Request, owner, name
s.renderWeb(w, r, "Repository", data, "") s.renderWeb(w, r, "Repository", data, "")
} }
func (s *Server) webBranches(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) {
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
branches, err := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads")
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderWeb(w, r, "Branches", webBranchesData{Repo: repo, Branches: branches, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived}, errMsg)
}
func (s *Server) webBranchCreatePost(w http.ResponseWriter, r *http.Request, owner, name string) {
if !validWebCSRF(r) {
webError(w, r, http.StatusBadRequest, "invalid form token")
return
}
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
if !authed || !s.canWriteRepo(repo, user) || repo.Archived {
webError(w, r, http.StatusForbidden, "write access required")
return
}
branch := strings.TrimSpace(r.FormValue("name"))
from := strings.TrimSpace(r.FormValue("from"))
if !branchRE.MatchString(branch) {
s.webBranches(w, r, owner, name, "invalid branch name")
return
}
if gitBranchExists(s.repoPath(repo.Owner, repo.Name), branch) {
s.webBranches(w, r, owner, name, "branch already exists")
return
}
if from == "" {
if err := createEmptyGitBranch(s.repoPath(repo.Owner, repo.Name), branch, user); err != nil {
s.webBranches(w, r, owner, name, err.Error())
return
}
} else {
if !branchRE.MatchString(from) || !gitBranchExists(s.repoPath(repo.Owner, repo.Name), from) {
s.webBranches(w, r, owner, name, "source branch does not exist")
return
}
if err := createGitBranchFrom(s.repoPath(repo.Owner, repo.Name), branch, from); err != nil {
s.webBranches(w, r, owner, name, err.Error())
return
}
}
http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/branches", http.StatusSeeOther)
}
func (s *Server) webBranchDeletePost(w http.ResponseWriter, r *http.Request, owner, name string) {
if !validWebCSRF(r) {
webError(w, r, http.StatusBadRequest, "invalid form token")
return
}
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
if !authed || !s.canWriteRepo(repo, user) || repo.Archived {
webError(w, r, http.StatusForbidden, "write access required")
return
}
branch := strings.TrimSpace(r.FormValue("name"))
if !branchRE.MatchString(branch) {
s.webBranches(w, r, owner, name, "invalid branch name")
return
}
if branch == repo.DefaultBranch {
s.webBranches(w, r, owner, name, "cannot delete the default branch")
return
}
if !gitBranchExists(s.repoPath(repo.Owner, repo.Name), branch) {
s.webBranches(w, r, owner, name, "branch does not exist")
return
}
if err := deleteGitBranch(s.repoPath(repo.Owner, repo.Name), branch); err != nil {
s.webBranches(w, r, owner, name, err.Error())
return
}
http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/branches", http.StatusSeeOther)
}
func (s *Server) webRepoBlob(w http.ResponseWriter, r *http.Request, owner, name string) { func (s *Server) webRepoBlob(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, user, authed, ok := s.webRepoContext(w, r, owner, name) repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok { if !ok {
@@ -678,6 +913,311 @@ func (s *Server) webRepoForkPost(w http.ResponseWriter, r *http.Request, owner,
http.Redirect(w, r, "/"+user.Username+"/"+newName, http.StatusSeeOther) http.Redirect(w, r, "/"+user.Username+"/"+newName, http.StatusSeeOther)
} }
func (s *Server) webPullsRoute(w http.ResponseWriter, r *http.Request, owner, name string, parts []string) {
if len(parts) == 0 {
if r.Method != http.MethodGet {
webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
s.webPRList(w, r, owner, name)
return
}
if len(parts) == 1 && parts[0] == "new" {
if r.Method == http.MethodGet {
s.webPRNew(w, r, owner, name, "")
return
}
if r.Method == http.MethodPost {
s.webPRCreatePost(w, r, owner, name)
return
}
webError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
n, err := strconv.Atoi(parts[0])
if err != nil {
webError(w, r, http.StatusBadRequest, "invalid pull request number")
return
}
if len(parts) == 1 && r.Method == http.MethodGet {
s.webPRView(w, r, owner, name, n)
return
}
if len(parts) == 2 && r.Method == http.MethodPost && parts[1] == "comments" {
s.webPRCommentPost(w, r, owner, name, n)
return
}
if len(parts) == 2 && r.Method == http.MethodPost && parts[1] == "close" {
s.webPRClosePost(w, r, owner, name, n)
return
}
if len(parts) == 2 && r.Method == http.MethodPost && parts[1] == "merge" {
s.webPRMergePost(w, r, owner, name, n)
return
}
webError(w, r, http.StatusNotFound, "not found")
}
func (s *Server) webPRList(w http.ResponseWriter, r *http.Request, owner, name string) {
repo, _, _, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
prs, err := s.listPRs(repo.ID)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderWeb(w, r, "Pull Requests", webPRListData{Repo: repo, PRs: prs}, "")
}
func (s *Server) webPRNew(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) {
if _, ok := s.optionalWebUser(r); !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
repo, _, _, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
branches, _ := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads")
s.renderWeb(w, r, "New Pull Request", webPRNewData{Repo: repo, Branches: branches}, errMsg)
}
func (s *Server) webPRCreatePost(w http.ResponseWriter, r *http.Request, owner, name string) {
if !validWebCSRF(r) {
s.webPRNew(w, r, owner, name, "invalid form token")
return
}
user, authed := s.optionalWebUser(r)
if !authed {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
target, err := s.loadRepo(owner, name)
if err != nil || !s.canReadRepo(target, user, true) {
webError(w, r, http.StatusNotFound, "target repository not found")
return
}
sourceOwner := strings.ToLower(strings.TrimSpace(r.FormValue("source_owner")))
sourceRepo := strings.ToLower(strings.TrimSpace(r.FormValue("source_repo")))
sourceBranch := strings.TrimSpace(r.FormValue("source_branch"))
targetBranch := strings.TrimSpace(r.FormValue("target_branch"))
title := strings.TrimSpace(r.FormValue("title"))
description := strings.TrimSpace(r.FormValue("description"))
if sourceOwner == "" {
sourceOwner = target.Owner
}
if sourceRepo == "" {
sourceRepo = target.Name
}
if title == "" || !branchRE.MatchString(sourceBranch) || !branchRE.MatchString(targetBranch) {
s.webPRNew(w, r, owner, name, "title and valid source/target branches are required")
return
}
source, err := s.loadRepo(sourceOwner, sourceRepo)
if err != nil {
s.webPRNew(w, r, owner, name, "source repository not found")
return
}
if source.ID == target.ID {
if target.OwnerUserID != user.ID {
webError(w, r, http.StatusForbidden, "same-repository PRs require repository ownership")
return
}
} else {
if source.OwnerUserID != user.ID {
webError(w, r, http.StatusForbidden, "source repository must be owned by you")
return
}
if target.Visibility != "public" && target.OwnerUserID != user.ID {
webError(w, r, http.StatusForbidden, "target repository is private")
return
}
}
if !gitBranchExists(s.repoPath(source.Owner, source.Name), sourceBranch) || !gitBranchExists(s.repoPath(target.Owner, target.Name), targetBranch) {
s.webPRNew(w, r, owner, name, "source and target branches must exist")
return
}
var number int
_ = s.db.QueryRow(`SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE target_repository_id = ?`, target.ID).Scan(&number)
res, err := s.db.Exec(`INSERT INTO pull_requests (target_repository_id, number, author_user_id, source_repository_id, source_branch, target_branch, title, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, target.ID, number, user.ID, source.ID, sourceBranch, targetBranch, title, description)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
if number == 0 {
id, _ := res.LastInsertId()
_ = id
}
http.Redirect(w, r, "/"+target.Owner+"/"+target.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther)
}
func (s *Server) webPRView(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok {
return
}
pr, err := s.loadPR(repo.ID, number)
if err != nil {
webError(w, r, http.StatusNotFound, "pull request not found")
return
}
comments, err := s.listPRComments(pr.ID)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderWeb(w, r, "Pull Request", webPRData{Repo: repo, PR: pr, Comments: comments, CanManage: authed && user.ID == repo.OwnerUserID, CanComment: authed}, "")
}
func (s *Server) webPRCommentPost(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
if !validWebCSRF(r) {
webError(w, r, http.StatusBadRequest, "invalid form token")
return
}
user, authed := s.optionalWebUser(r)
if !authed {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
repo, err := s.loadRepo(owner, name)
if err != nil || !s.canReadRepo(repo, user, true) {
webError(w, r, http.StatusNotFound, "repository not found")
return
}
pr, err := s.loadPR(repo.ID, number)
if err != nil {
webError(w, r, http.StatusNotFound, "pull request not found")
return
}
body := strings.TrimSpace(r.FormValue("body"))
if body == "" {
webError(w, r, http.StatusBadRequest, "comment body is required")
return
}
_, err = s.db.Exec(`INSERT INTO pull_request_comments (pull_request_id, author_user_id, body) VALUES (?, ?, ?)`, pr.ID, user.ID, body)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther)
}
func (s *Server) webPRClosePost(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
if !validWebCSRF(r) {
webError(w, r, http.StatusBadRequest, "invalid form token")
return
}
user, authed := s.optionalWebUser(r)
if !authed {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
repo, err := s.loadRepo(owner, name)
if err != nil || repo.OwnerUserID != user.ID {
webError(w, r, http.StatusForbidden, "only target owner can close pull requests")
return
}
res, err := s.db.Exec(`UPDATE pull_requests SET status = 'closed', closed_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ? AND status = 'open'`, repo.ID, number)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
webError(w, r, http.StatusConflict, "pull request is not open")
return
}
http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther)
}
func (s *Server) webPRMergePost(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
if !validWebCSRF(r) {
webError(w, r, http.StatusBadRequest, "invalid form token")
return
}
user, authed := s.optionalWebUser(r)
if !authed {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
repo, err := s.loadRepo(owner, name)
if err != nil || repo.OwnerUserID != user.ID {
webError(w, r, http.StatusForbidden, "only target owner can merge pull requests")
return
}
pr, err := s.loadPR(repo.ID, number)
if err != nil || pr.Status != "open" {
webError(w, r, http.StatusConflict, "pull request is not open")
return
}
if err := s.mergePR(pr); err != nil {
webError(w, r, http.StatusConflict, err.Error())
return
}
_, err = s.db.Exec(`UPDATE pull_requests SET status = 'merged', merged_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ?`, repo.ID, number)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
http.Redirect(w, r, "/"+repo.Owner+"/"+repo.Name+"/pulls/"+strconv.Itoa(number), http.StatusSeeOther)
}
func (s *Server) listPRs(repoID int64) ([]PullRequest, error) {
rows, err := s.db.Query(prSelectSQL()+` WHERE pr.target_repository_id = ? ORDER BY pr.number DESC`, repoID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPRs(rows)
}
func (s *Server) listPRComments(prID int64) ([]PRComment, error) {
rows, err := s.db.Query(`SELECT c.id, u.username, c.body, c.created_at, c.updated_at FROM pull_request_comments c JOIN users u ON u.id = c.author_user_id WHERE c.pull_request_id = ? ORDER BY c.created_at`, prID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PRComment
for rows.Next() {
var c PRComment
if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Server) webProfile(w http.ResponseWriter, r *http.Request, username string) {
if s.db == nil {
webError(w, r, http.StatusNotFound, "user not found")
return
}
var profile User
if err := s.db.QueryRow(`SELECT id, email, username, is_admin FROM users WHERE username = ?`, username).Scan(&profile.ID, &profile.Email, &profile.Username, &profile.IsAdmin); err != nil {
webError(w, r, http.StatusNotFound, "user not found")
return
}
rows, err := s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at
FROM repositories r JOIN users u ON u.id = r.owner_user_id
WHERE u.username = ? AND r.visibility = 'public'
ORDER BY r.updated_at DESC LIMIT 100`, username)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
repos, err := scanRepos(rows)
if err != nil {
webError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderWeb(w, r, "Profile", webProfileData{Profile: profile, Repos: repos}, "")
}
func (s *Server) optionalWebUser(r *http.Request) (User, bool) { func (s *Server) optionalWebUser(r *http.Request) (User, bool) {
c, err := r.Cookie(webAuthCookie) c, err := r.Cookie(webAuthCookie)
if err != nil || c.Value == "" { if err != nil || c.Value == "" {
@@ -907,6 +1447,44 @@ func (s *Server) commitEditedFile(repo Repository, ref, p, content string, user
return gitRunOutput(work, "push", "origin", "HEAD:"+ref) return gitRunOutput(work, "push", "origin", "HEAD:"+ref)
} }
func createGitBranchFrom(repoPath, branch, from string) error {
cmd := exec.Command("git", "--git-dir", repoPath, "rev-parse", "refs/heads/"+from)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("source branch does not exist: %s", strings.TrimSpace(string(out)))
}
commit := strings.TrimSpace(string(out))
return gitRunOutput("", "--git-dir", repoPath, "update-ref", "refs/heads/"+branch, commit)
}
func deleteGitBranch(repoPath, branch string) error {
return gitRunOutput("", "--git-dir", repoPath, "update-ref", "-d", "refs/heads/"+branch)
}
func createEmptyGitBranch(repoPath, branch string, user User) error {
work := filepath.Join(os.TempDir(), fmt.Sprintf("gitocean-empty-branch-%d", time.Now().UnixNano()))
defer os.RemoveAll(work)
if err := gitRunOutput("", "init", work); err != nil {
return err
}
if err := gitRunOutput(work, "config", "user.name", user.Username); err != nil {
return err
}
if err := gitRunOutput(work, "config", "user.email", user.Email); err != nil {
return err
}
if err := gitRunOutput(work, "checkout", "--orphan", branch); err != nil {
return err
}
if err := gitRunOutput(work, "commit", "--allow-empty", "-m", "Create empty branch "+branch); err != nil {
return err
}
if err := gitRunOutput(work, "remote", "add", "origin", repoPath); err != nil {
return err
}
return gitRunOutput(work, "push", "origin", "HEAD:"+branch)
}
func gitRunOutput(dir string, args ...string) error { func gitRunOutput(dir string, args ...string) error {
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
if dir != "" { if dir != "" {
+131
View File
@@ -0,0 +1,131 @@
package app
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestWebBranchCreateFromExistingAndEmpty(t *testing.T) {
requireGitForApp(t)
s, mock, cleanup := newMockServer(t)
defer cleanup()
user := User{ID: 1, Email: "alice@example.com", Username: "alice"}
repo := Repository{ID: 10, OwnerUserID: user.ID, Owner: "alice", Name: "demo", Visibility: "public", DefaultBranch: "main"}
bare := s.repoPath(repo.Owner, repo.Name)
if err := os.MkdirAll(filepath.Dir(bare), 0755); err != nil {
t.Fatal(err)
}
seedRepoWithFeatureBranch(t, bare)
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/login", nil))
csrf := csrfFromResponse(t, rr)
expectLoadRepo(mock, "alice", "demo", repo)
expectBearerUser(mock, "tok", user)
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alice/demo/branches", nil)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Branches for alice/demo") || !strings.Contains(rr.Body.String(), "New branch") || !strings.Contains(rr.Body.String(), "delete") {
t.Fatalf("branches page status=%d body=%s", rr.Code, rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
expectBearerUser(mock, "tok", user)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/alice/demo/branches", strings.NewReader("_csrf="+csrf.Value+"&name=copy&from=main"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/demo/branches" || !gitBranchExists(bare, "copy") {
t.Fatalf("copy branch status=%d location=%q exists=%v body=%s", rr.Code, rr.Header().Get("Location"), gitBranchExists(bare, "copy"), rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
expectBearerUser(mock, "tok", user)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/alice/demo/branches", strings.NewReader("_csrf="+csrf.Value+"&name=empty"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/demo/branches" || !gitBranchExists(bare, "empty") {
t.Fatalf("empty branch status=%d location=%q exists=%v body=%s", rr.Code, rr.Header().Get("Location"), gitBranchExists(bare, "empty"), rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
expectBearerUser(mock, "tok", user)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/alice/demo/branches/delete", strings.NewReader("_csrf="+csrf.Value+"&name=copy"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/demo/branches" || gitBranchExists(bare, "copy") {
t.Fatalf("delete branch status=%d location=%q exists=%v body=%s", rr.Code, rr.Header().Get("Location"), gitBranchExists(bare, "copy"), rr.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestWebPRPagesCommentsAndProfile(t *testing.T) {
s, mock, cleanup := newMockServer(t)
defer cleanup()
now := time.Now().UTC()
user := User{ID: 1, Email: "alice@example.com", Username: "alice"}
repo := Repository{ID: 10, OwnerUserID: user.ID, Owner: "alice", Name: "demo", Visibility: "public", Description: "desc", DefaultBranch: "main", CreatedAt: now, UpdatedAt: now}
pr := PullRequest{ID: 30, Number: 2, TargetRepositoryID: repo.ID, SourceRepositoryID: repo.ID, AuthorUserID: user.ID, Author: "alice", SourceOwner: "alice", SourceRepo: "demo", SourceBranch: "feature", TargetOwner: "alice", TargetRepo: "demo", TargetBranch: "main", Title: "Fix", Description: "desc", Status: "open", CreatedAt: now, UpdatedAt: now}
mock.ExpectQuery("SELECT id, email, username, is_admin FROM users WHERE username").WithArgs("alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin"}).AddRow(user.ID, user.Email, user.Username, user.IsAdmin))
mock.ExpectQuery("FROM repositories r JOIN users u").WithArgs("alice").WillReturnRows(repoRows(repo))
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Public repositories") || !strings.Contains(rr.Body.String(), "alice/demo") {
t.Fatalf("profile status=%d body=%s", rr.Code, rr.Body.String())
}
csrf := csrfFromResponse(t, rr)
expectLoadRepo(mock, "alice", "demo", repo)
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID).WillReturnRows(prRows(pr))
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/pulls", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Fix") || !strings.Contains(rr.Body.String(), "alice/demo:feature") {
t.Fatalf("PR list status=%d body=%s", rr.Code, rr.Body.String())
}
expectLoadRepo(mock, "alice", "demo", repo)
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr))
mock.ExpectQuery("SELECT c.id, u.username").WithArgs(pr.ID).WillReturnRows(sqlmock.NewRows([]string{"id", "username", "body", "created_at", "updated_at"}).AddRow(int64(1), "alice", "hello", now, now))
rr = httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/pulls/2", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "#2 Fix") || !strings.Contains(rr.Body.String(), "hello") {
t.Fatalf("PR view status=%d body=%s", rr.Code, rr.Body.String())
}
expectBearerUser(mock, "tok", user)
expectLoadRepo(mock, "alice", "demo", repo)
mock.ExpectQuery("FROM pull_requests pr").WithArgs(repo.ID, pr.Number).WillReturnRows(prRows(pr))
mock.ExpectExec("INSERT INTO pull_request_comments").WithArgs(pr.ID, user.ID, "new comment").WillReturnResult(sqlmock.NewResult(5, 1))
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/alice/demo/pulls/2/comments", strings.NewReader("_csrf="+csrf.Value+"&body=new+comment"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
req.AddCookie(&http.Cookie{Name: webAuthCookie, Value: "tok"})
s.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/alice/demo/pulls/2" {
t.Fatalf("comment status=%d location=%q body=%s", rr.Code, rr.Header().Get("Location"), rr.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ go test -cover ./...
Current default coverage snapshot after Phase 4 implementation: Current default coverage snapshot after Phase 4 implementation:
```text ```text
internal/app 62.5% internal/app 59.8%
internal/backup 42.3% internal/backup 42.3%
internal/config 79.5% internal/config 79.5%
internal/dbutil 8.6% internal/dbutil 8.6%
@@ -0,0 +1,24 @@
# Phase 6 Web UI Repository Workflow Plan
Goal: extend the minimal web UI with branch workflows, pull request workflows, and user profile pages.
## Scope
- [x] Select active branch on repository pages
- [x] Branches page showing all repository branches
- [x] Create a new branch from an existing branch
- [x] Create an empty branch with an empty root commit
- [x] Delete non-default branches from the branches page
- [x] No branch rename support in API or web UI
- [x] List pull requests in the web UI
- [x] Create pull requests in the web UI
- [x] View pull request details and comments
- [x] Add pull request comments
- [x] Close and merge pull requests from the web UI where permitted
- [x] User profile page at `/{username}` showing public repositories
- [x] Tests for branch creation, PR web pages, and profile pages
## Completion
- [x] Implemented
- [x] Tests pass