diff --git a/README.md b/README.md index cf9ef6e..d5799b6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Open the minimal web UI at the server URL, for example: 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 @@ -48,7 +48,7 @@ CLI auth config defaults to: ## 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 diff --git a/internal/app/web.go b/internal/app/web.go index c4e2558..f597ab3 100644 --- a/internal/app/web.go +++ b/internal/app/web.go @@ -55,6 +55,7 @@ type webRepoData struct { Branches []RefInfo CloneURL string ParentPath string + PRs []PullRequest } type webBlobData struct { @@ -84,6 +85,35 @@ type webTreeEntry struct { 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{ "urlquery": url.QueryEscape, }).Parse(`{{define "layout"}} @@ -160,7 +190,19 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ {{if $d.Repo.Description}}

{{$d.Repo.Description}}

{{end}}

Clone: {{$d.CloneURL}}

{{if $d.CanFork}}
{{end}} -{{if $d.CanWrite}}

create/edit file

{{end}} +

branches | pull requests {{if .Authed}}| new pull request{{end}}

+
+ + + +
+{{if $d.CanWrite}} +

create/edit file

+{{end}}

Branch: {{$d.Ref}} {{if $d.Path}} Path: {{$d.Path}}{{end}}

{{if $d.ParentPath}}

..

{{end}} {{if $d.Entries}} @@ -177,6 +219,32 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ {{else}}

No files yet.

{{end}} {{end}} +{{define "Branches"}} +{{$d := .Data}} +

Branches for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

+

repo

+{{if $d.Branches}} + +{{range $d.Branches}} + + + + + +{{end}} +
BranchCommit
{{.Name}}{{if eq .Name $d.Repo.DefaultBranch}} (default){{end}}{{.Commit}}{{if and $d.CanWrite (ne .Name $d.Repo.DefaultBranch)}}
{{end}}
+{{else}}

No branches yet.

{{end}} +{{if $d.CanWrite}} +

New branch

+
+ +

+

+

+
+{{end}} +{{end}} + {{define "File"}} {{$d := .Data}}

{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}

@@ -200,6 +268,62 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{

+{{end}} + +{{define "Pull Requests"}} +{{$d := .Data}} +

Pull requests for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

+

repo {{if .Authed}}| new pull request{{end}}

+{{if $d.PRs}} + +{{range $d.PRs}}{{end}} +
#TitleStatusBranches
#{{.Number}}{{.Title}}{{.Status}}{{.SourceOwner}}/{{.SourceRepo}}:{{.SourceBranch}} → {{.TargetBranch}}
+{{else}}

No pull requests.

{{end}} +{{end}} + +{{define "New Pull Request"}} +{{$d := .Data}} +

New pull request for {{$d.Repo.Owner}}/{{$d.Repo.Name}}

+
+ +

+

+

+

+

+

+

+
+{{end}} + +{{define "Pull Request"}} +{{$d := .Data}} +

#{{$d.PR.Number}} {{$d.PR.Title}}

+

pull requests | {{$d.PR.Status}} | {{$d.PR.SourceOwner}}/{{$d.PR.SourceRepo}}:{{$d.PR.SourceBranch}} → {{$d.PR.TargetBranch}}

+{{if $d.PR.Description}}

{{$d.PR.Description}}

{{end}} +{{if $d.CanManage}}{{if eq $d.PR.Status "open"}} +
+
+{{end}}{{end}} +

Comments

+{{range $d.Comments}}
{{.Author}}
{{.Body}}
{{else}}

No comments.

{{end}} +{{if $d.CanComment}} +
+ +

+

+
+{{end}} +{{end}} + +{{define "Profile"}} +{{$d := .Data}} +

{{$d.Profile.Username}}

+

{{$d.Profile.Email}}

+

Public repositories

+{{if $d.Repos}} +{{range $d.Repos}}{{end}}
RepositoryDescription
{{.Owner}}/{{.Name}}{{.Description}}
+{{else}}

No public repositories.

{{end}} {{end}}`)) 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) { + 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 { webError(w, r, http.StatusNotFound, "not found") return @@ -466,6 +598,22 @@ func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []st return } 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": if r.Method != http.MethodPost { 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, "") } +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) { repo, user, authed, ok := s.webRepoContext(w, r, owner, name) 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) } +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) { c, err := r.Cookie(webAuthCookie) 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) } +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 { cmd := exec.Command("git", args...) if dir != "" { diff --git a/internal/app/web_workflows_test.go b/internal/app/web_workflows_test.go new file mode 100644 index 0000000..8a5bea2 --- /dev/null +++ b/internal/app/web_workflows_test.go @@ -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) + } +} diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md index 36ae90c..6deaa90 100644 --- a/notes/PHASE_4_COVERAGE_NOTES.md +++ b/notes/PHASE_4_COVERAGE_NOTES.md @@ -15,7 +15,7 @@ go test -cover ./... Current default coverage snapshot after Phase 4 implementation: ```text -internal/app 62.5% +internal/app 59.8% internal/backup 42.3% internal/config 79.5% internal/dbutil 8.6% diff --git a/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md b/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md new file mode 100644 index 0000000..956c310 --- /dev/null +++ b/plans/PHASE_6_WEB_UI_REPO_WORKFLOW_PLAN.md @@ -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