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 | 80 | Creating from a fork works anywhere the source can be typed as |
| 81 | 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 | |
| 83 | a CLI operation. Retargeting moves an open | |
| 82 | every fork the viewer can push to in that form, and the repository | |
| 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 | 85 | merge request onto another branch of the same repository and stales the |
| 85 | 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 | 134 | | download an archive | yes | yes | n/a | |
| 134 | 135 | | edit a file | yes | yes | yes | |
| 135 | 136 | | create | yes | yes | yes | |
| 137 | | fork | yes | yes | no | | |
| 136 | 138 | | pin | yes | yes | yes | |
| 137 | 139 | | watch, mute | yes | yes | no | |
| 138 | 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 | 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 | 97 | func runRepoFork(c *Ctx, args []string) int { |
| 91 | 98 | f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"}) |
| 92 | 99 | if err != nil { |
| @@ -133,7 +140,7 @@ func runRepoFork(c *Ctx, args []string) int { | ||
| 133 | 140 | } |
| 134 | 141 | } |
| 135 | 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 | 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 | 16 | &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, |
| 17 | 17 | &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, |
| 18 | 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 | 22 | for _, p := range payloads { |
| 22 | 23 | name := reflect.TypeOf(p).Elem().Name() |
| @@ -39,7 +40,8 @@ func TestPayloadFieldsAreTagged(t *testing.T) { | ||
| 39 | 40 | types := []any{ |
| 40 | 41 | Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, |
| 41 | 42 | ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, |
| 42 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, SearchResult{}, ReviewOut{}, | |
| 43 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, | |
| 44 | SearchResult{}, ReviewOut{}, | |
| 43 | 45 | CheckOut{}, CommitOut{}, ServerOut{}, |
| 44 | 46 | } |
| 45 | 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 | 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 | 242 | // repoForUser is repoFor with a write/read permission requirement for a |
| 225 | 243 | // logged-in user. |
| 226 | 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 | 152 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 153 | 153 | Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, |
| 154 | 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 | 157 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
| 156 | 158 | Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))}, |
| 157 | 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 | 483 | LastCommits map[string]namedCommit |
| 484 | 484 | Tip namedCommit |
| 485 | 485 | Facts repoFacts |
| 486 | Notice string | |
| 486 | 487 | } |
| 487 | 488 | |
| 488 | 489 | func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) { |
| 489 | 490 | if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil { |
| 490 | 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 | 493 | return |
| 493 | 494 | } |
| 494 | 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 | 530 | s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, |
| 530 | 531 | readmeName, readmeHTML, |
| 531 | 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 | 536 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { |
internal/web/templates/layout.html +2 −1
| @@ -64,7 +64,8 @@ | ||
| 64 | 64 | {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} |
| 65 | 65 | <span class="grow"></span> |
| 66 | 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 | 69 | </div> |
| 69 | 70 | {{$top := topTab (str $ "Tab")}} |
| 70 | 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 | 1 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 3 | 4 | {{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}} |
| 4 | 5 | <div class="pathbar"> |
| 5 | 6 | {{template "refmenu" .}} |