Commit a733a81313
Verified · cmc
e2e/design_test.go +100
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "encoding/json" | |
| 5 | "net/url" | |
| 4 | 6 | "os" |
| 5 | 7 | "path/filepath" |
| 6 | 8 | "strings" |
| @@ -64,3 +66,101 @@ func TestReadmeRelativeLinks(t *testing.T) { | ||
| 64 | 66 | } |
| 65 | 67 | } |
| 66 | 68 | } |
| 69 | ||
| 70 | func TestWebInteractions(t *testing.T) { | |
| 71 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 72 | aliceKey := inst.newKey(t, "alice") | |
| 73 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 74 | if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "theorg"); code != 0 { | |
| 75 | t.Fatal("org create failed") | |
| 76 | } | |
| 77 | ||
| 78 | out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json") | |
| 79 | if code != 0 { | |
| 80 | t.Fatal("web login failed") | |
| 81 | } | |
| 82 | var env2 struct { | |
| 83 | Data struct { | |
| 84 | URL string `json:"url"` | |
| 85 | } `json:"data"` | |
| 86 | } | |
| 87 | json.Unmarshal([]byte(out), &env2) | |
| 88 | browser := newBrowser(t) | |
| 89 | browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]) | |
| 90 | ||
| 91 | // Create a repo under the org through the web form. | |
| 92 | if status, _ := browserPost(t, browser, inst.base()+"/new", | |
| 93 | url.Values{"owner": {"theorg"}, "name": {"webborn"}, "visibility": {"public"}}); status != 200 { | |
| 94 | t.Fatalf("org repo via web: %d", status) | |
| 95 | } | |
| 96 | if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "theorg/webborn"); code != 0 { | |
| 97 | t.Fatalf("org repo missing: %s", out) | |
| 98 | } | |
| 99 | ||
| 100 | // Pin from the web; the repo header reflects it and the dashboard | |
| 101 | // lists it; a second toggle unpins. | |
| 102 | if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}); status != 200 { | |
| 103 | t.Fatal("pin toggle failed") | |
| 104 | } | |
| 105 | _, body := browserGet(t, browser, inst.base()+"/theorg/webborn") | |
| 106 | if !strings.Contains(body, "★ pinned") { | |
| 107 | t.Fatal("repo header not pinned") | |
| 108 | } | |
| 109 | if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") { | |
| 110 | t.Fatal("dashboard missing pinned repo") | |
| 111 | } | |
| 112 | browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}) | |
| 113 | if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "☆ pin") { | |
| 114 | t.Fatal("unpin failed") | |
| 115 | } | |
| 116 | ||
| 117 | // New issue with labels through the form; label chips filter. | |
| 118 | if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new", | |
| 119 | url.Values{"title": {"styled"}, "body": {"b"}, "labels": {"bug ui"}}); status != 200 { | |
| 120 | t.Fatal("issue via web failed") | |
| 121 | } | |
| 122 | if status, _ := browserPost(t, browser, inst.base()+"/theorg/webborn/issues/new", | |
| 123 | url.Values{"title": {"plain"}}); status != 200 { | |
| 124 | t.Fatal("second issue failed") | |
| 125 | } | |
| 126 | _, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues?label=bug") | |
| 127 | if !strings.Contains(body, "styled") || strings.Contains(body, ">plain<") { | |
| 128 | t.Fatalf("label filter wrong:\n%s", body) | |
| 129 | } | |
| 130 | _, body = browserGet(t, browser, inst.base()+"/theorg/webborn/issues/1") | |
| 131 | if !strings.Contains(body, `href="/theorg/webborn/issues?label=bug"`) || | |
| 132 | !strings.Contains(body, `href="/alice">alice</a>`) { | |
| 133 | t.Fatal("issue page chips/author not linked") | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | func TestCommitParentLinks(t *testing.T) { | |
| 138 | inst := startInstance(t) | |
| 139 | aliceKey := inst.newKey(t, "alice") | |
| 140 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 141 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 142 | t.Fatal("repo create failed") | |
| 143 | } | |
| 144 | work := t.TempDir() | |
| 145 | env := inst.gitEnv(aliceKey) | |
| 146 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 147 | dir := filepath.Join(work, "w") | |
| 148 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 149 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 150 | mustGit(t, dir, env, "add", ".") | |
| 151 | mustGit(t, dir, env, "commit", "-q", "-m", "first") | |
| 152 | first := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 153 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b\n"), 0o644) | |
| 154 | mustGit(t, dir, env, "commit", "-qam", "second") | |
| 155 | head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | |
| 156 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 157 | ||
| 158 | _, body := inst.get(t, "/alice/app/commit/"+head) | |
| 159 | if !strings.Contains(body, `href="/alice/app/commit/`+first+`"`) { | |
| 160 | t.Fatal("parent commit not linked") | |
| 161 | } | |
| 162 | _, body = inst.get(t, "/alice/app/commit/"+first) | |
| 163 | if strings.Contains(body, ">parent") { | |
| 164 | t.Fatal("root commit shows a parent") | |
| 165 | } | |
| 166 | } | |
internal/gitutil/messages.go +9
| @@ -8,6 +8,15 @@ import ( | ||
| 8 | 8 | |
| 9 | 9 | const zeroSHA = "0000000000000000000000000000000000000000" |
| 10 | 10 | |
| 11 | // Parents returns a commit's parent shas. | |
| 12 | func Parents(dir, sha string) []string { | |
| 13 | out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%P", sha).Output() | |
| 14 | if err != nil { | |
| 15 | return nil | |
| 16 | } | |
| 17 | return strings.Fields(string(out)) | |
| 18 | } | |
| 19 | ||
| 11 | 20 | // LastCommitDate returns the committer date (YYYY-MM-DD) of the ref tip, |
| 12 | 21 | // or "" for empty repos. |
| 13 | 22 | func LastCommitDate(dir, ref string) string { |
internal/httpd/accounts.go +68 −12
| @@ -104,12 +104,30 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | ||
| 104 | 104 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 107 | // adminOrgs lists organizations the user administers, for owner pickers. | |
| 108 | func (s *Server) adminOrgs(u store.User) []string { | |
| 109 | var out []string | |
| 110 | if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil { | |
| 111 | for _, o := range orgs { | |
| 112 | if o.Role == "admin" { | |
| 113 | out = append(out, o.Username) | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 117 | return out | |
| 118 | } | |
| 119 | ||
| 120 | func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { | |
| 108 | 121 | s.render(w, "new.html", struct { |
| 109 | 122 | Site string |
| 110 | 123 | Viewer string |
| 124 | Orgs []string | |
| 111 | 125 | Error string |
| 112 | }{s.siteName(), u.Username, ""}) | |
| 126 | }{s.siteName(), u.Username, s.adminOrgs(u), errMsg}) | |
| 127 | } | |
| 128 | ||
| 129 | func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 130 | s.renderNewRepo(w, u, "") | |
| 113 | 131 | } |
| 114 | 132 | |
| 115 | 133 | func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| @@ -118,29 +136,56 @@ func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.U | ||
| 118 | 136 | if r.FormValue("visibility") == "private" { |
| 119 | 137 | visibility = "private" |
| 120 | 138 | } |
| 121 | fail := func(msg string) { | |
| 122 | s.render(w, "new.html", struct { | |
| 123 | Site string | |
| 124 | Viewer string | |
| 125 | Error string | |
| 126 | }{s.siteName(), u.Username, msg}) | |
| 127 | } | |
| 139 | fail := func(msg string) { s.renderNewRepo(w, u, msg) } | |
| 128 | 140 | if err := policy.ValidateName(name); err != nil { |
| 129 | 141 | fail(err.Error()) |
| 130 | 142 | return |
| 131 | 143 | } |
| 132 | id, err := s.st.CreateRepo("user", u.ID, name, visibility) | |
| 144 | // Owner: yourself, or an org you admin — same rule as repo create. | |
| 145 | owner := r.FormValue("owner") | |
| 146 | ownerKind, ownerID := "user", u.ID | |
| 147 | if owner == "" { | |
| 148 | owner = u.Username | |
| 149 | } | |
| 150 | if owner != u.Username { | |
| 151 | org, err := s.st.OrgByName(owner) | |
| 152 | if err != nil { | |
| 153 | fail("no such organization") | |
| 154 | return | |
| 155 | } | |
| 156 | role, _ := s.st.OrgRole(org.ID, u.ID) | |
| 157 | if role != "admin" { | |
| 158 | fail("only admins of " + owner + " can create repositories there") | |
| 159 | return | |
| 160 | } | |
| 161 | ownerKind, ownerID = "org", org.ID | |
| 162 | } | |
| 163 | id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility) | |
| 133 | 164 | if err != nil { |
| 134 | 165 | fail(err.Error()) |
| 135 | 166 | return |
| 136 | 167 | } |
| 137 | dir := control.RepoDir(s.cfg.Server.Root, u.Username, name) | |
| 168 | dir := control.RepoDir(s.cfg.Server.Root, owner, name) | |
| 138 | 169 | if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil { |
| 139 | 170 | s.st.DeleteRepo(id) |
| 140 | 171 | fail("initializing repository failed") |
| 141 | 172 | return |
| 142 | 173 | } |
| 143 | http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther) | |
| 174 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) | |
| 175 | } | |
| 176 | ||
| 177 | // pinToggle pins or unpins the repo for the logged-in viewer. | |
| 178 | func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 179 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | |
| 180 | if !ok { | |
| 181 | return | |
| 182 | } | |
| 183 | if s.st.IsPinned(u.ID, repo.ID) { | |
| 184 | s.st.UnpinRepo(u.ID, repo.ID) | |
| 185 | } else { | |
| 186 | s.st.PinRepo(u.ID, repo.ID) | |
| 187 | } | |
| 188 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 144 | 189 | } |
| 145 | 190 | |
| 146 | 191 | // repoForUser is repoFor with a write/read permission requirement for a |
| @@ -258,6 +303,17 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto | ||
| 258 | 303 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 259 | 304 | return |
| 260 | 305 | } |
| 306 | // Labels need write access, matching the SSH rule; ignored otherwise. | |
| 307 | if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 { | |
| 308 | grant, _ := s.st.AccessRole(repo.ID, u.ID) | |
| 309 | if policy.CanWrite(u, repo, grant) { | |
| 310 | if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil { | |
| 311 | for _, l := range labels { | |
| 312 | s.st.SetIssueLabel(repo.ID, iss.ID, l, true) | |
| 313 | } | |
| 314 | } | |
| 315 | } | |
| 316 | } | |
| 261 | 317 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) |
| 262 | 318 | } |
| 263 | 319 | |
internal/httpd/routes.go +2
| @@ -85,6 +85,8 @@ func (s *Server) Routes() []Route { | ||
| 85 | 85 | routes = append(routes, |
| 86 | 86 | Route{Method: "POST", Pattern: "/new", Mutating: true, |
| 87 | 87 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, |
| 88 | Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true, | |
| 89 | Handler: s.checkOrigin(s.requireUser(s.pinToggle))}, | |
| 88 | 90 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", |
| 89 | 91 | Handler: s.requireUser(s.issueCreateForm)}, |
| 90 | 92 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, |
internal/httpd/web.go +25 −2
| @@ -215,6 +215,7 @@ type repoPage struct { | ||
| 215 | 215 | Dir string |
| 216 | 216 | Tab string // active tab in the repo header |
| 217 | 217 | Topics []string |
| 218 | Pinned bool // by the viewer | |
| 218 | 219 | } |
| 219 | 220 | |
| 220 | 221 | // repoFor resolves the repo for a web request; false means 404 was sent. |
| @@ -244,9 +245,14 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 244 | 245 | ref = repo.DefaultBranch |
| 245 | 246 | } |
| 246 | 247 | topics, _ := s.st.ListTopics(repo.ID) |
| 248 | pinned := false | |
| 249 | if viewer.ID != 0 { | |
| 250 | pinned = s.st.IsPinned(viewer.ID, repo.ID) | |
| 251 | } | |
| 247 | 252 | return repoPage{ |
| 248 | 253 | Site: s.siteName(), |
| 249 | 254 | Viewer: viewer.Username, |
| 255 | Pinned: pinned, | |
| 250 | 256 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), |
| 251 | 257 | Repo: repo, |
| 252 | 258 | Ref: ref, |
| @@ -1064,11 +1070,13 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 1064 | 1070 | s.render(w, "commit.html", struct { |
| 1065 | 1071 | repoPage |
| 1066 | 1072 | SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string |
| 1073 | Parents []string | |
| 1067 | 1074 | Sig sigView |
| 1068 | 1075 | Checks []store.CommitStatus |
| 1069 | 1076 | DiffLines []diffLine |
| 1070 | 1077 | }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail, |
| 1071 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines}) | |
| 1078 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, | |
| 1079 | gitutil.Parents(p.Dir, full), v, checks, lines}) | |
| 1072 | 1080 | } |
| 1073 | 1081 | |
| 1074 | 1082 | // labelPalette provides default label chip colors: mid-tone hues that stay |
| @@ -1117,12 +1125,27 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | ||
| 1117 | 1125 | issues[i].Labels = labels[issues[i].ID] |
| 1118 | 1126 | } |
| 1119 | 1127 | } |
| 1128 | // ?label=x narrows to issues carrying that label (chips link here). | |
| 1129 | labelFilter := r.URL.Query().Get("label") | |
| 1130 | if labelFilter != "" { | |
| 1131 | var kept []store.Issue | |
| 1132 | for _, iss := range issues { | |
| 1133 | for _, l := range iss.Labels { | |
| 1134 | if l == labelFilter { | |
| 1135 | kept = append(kept, iss) | |
| 1136 | break | |
| 1137 | } | |
| 1138 | } | |
| 1139 | } | |
| 1140 | issues = kept | |
| 1141 | } | |
| 1120 | 1142 | s.render(w, "issues.html", struct { |
| 1121 | 1143 | repoPage |
| 1122 | 1144 | State string |
| 1145 | Label string | |
| 1123 | 1146 | Issues []store.Issue |
| 1124 | 1147 | LabelColors map[string]template.CSS |
| 1125 | }{p, state, issues, s.labelColors(p.Repo.ID)}) | |
| 1148 | }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)}) | |
| 1126 | 1149 | } |
| 1127 | 1150 | |
| 1128 | 1151 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { |
internal/store/dashboard.go +7
| @@ -74,6 +74,13 @@ func (s *Store) PinRepo(userID, repoID int64) error { | ||
| 74 | 74 | return err |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | func (s *Store) IsPinned(userID, repoID int64) bool { | |
| 78 | var n int | |
| 79 | s.DB.QueryRow("SELECT COUNT(*) FROM repo_pins WHERE user_id = ? AND repo_id = ?", | |
| 80 | userID, repoID).Scan(&n) | |
| 81 | return n > 0 | |
| 82 | } | |
| 83 | ||
| 77 | 84 | func (s *Store) UnpinRepo(userID, repoID int64) error { |
| 78 | 85 | res, err := s.DB.Exec( |
| 79 | 86 | "DELETE FROM repo_pins WHERE user_id = ? AND repo_id = ?", userID, repoID) |
internal/web/static/style.css +13
| @@ -149,6 +149,19 @@ h1.repotitle a { color: var(--fg); } | ||
| 149 | 149 | h1.repotitle a:last-of-type { font-weight: 650; } |
| 150 | 150 | h1.repotitle a:hover { color: var(--accent); text-decoration: none; } |
| 151 | 151 | h1.repotitle .sep { color: var(--muted); margin: 0 0.15em; } |
| 152 | form.pinform { display: inline; margin-left: var(--sp-2); vertical-align: middle; } | |
| 153 | button.pinbtn { | |
| 154 | background: none; | |
| 155 | border: 1px solid var(--line); | |
| 156 | border-radius: var(--r-pill); | |
| 157 | color: var(--muted); | |
| 158 | font-size: var(--fs-0); | |
| 159 | padding: 0.05rem 0.55rem; | |
| 160 | cursor: pointer; | |
| 161 | } | |
| 162 | button.pinbtn:hover { border-color: var(--accent); color: var(--accent); filter: none; } | |
| 163 | button.pinbtn.pinned { color: var(--accent); border-color: var(--accent); } | |
| 164 | a.chip.label:hover, a.chip.topic:hover { text-decoration: none; filter: brightness(1.15); } | |
| 152 | 165 | .repohead .desc { margin: 0 0 var(--sp-2); } |
| 153 | 166 | |
| 154 | 167 | /* repo tabs */ |
internal/web/templates/commit.html +1
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | <div class="commithead"> |
| 5 | 5 | <h2>commit <code>{{.ShortSHA}}</code></h2> |
| 6 | 6 | <p class="meta"><code class="fullsha">{{.SHA}}</code></p> |
| 7 | {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}} | |
| 7 | 8 | <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> |
| 8 | 9 | <p class="meta"><span title="{{.AuthorEmail}}">{{.AuthorName}}</span> <{{.AuthorEmail}}> · {{.Date}} |
| 9 | 10 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> |
internal/web/templates/dashboard.html +2 −2
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | {{range .MRs}}<li> |
| 16 | 16 | <div class="issuemain"> |
| 17 | 17 | <p class="title"><a href="/{{.RepoPath}}/mrs/{{.Number}}">{{.RepoPath}}!{{.Number}} {{.Title}}</a></p> |
| 18 | <p class="meta">{{.Author}} · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | |
| 18 | <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | |
| 19 | 19 | </div> |
| 20 | 20 | </li> |
| 21 | 21 | {{else}}<li class="empty">no open merge requests</li>{{end}} |
| @@ -25,7 +25,7 @@ | ||
| 25 | 25 | {{range .Issues}}<li> |
| 26 | 26 | <div class="issuemain"> |
| 27 | 27 | <p class="title"><a href="/{{.RepoPath}}/issues/{{.Number}}">{{.RepoPath}}#{{.Number}} {{.Title}}</a></p> |
| 28 | <p class="meta">{{.Author}} · {{when .UpdatedAt}}</p> | |
| 28 | <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}</p> | |
| 29 | 29 | </div> |
| 30 | 30 | </li> |
| 31 | 31 | {{else}}<li class="empty">no open issues</li>{{end}} |
internal/web/templates/issue.html +3 −3
| @@ -3,12 +3,12 @@ | ||
| 3 | 3 | {{template "repoheader" .}} |
| 4 | 4 | <h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2> |
| 5 | 5 | <p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> |
| 6 | {{.Issue.Author}} opened this on {{when .Issue.CreatedAt}} | |
| 7 | {{if .Issue.Labels}} · {{range .Issue.Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}{{end}} | |
| 6 | <a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}} | |
| 7 | {{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}} | |
| 8 | 8 | {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}} |
| 9 | 9 | {{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> |
| 10 | 10 | {{if .BodyHTML}}<article class="comment"> |
| 11 | <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> | |
| 11 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> | |
| 12 | 12 | <div class="rendered">{{.BodyHTML}}</div> |
| 13 | 13 | </article>{{end}} |
| 14 | 14 | {{range .Comments}} |
internal/web/templates/issuenew.html +1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> |
| 8 | 8 | <p><input type="text" name="title" placeholder="title" required></p> |
| 9 | 9 | <p><textarea name="body" rows="12">{{.Body}}</textarea></p> |
| 10 | <p><input type="text" name="labels" placeholder="labels, space-separated (write access)"></p> | |
| 10 | 11 | <p><button type="submit">open issue</button></p> |
| 11 | 12 | </form> |
| 12 | 13 | {{end}} |
internal/web/templates/issues.html +3 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> |
| 9 | 9 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> |
| 10 | 10 | </nav> |
| 11 | {{if .Label}}<p class="meta">label: <span class="chip label" style="{{index .LabelColors .Label}}">{{.Label}}</span> <a href="?state={{.State}}">clear</a></p>{{end}} | |
| 11 | 12 | <span class="spacer"></span> |
| 12 | 13 | <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p> |
| 13 | 14 | </div> |
| @@ -15,8 +16,8 @@ | ||
| 15 | 16 | {{range .Issues}}<li> |
| 16 | 17 | <div class="issuemain"> |
| 17 | 18 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> |
| 18 | {{range .Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}</p> | |
| 19 | <p class="meta">#{{.Number}} opened by {{.Author}}{{if .Milestone}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones">{{.Milestone}}</a>{{end}}</p> | |
| 19 | {{range .Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="?label={{.}}">{{.}}</a> {{end}}</p> | |
| 20 | <p class="meta">#{{.Number}} opened by <a href="/{{.Author}}">{{.Author}}</a>{{if .Milestone}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones">{{.Milestone}}</a>{{end}}</p> | |
| 20 | 21 | </div> |
| 21 | 22 | <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span> |
| 22 | 23 | </li> |
internal/web/templates/layout.html +1 −1
| @@ -29,7 +29,7 @@ | ||
| 29 | 29 | |
| 30 | 30 | {{define "repoheader"}} |
| 31 | 31 | <div class="repohead"> |
| 32 | <h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</h1> | |
| 32 | <h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1> | |
| 33 | 33 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} |
| 34 | 34 | {{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} |
| 35 | 35 | <nav class="tabs"> |
internal/web/templates/mr.html +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{template "repoheader" .}} |
| 4 | 4 | <h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2> |
| 5 | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | {{.MR.Author}} wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} | |
| 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} | |
| 7 | 7 | at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> |
| 8 | 8 | {{if .BodyHTML}}<article class="comment"> |
| 9 | 9 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> |
internal/web/templates/mrs.html +1 −1
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | {{range .MRs}}<li> |
| 15 | 15 | <div class="issuemain"> |
| 16 | 16 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p> |
| 17 | <p class="meta">!{{.Number}} by {{.Author}} · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p> | |
| 17 | <p class="meta">!{{.Number}} by <a href="/{{.Author}}">{{.Author}}</a> · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p> | |
| 18 | 18 | </div> |
| 19 | 19 | <span class="chip chip-{{.State}}">{{.State}}</span> |
| 20 | 20 | </li> |
internal/web/templates/new.html +5 −1
| @@ -3,7 +3,11 @@ | ||
| 3 | 3 | <h1>new repository</h1> |
| 4 | 4 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 5 | 5 | <form method="post" action="/new"> |
| 6 | <p><label>name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label> (under {{.Viewer}}/)</p> | |
| 6 | <p><label>owner <select name="owner"> | |
| 7 | <option value="{{.Viewer}}">{{.Viewer}}</option> | |
| 8 | {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}} | |
| 9 | </select></label> | |
| 10 | <label>/ name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label></p> | |
| 7 | 11 | <p><label><input type="radio" name="visibility" value="public" checked> public</label> |
| 8 | 12 | <label><input type="radio" name="visibility" value="private"> private</label></p> |
| 9 | 13 | <p><button type="submit">create</button></p> |