|
|
|
@@ -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"}}<!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}}
|
|
|
|
|
<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.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>
|
|
|
|
|
{{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}}
|
|
|
|
@@ -177,6 +219,32 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
|
|
|
|
|
{{else}}<p>No files yet.</p>{{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"}}
|
|
|
|
|
{{$d := .Data}}
|
|
|
|
|
<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><button type="submit">commit changes</button></p>
|
|
|
|
|
</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}}`))
|
|
|
|
|
|
|
|
|
|
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 != "" {
|
|
|
|
|