Commit e88e46e41d
Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success
Layout: unified · split
.gitbay/wiki/Parity.org +4 −2
| @@ -79,8 +79,9 @@ a thread hears nothing until they do (krz/gitbay#145). | |||
| 79 | 79 | ||
| 80 | Creating from a fork works anywhere the source can be typed as | 80 | Creating from a fork works anywhere the source can be typed as |
| 81 | =owner/name:branch=. The web's source picker offers the branches of | 81 | =owner/name:branch=. The web's source picker offers the branches of |
| 82 | every fork the viewer can push to in that form; forking itself is still | 82 | every fork the viewer can push to in that form, and the repository |
| 83 | a CLI operation. Retargeting moves an open | 83 | header has the fork control, so the whole path — fork, edit a file, |
| 84 | propose — runs in a browser. Retargeting moves an open | ||
| 84 | merge request onto another branch of the same repository and stales the | 85 | merge request onto another branch of the same repository and stales the |
| 85 | reviews, since an approval was of the diff against the old branch. | 86 | reviews, since an approval was of the diff against the old branch. |
| 86 | 87 | ||
| @@ -133,6 +134,7 @@ format column and are always markdown. | |||
| 133 | | download an archive | yes | yes | n/a | | 134 | | download an archive | yes | yes | n/a | |
| 134 | | edit a file | yes | yes | yes | | 135 | | edit a file | yes | yes | yes | |
| 135 | | create | yes | yes | yes | | 136 | | create | yes | yes | yes | |
| 137 | | fork | yes | yes | no | | ||
| 136 | | pin | yes | yes | yes | | 138 | | pin | yes | yes | yes | |
| 137 | | watch, mute | yes | yes | no | | 139 | | watch, mute | yes | yes | no | |
| 138 | | settings, protection | yes | yes | yes | | 140 | | settings, protection | yes | yes | yes | |
e2e/forkweb_test.go added +78
| @@ -0,0 +1,78 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/url" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Forking from the browser completes the contributor path: fork, edit a | ||
| 12 | // file in the fork, open the merge request against the parent, without a | ||
| 13 | // terminal at any step (#174). | ||
| 14 | func TestForkWeb(t *testing.T) { | ||
| 15 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 16 | aliceKey := inst.newKey(t, "alice") | ||
| 17 | bobKey := inst.newKey(t, "bob") | ||
| 18 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", | ||
| 19 | "--email", "alice@example.test", "--verified") | ||
| 20 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", | ||
| 21 | "--email", "bob@example.test", "--verified") | ||
| 22 | |||
| 23 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 24 | t.Fatalf("repo create: %s", errOut) | ||
| 25 | } | ||
| 26 | env := inst.gitEnv(aliceKey) | ||
| 27 | work := t.TempDir() | ||
| 28 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 29 | dir := filepath.Join(work, "w") | ||
| 30 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644) | ||
| 31 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 32 | mustGit(t, dir, env, "add", ".") | ||
| 33 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 34 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 35 | |||
| 36 | bob := inst.login(t, bobKey) | ||
| 37 | base := inst.base() + "/alice/app" | ||
| 38 | |||
| 39 | // The control is on the repository header for a signed-in visitor. | ||
| 40 | if _, page := browserGet(t, bob, base); !strings.Contains(page, `/alice/app/fork"`) { | ||
| 41 | t.Fatalf("no fork control on the repository header:\n%s", page) | ||
| 42 | } | ||
| 43 | |||
| 44 | // Forking lands on the fork, which carries the parent's history. | ||
| 45 | if status, body := browserPost(t, bob, base+"/fork", url.Values{}); status != 200 || | ||
| 46 | !strings.Contains(body, "bob/app") { | ||
| 47 | t.Fatalf("fork did not land on the new repository: %d\n%s", status, body) | ||
| 48 | } | ||
| 49 | out, _, _ := inst.ssh(t, bobKey, "", "repo", "list", "--json") | ||
| 50 | if !strings.Contains(out, `"path":"bob/app"`) { | ||
| 51 | t.Fatalf("fork not created:\n%s", out) | ||
| 52 | } | ||
| 53 | |||
| 54 | // Editing a file in the fork from the browser — the editor commits to | ||
| 55 | // the ref in the URL — then proposing it to the parent through the | ||
| 56 | // source picker. | ||
| 57 | if status, _ := browserPost(t, bob, inst.base()+"/bob/app/edit/main/f.txt", url.Values{ | ||
| 58 | "content": {"y\n"}, "message": {"change f"}}); status != 200 { | ||
| 59 | t.Fatal("web edit in the fork failed") | ||
| 60 | } | ||
| 61 | if _, page := browserGet(t, bob, base+"/mrs/new"); !strings.Contains(page, `value="bob/app:main"`) { | ||
| 62 | t.Fatalf("the fork's branch is not offered:\n%s", page) | ||
| 63 | } | ||
| 64 | if status, _ := browserPost(t, bob, base+"/mrs/new", url.Values{ | ||
| 65 | "source": {"bob/app:main"}, "target": {"main"}, "title": {"change"}}); status != 200 { | ||
| 66 | t.Fatal("mr create from the fork failed") | ||
| 67 | } | ||
| 68 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); !strings.Contains(out, `"source":"bob/app:main"`) { | ||
| 69 | t.Fatalf("merge request not opened from the fork:\n%s", out) | ||
| 70 | } | ||
| 71 | |||
| 72 | // Forking twice collides on the name, and says so on the page rather | ||
| 73 | // than failing silently. | ||
| 74 | _, body := browserPost(t, bob, base+"/fork", url.Values{}) | ||
| 75 | if !strings.Contains(body, `class="error"`) { | ||
| 76 | t.Errorf("a second fork reported nothing:\n%s", body) | ||
| 77 | } | ||
| 78 | } | ||
internal/control/mr.go +8 −1
| @@ -87,6 +87,13 @@ func init() { | |||
| 87 | Usage: "mr close <owner/name> <n>", Run: runMRClose}) | 87 | Usage: "mr close <owner/name> <n>", Run: runMRClose}) |
| 88 | } | 88 | } |
| 89 | 89 | ||
| 90 | // ForkOut is what `repo fork` emits: where the fork landed, and what it | ||
| 91 | // came from. Named so the web can send a person to the new repository. | ||
| 92 | type ForkOut struct { | ||
| 93 | Path string `json:"path"` | ||
| 94 | ForkOf string `json:"fork_of"` | ||
| 95 | } | ||
| 96 | |||
| 90 | func runRepoFork(c *Ctx, args []string) int { | 97 | func runRepoFork(c *Ctx, args []string) int { |
| 91 | f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"}) | 98 | f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"}) |
| 92 | if err != nil { | 99 | if err != nil { |
| @@ -133,7 +140,7 @@ func runRepoFork(c *Ctx, args []string) int { | |||
| 133 | } | 140 | } |
| 134 | } | 141 | } |
| 135 | forkPath := c.User.Username + "/" + name | 142 | forkPath := c.User.Username + "/" + name |
| 136 | return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) { | 143 | return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) { |
| 137 | fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) | 144 | fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) |
| 138 | }) | 145 | }) |
| 139 | } | 146 | } |
internal/control/output_test.go +4 −2
| @@ -16,7 +16,8 @@ func TestNamedPayloadsRoundTrip(t *testing.T) { | |||
| 16 | &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, | 16 | &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, |
| 17 | &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, | 17 | &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, |
| 18 | &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, | 18 | &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, |
| 19 | &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, | 19 | &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{}, |
| 20 | &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, | ||
| 20 | } | 21 | } |
| 21 | for _, p := range payloads { | 22 | for _, p := range payloads { |
| 22 | name := reflect.TypeOf(p).Elem().Name() | 23 | name := reflect.TypeOf(p).Elem().Name() |
| @@ -39,7 +40,8 @@ func TestPayloadFieldsAreTagged(t *testing.T) { | |||
| 39 | types := []any{ | 40 | types := []any{ |
| 40 | Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, | 41 | Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, |
| 41 | ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, | 42 | ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, |
| 42 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{}, | 43 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, |
| 44 | SearchResult{}, ReviewOut{}, | ||
| 43 | CheckOut{}, CommitOut{}, ServerOut{}, | 45 | CheckOut{}, CommitOut{}, ServerOut{}, |
| 44 | } | 46 | } |
| 45 | for _, v := range types { | 47 | for _, v := range types { |
internal/httpd/accounts.go +18
| @@ -221,6 +221,24 @@ func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) | |||
| 221 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | 221 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) |
| 222 | } | 222 | } |
| 223 | 223 | ||
| 224 | // forkSubmit forks the repository under the viewer's account and sends | ||
| 225 | // them to it. The command decides everything that matters — read access, | ||
| 226 | // quota, name collisions — so a refusal comes back as its own message on | ||
| 227 | // the page the button was pressed from (#174). | ||
| 228 | func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 229 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | ||
| 230 | if !ok { | ||
| 231 | return | ||
| 232 | } | ||
| 233 | var fork control.ForkOut | ||
| 234 | if msg, ok := s.runControlInto(u, []string{"repo", "fork", repo.Path()}, &fork); !ok { | ||
| 235 | s.setFlash(w, msg) | ||
| 236 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | ||
| 237 | return | ||
| 238 | } | ||
| 239 | http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther) | ||
| 240 | } | ||
| 241 | |||
| 224 | // repoForUser is repoFor with a write/read permission requirement for a | 242 | // repoForUser is repoFor with a write/read permission requirement for a |
| 225 | // logged-in user. | 243 | // logged-in user. |
| 226 | func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User, | 244 | func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User, |
internal/httpd/routes.go +2
| @@ -152,6 +152,8 @@ func (s *Server) Routes() []Route { | |||
| 152 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, | 152 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 153 | Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, | 153 | Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, |
| 154 | Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, | 154 | Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, |
| 155 | Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, | ||
| 156 | Handler: s.checkOrigin(s.requireUser(s.forkSubmit))}, | ||
| 155 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, | 157 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
| 156 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, | 158 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, |
| 157 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true, | 159 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds/{n}/cancel", Mutating: true, |
internal/httpd/web.go +3 −2
| @@ -483,12 +483,13 @@ type treePage struct { | |||
| 483 | LastCommits map[string]namedCommit | 483 | LastCommits map[string]namedCommit |
| 484 | Tip namedCommit | 484 | Tip namedCommit |
| 485 | Facts repoFacts | 485 | Facts repoFacts |
| 486 | Notice string | ||
| 486 | } | 487 | } |
| 487 | 488 | ||
| 488 | func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { | 489 | func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { |
| 489 | if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { | 490 | if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { |
| 490 | // Empty repo: render the page with no entries rather than 404. | 491 | // Empty repo: render the page with no entries rather than 404. |
| 491 | s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"}) | 492 | s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)}) |
| 492 | return | 493 | return |
| 493 | } | 494 | } |
| 494 | entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) | 495 | entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) |
| @@ -529,7 +530,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | |||
| 529 | s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, | 530 | s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, |
| 530 | readmeName, readmeHTML, | 531 | readmeName, readmeHTML, |
| 531 | s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), | 532 | s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), |
| 532 | s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts}) | 533 | s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)}) |
| 533 | } | 534 | } |
| 534 | 535 | ||
| 535 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | 536 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { |
internal/web/templates/layout.html +2 −1
| @@ -64,7 +64,8 @@ | |||
| 64 | {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} | 64 | {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} |
| 65 | <span class="grow"></span> | 65 | <span class="grow"></span> |
| 66 | {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> | 66 | {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> |
| 67 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>{{end}} | 67 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> |
| 68 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} | ||
| 68 | </div> | 69 | </div> |
| 69 | {{$top := topTab (str $ "Tab")}} | 70 | {{$top := topTab (str $ "Tab")}} |
| 70 | {{/* The header is the same on every tab: it sits above the tab bar, | 71 | {{/* The header is the same on every tab: it sits above the tab bar, |
internal/web/templates/tree.html +1
| @@ -1,5 +1,6 @@ | |||
| 1 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 2 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | ||
| 3 | {{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}} | 4 | {{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}} |
| 4 | <div class="pathbar"> | 5 | <div class="pathbar"> |
| 5 | {{template "refmenu" .}} | 6 | {{template "refmenu" .}} |