web: the owner page dispatches profile show !122
10 files changed, +207 −108
e2e/profile_test.go +41 −1
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 4 | 6 | "strings" |
| 5 | 7 | "testing" |
| 6 | 8 | ) |
| @@ -46,6 +48,44 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 46 | 48 | } |
| 47 | 49 | } |
| 48 | 50 | |
| 51 | // A profile's repository rows carry the listing metadata the web | |
| 52 | // shows: topics, license, default branch, last commit. Without them | |
| 53 | // the web had to decorate the rows itself, which is what kept it | |
| 54 | // reading the store (krz/gitbay#47). | |
| 55 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/public-tool", "cli", "go"); code != 0 { | |
| 56 | t.Fatalf("topics add: %s", errOut) | |
| 57 | } | |
| 58 | env := inst.gitEnv(aliceKey) | |
| 59 | work := t.TempDir() | |
| 60 | mustGit(t, work, env, "clone", inst.sshURL("alice/public-tool"), "w") | |
| 61 | dir := filepath.Join(work, "w") | |
| 62 | os.WriteFile(filepath.Join(dir, "LICENSE"), []byte("MIT License\n\nPermission is hereby granted, free of charge\n"), 0o644) | |
| 63 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 64 | mustGit(t, dir, env, "add", ".") | |
| 65 | mustGit(t, dir, env, "commit", "-q", "-m", "add license") | |
| 66 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 67 | ||
| 68 | out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") | |
| 69 | for _, want := range []string{`"cli"`, `"go"`, `"license":"MIT"`, `"default_branch":"main"`, `"updated"`} { | |
| 70 | if !strings.Contains(out, want) { | |
| 71 | t.Errorf("profile repo row missing %q: %s", want, out) | |
| 72 | } | |
| 73 | } | |
| 74 | // The owner page renders those rows, having dispatched the same | |
| 75 | // command rather than assembling them from the store again. | |
| 76 | status, body := inst.get(t, "/alice") | |
| 77 | if status != 200 { | |
| 78 | t.Fatalf("owner page: %d", status) | |
| 79 | } | |
| 80 | for _, want := range []string{">public-tool<", "?q=cli", "MIT", "updated "} { | |
| 81 | if !strings.Contains(body, want) { | |
| 82 | t.Errorf("owner page missing %q:\n%s", want, body) | |
| 83 | } | |
| 84 | } | |
| 85 | if strings.Contains(body, "secret") { | |
| 86 | t.Fatal("owner page leaks a private repo") | |
| 87 | } | |
| 88 | ||
| 49 | 89 | // A stranger sees the public repository and not the private one. |
| 50 | 90 | out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") |
| 51 | 91 | if !strings.Contains(out, `"path":"alice/public-tool"`) { |
| @@ -123,7 +163,7 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 123 | 163 | } |
| 124 | 164 | // Org emphasis parsed, not left as literal slashes the way the |
| 125 | 165 | // markdown renderer would. |
| 126 | status, body := inst.get(t, "/alice") | |
| 166 | _, body = inst.get(t, "/alice") | |
| 127 | 167 | if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { |
| 128 | 168 | t.Fatalf("org about not rendered as org: %s", body) |
| 129 | 169 | } |
internal/control/license.go added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | ||
| 6 | "gitbay.org/gitbay/internal/gitutil" | |
| 7 | ) | |
| 8 | ||
| 9 | // licenseNames maps content markers to display names, checked in order. | |
| 10 | var licenseChecks = []struct{ marker, name string }{ | |
| 11 | {"MIT License", "MIT"}, | |
| 12 | {"Permission is hereby granted, free of charge", "MIT"}, | |
| 13 | {"Apache License", "Apache-2.0"}, | |
| 14 | {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"}, | |
| 15 | {"GNU GENERAL PUBLIC LICENSE", "GPL"}, | |
| 16 | {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"}, | |
| 17 | {"Mozilla Public License", "MPL-2.0"}, | |
| 18 | {"BSD 3-Clause", "BSD-3-Clause"}, | |
| 19 | {"BSD 2-Clause", "BSD-2-Clause"}, | |
| 20 | {"Redistribution and use in source and binary forms", "BSD"}, | |
| 21 | {"BSD Zero Clause License", "0BSD"}, | |
| 22 | {"Zero Clause BSD", "0BSD"}, | |
| 23 | // ISC is the 0BSD grant plus the notice-retention clause; match on the | |
| 24 | // clause so title-less 0BSD files don't read as ISC. | |
| 25 | {"hereby granted, provided that the above copyright notice", "ISC"}, | |
| 26 | {"Permission to use, copy, modify, and/or distribute", "0BSD"}, | |
| 27 | {"This is free and unencumbered software", "Unlicense"}, | |
| 28 | {"CC0", "CC0"}, | |
| 29 | } | |
| 30 | ||
| 31 | // DetectLicense reports the repo's license name from a conventional file | |
| 32 | // at the ref root, or "". It lives here because profile show and the | |
| 33 | // web's repo listings both report it. | |
| 34 | func DetectLicense(dir, ref string) string { | |
| 35 | for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "UNLICENSE"} { | |
| 36 | raw, err := gitutil.ReadBlob(dir, ref, name, 2048) | |
| 37 | if err != nil { | |
| 38 | continue | |
| 39 | } | |
| 40 | // Collapse whitespace so markers match across line wraps. | |
| 41 | text := strings.Join(strings.Fields(string(raw)), " ") | |
| 42 | for _, c := range licenseChecks { | |
| 43 | if strings.Contains(text, c.marker) { | |
| 44 | return c.name | |
| 45 | } | |
| 46 | } | |
| 47 | return "license" | |
| 48 | } | |
| 49 | return "" | |
| 50 | } | |
internal/control/profile.go +25 −8
| @@ -184,11 +184,19 @@ type profileMember struct { | ||
| 184 | 184 | Role string `json:"role,omitempty"` |
| 185 | 185 | } |
| 186 | 186 | |
| 187 | // profileRepo is one repository as a profile lists it. The listing | |
| 188 | // metadata — topics, license, last commit — is here because a profile is | |
| 189 | // a listing: a client that renders repositories without it is showing | |
| 190 | // less than the web does, which is why the web kept its own copy. | |
| 187 | 191 | type profileRepo struct { |
| 188 | Path string `json:"path"` | |
| 189 | Visibility string `json:"visibility"` | |
| 190 | Description string `json:"description,omitempty"` | |
| 191 | Archived bool `json:"archived,omitempty"` | |
| 192 | Path string `json:"path"` | |
| 193 | Visibility string `json:"visibility"` | |
| 194 | Description string `json:"description,omitempty"` | |
| 195 | DefaultBranch string `json:"default_branch"` | |
| 196 | Topics []string `json:"topics,omitempty"` | |
| 197 | License string `json:"license,omitempty"` | |
| 198 | Updated string `json:"updated,omitempty"` | |
| 199 | Archived bool `json:"archived,omitempty"` | |
| 192 | 200 | } |
| 193 | 201 | |
| 194 | 202 | // activityDay is one day's contribution count. Days with nothing are |
| @@ -293,11 +301,20 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 293 | 301 | if !policy.CanRead(c.User, repo, grant) { |
| 294 | 302 | continue |
| 295 | 303 | } |
| 304 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 305 | topics, err := c.Store.ListTopics(repo.ID) | |
| 306 | if err != nil { | |
| 307 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 308 | } | |
| 296 | 309 | d.Repos = append(d.Repos, profileRepo{ |
| 297 | Path: repo.Path(), | |
| 298 | Visibility: repo.Visibility, | |
| 299 | Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)), | |
| 300 | Archived: repo.Settings.Archived, | |
| 310 | Path: repo.Path(), | |
| 311 | Visibility: repo.Visibility, | |
| 312 | Description: gitutil.ReadDescription(dir), | |
| 313 | DefaultBranch: repo.DefaultBranch, | |
| 314 | Topics: topics, | |
| 315 | License: DetectLicense(dir, repo.DefaultBranch), | |
| 316 | Updated: gitutil.LastCommitDate(dir, repo.DefaultBranch), | |
| 317 | Archived: repo.Settings.Archived, | |
| 301 | 318 | }) |
| 302 | 319 | } |
| 303 | 320 | |
internal/httpd/activity.go +1 −10
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | import ( | |
| 4 | "time" | |
| 5 | ||
| 6 | "gitbay.org/gitbay/internal/control" | |
| 7 | ) | |
| 3 | import "time" | |
| 8 | 4 | |
| 9 | 5 | // activityDay is one cell of the graph; Level buckets Count into the five |
| 10 | 6 | // intensity classes the stylesheet colors. |
| @@ -59,8 +55,3 @@ func activityLevel(n int) int { | ||
| 59 | 55 | return 4 |
| 60 | 56 | } |
| 61 | 57 | } |
| 62 | ||
| 63 | // activitySince is the first day the grid can show, for the query bound. | |
| 64 | // activitySince is control.ActivityWindow: the profile command and the | |
| 65 | // web must report the same year, so the span has one definition. | |
| 66 | func activitySince() string { return control.ActivityWindow() } | |
internal/httpd/control.go +15 −3
| @@ -70,6 +70,18 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg | ||
| 70 | 70 | // target. Read handlers use it so the web renders exactly what the CLI |
| 71 | 71 | // and the API return, rather than reaching past the registry into git. |
| 72 | 72 | func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) { |
| 73 | code, msg := s.dispatchInto(u, argv, target) | |
| 74 | return msg, code == protocol.ExitOK | |
| 75 | } | |
| 76 | ||
| 77 | // runControlIntoCode is runControlInto for handlers that have to tell | |
| 78 | // "no such thing" from "that failed": a profile page 404s on the first | |
| 79 | // and errors on the second. | |
| 80 | func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) { | |
| 81 | return s.dispatchInto(u, argv, target) | |
| 82 | } | |
| 83 | ||
| 84 | func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) { | |
| 73 | 85 | var stdout, stderr bytes.Buffer |
| 74 | 86 | ctx := &control.Ctx{ |
| 75 | 87 | User: u, |
| @@ -94,14 +106,14 @@ func (s *Server) runControlInto(u store.User, argv []string, target any) (msg st | ||
| 94 | 106 | if m == "" { |
| 95 | 107 | m = strings.TrimSpace(stderr.String()) |
| 96 | 108 | } |
| 97 | return m, false | |
| 109 | return code, m | |
| 98 | 110 | } |
| 99 | 111 | if len(env.Data) > 0 { |
| 100 | 112 | if err := json.Unmarshal(env.Data, target); err != nil { |
| 101 | return "unreadable response", false | |
| 113 | return protocol.ExitFailure, "unreadable response" | |
| 102 | 114 | } |
| 103 | 115 | } |
| 104 | return "", true | |
| 116 | return protocol.ExitOK, "" | |
| 105 | 117 | } |
| 106 | 118 | |
| 107 | 119 | // runControlJSON runs a command in JSON mode and returns its data object. |
internal/httpd/facts.go +2 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "gitbay.org/gitbay/internal/control" | |
| 4 | 5 | "gitbay.org/gitbay/internal/gitutil" |
| 5 | 6 | ) |
| 6 | 7 | |
| @@ -29,7 +30,7 @@ const maxContributors = 12 | ||
| 29 | 30 | func (s *Server) factsFor(p repoPage) repoFacts { |
| 30 | 31 | f := repoFacts{ |
| 31 | 32 | Commits: gitutil.CountCommits(p.Dir, p.Ref), |
| 32 | License: detectLicense(p.Dir, p.Ref), | |
| 33 | License: control.DetectLicense(p.Dir, p.Ref), | |
| 33 | 34 | } |
| 34 | 35 | if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil { |
| 35 | 36 | f.Branches = len(heads) |
internal/httpd/readme.go −42
| @@ -11,48 +11,6 @@ import ( | ||
| 11 | 11 | "gitbay.org/gitbay/internal/gitutil" |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | // licenseNames maps content markers to display names, checked in order. | |
| 15 | var licenseChecks = []struct{ marker, name string }{ | |
| 16 | {"MIT License", "MIT"}, | |
| 17 | {"Permission is hereby granted, free of charge", "MIT"}, | |
| 18 | {"Apache License", "Apache-2.0"}, | |
| 19 | {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"}, | |
| 20 | {"GNU GENERAL PUBLIC LICENSE", "GPL"}, | |
| 21 | {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"}, | |
| 22 | {"Mozilla Public License", "MPL-2.0"}, | |
| 23 | {"BSD 3-Clause", "BSD-3-Clause"}, | |
| 24 | {"BSD 2-Clause", "BSD-2-Clause"}, | |
| 25 | {"Redistribution and use in source and binary forms", "BSD"}, | |
| 26 | {"BSD Zero Clause License", "0BSD"}, | |
| 27 | {"Zero Clause BSD", "0BSD"}, | |
| 28 | // ISC is the 0BSD grant plus the notice-retention clause; match on the | |
| 29 | // clause so title-less 0BSD files don't read as ISC. | |
| 30 | {"hereby granted, provided that the above copyright notice", "ISC"}, | |
| 31 | {"Permission to use, copy, modify, and/or distribute", "0BSD"}, | |
| 32 | {"This is free and unencumbered software", "Unlicense"}, | |
| 33 | {"CC0", "CC0"}, | |
| 34 | } | |
| 35 | ||
| 36 | // detectLicense reports the repo's license name from a conventional file | |
| 37 | // at the ref root, or "". | |
| 38 | func detectLicense(dir, ref string) string { | |
| 39 | for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "UNLICENSE"} { | |
| 40 | raw, err := gitutil.ReadBlob(dir, ref, name, 2048) | |
| 41 | if err != nil { | |
| 42 | continue | |
| 43 | } | |
| 44 | // Collapse whitespace so markers match across line wraps. | |
| 45 | text := strings.Join(strings.Fields(string(raw)), " ") | |
| 46 | for _, c := range licenseChecks { | |
| 47 | if strings.Contains(text, c.marker) { | |
| 48 | return c.name | |
| 49 | } | |
| 50 | } | |
| 51 | return "license" | |
| 52 | } | |
| 53 | return "" | |
| 54 | } | |
| 55 | ||
| 56 | 14 | // rewriteRelativeLinks makes relative hrefs and srcs in rendered repo |
| 57 | 15 | // content resolve on the forge: links go to blob pages, images to raw. |
| 58 | 16 | // go-org exports .org links as .html, so an .html target whose .org (or |
internal/httpd/web.go +68 −38
| @@ -11,6 +11,7 @@ import ( | ||
| 11 | 11 | "path/filepath" |
| 12 | 12 | |
| 13 | 13 | "gitbay.org/gitbay/internal/policy" |
| 14 | "gitbay.org/gitbay/internal/protocol" | |
| 14 | 15 | "html/template" |
| 15 | 16 | "net/http" |
| 16 | 17 | "net/url" |
| @@ -107,6 +108,10 @@ type describedRepo struct { | ||
| 107 | 108 | Updated string |
| 108 | 109 | } |
| 109 | 110 | |
| 111 | // Archived flattens the settings flag so the reporow partial can read the | |
| 112 | // same field name from a describedRepo and from a profile's repo row. | |
| 113 | func (d describedRepo) Archived() bool { return d.Settings.Archived } | |
| 114 | ||
| 110 | 115 | func (s *Server) describeAll(repos []store.Repo) []describedRepo { |
| 111 | 116 | var out []describedRepo |
| 112 | 117 | for _, r := range repos { |
| @@ -114,7 +119,7 @@ func (s *Server) describeAll(repos []store.Repo) []describedRepo { | ||
| 114 | 119 | d := describedRepo{ |
| 115 | 120 | Repo: r, |
| 116 | 121 | Desc: gitutil.ReadDescription(dir), |
| 117 | License: detectLicense(dir, r.DefaultBranch), | |
| 122 | License: control.DetectLicense(dir, r.DefaultBranch), | |
| 118 | 123 | Updated: gitutil.LastCommitDate(dir, r.DefaultBranch), |
| 119 | 124 | } |
| 120 | 125 | d.Topics, _ = s.st.ListTopics(r.ID) |
| @@ -346,6 +351,47 @@ func crumbs(p repoPage, kind, filePath string) []crumb { | ||
| 346 | 351 | return cs |
| 347 | 352 | } |
| 348 | 353 | |
| 354 | // profileView is profile show's payload, shaped for the templates. The | |
| 355 | // repo rows carry the same names the reporow partial reads, so a profile | |
| 356 | // listing renders identically to explore's. | |
| 357 | type profileView struct { | |
| 358 | Name string `json:"name"` | |
| 359 | Kind string `json:"kind"` | |
| 360 | Description string `json:"description"` | |
| 361 | Website string `json:"website"` | |
| 362 | About string `json:"about"` | |
| 363 | AboutFormat string `json:"about_format"` | |
| 364 | Links []store.ProfileLink `json:"links"` | |
| 365 | Orgs []profileMember `json:"orgs"` | |
| 366 | Members []profileMember `json:"members"` | |
| 367 | Repos []profileRepoRow `json:"repos"` | |
| 368 | Activity []struct { | |
| 369 | Date string `json:"date"` | |
| 370 | Count int `json:"count"` | |
| 371 | } `json:"activity"` | |
| 372 | } | |
| 373 | ||
| 374 | type profileMember struct { | |
| 375 | Name string `json:"name"` | |
| 376 | Role string `json:"role"` | |
| 377 | } | |
| 378 | ||
| 379 | // profileRepoRow is one repository row on a profile. Path arrives as | |
| 380 | // owner/name; OwnerName and Name are split out for the partial. | |
| 381 | type profileRepoRow struct { | |
| 382 | Path string `json:"path"` | |
| 383 | Visibility string `json:"visibility"` | |
| 384 | Desc string `json:"description"` | |
| 385 | DefaultBranch string `json:"default_branch"` | |
| 386 | Topics []string `json:"topics"` | |
| 387 | License string `json:"license"` | |
| 388 | Updated string `json:"updated"` | |
| 389 | Archived bool `json:"archived"` | |
| 390 | } | |
| 391 | ||
| 392 | func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner } | |
| 393 | func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name } | |
| 394 | ||
| 349 | 395 | // ownerPage renders /{owner} for users and orgs: the repositories the |
| 350 | 396 | // viewer may see, org membership either direction. Owner names are not |
| 351 | 397 | // secret (they are on every commit); repository visibility rules hold. |
| @@ -356,62 +402,46 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 356 | 402 | viewer = s.viewer(r) |
| 357 | 403 | } |
| 358 | 404 | |
| 359 | kind := "user" | |
| 360 | var ownerID int64 | |
| 361 | var members []store.OrgMember | |
| 362 | var orgs []store.OrgMember | |
| 363 | if u, err := s.st.UserByUsername(name); err == nil { | |
| 364 | ownerID = u.ID | |
| 365 | orgs, _ = s.st.ListOrgsForUser(u.ID) | |
| 366 | } else if o, err := s.st.OrgByName(name); err == nil { | |
| 367 | kind, ownerID = "org", o.ID | |
| 368 | members, _ = s.st.OrgMembers(o.ID) | |
| 369 | } else { | |
| 405 | // Everything on this page — membership, the repositories this viewer | |
| 406 | // may see, the activity year — comes from profile show, so the page | |
| 407 | // and the command cannot report different things. | |
| 408 | var d profileView | |
| 409 | code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d) | |
| 410 | switch { | |
| 411 | case code == protocol.ExitNotFound: | |
| 370 | 412 | s.notFound(w, r) |
| 371 | 413 | return |
| 372 | } | |
| 373 | profile, _ := s.st.OwnerProfile(kind, ownerID) | |
| 374 | ||
| 375 | all, err := s.st.ListReposForOwner(kind, ownerID) | |
| 376 | if err != nil { | |
| 414 | case code != protocol.ExitOK: | |
| 415 | log.Printf("profile %s: %s", name, msg) | |
| 377 | 416 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 378 | 417 | return |
| 379 | 418 | } |
| 380 | var visible []store.Repo | |
| 381 | for _, repo := range all { | |
| 382 | grant := "" | |
| 383 | if viewer.ID != 0 { | |
| 384 | grant, _ = s.st.AccessRole(repo.ID, viewer.ID) | |
| 385 | } | |
| 386 | if policy.CanRead(viewer, repo, grant) { | |
| 387 | visible = append(visible, repo) | |
| 388 | } | |
| 389 | } | |
| 390 | var counts map[string]int | |
| 391 | if kind == "user" { | |
| 392 | counts, _ = s.st.ActivityByDay(ownerID, activitySince()) | |
| 393 | } else { | |
| 394 | counts, _ = s.st.OrgActivityByDay(ownerID, activitySince()) | |
| 419 | ||
| 420 | counts := make(map[string]int, len(d.Activity)) | |
| 421 | for _, day := range d.Activity { | |
| 422 | counts[day.Date] = day.Count | |
| 395 | 423 | } |
| 396 | 424 | weeks, activityTotal := activityGrid(counts) |
| 397 | 425 | |
| 398 | teams, canAdmin := s.orgAdminView(viewer, kind, name) | |
| 426 | teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) | |
| 427 | profile := store.Profile{Description: d.Description, Website: d.Website, | |
| 428 | About: d.About, AboutFormat: d.AboutFormat, Links: d.Links} | |
| 399 | 429 | s.render(w, "owner.html", struct { |
| 400 | 430 | basePage |
| 401 | 431 | Owner string |
| 402 | 432 | Kind string |
| 403 | 433 | Profile store.Profile |
| 404 | 434 | AboutHTML template.HTML |
| 405 | Repos []describedRepo | |
| 406 | Members []store.OrgMember | |
| 407 | Orgs []store.OrgMember | |
| 435 | Repos []profileRepoRow | |
| 436 | Members []profileMember | |
| 437 | Orgs []profileMember | |
| 408 | 438 | Activity []activityWeek |
| 409 | 439 | ActivityTotal int |
| 410 | 440 | Teams []teamView |
| 411 | 441 | CanAdmin bool |
| 412 | 442 | Notice string |
| 413 | }{s.baseFor(viewer), name, kind, profile, aboutHTML(profile), | |
| 414 | s.describeAll(visible), members, orgs, | |
| 443 | }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), | |
| 444 | d.Repos, d.Members, d.Orgs, | |
| 415 | 445 | weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")}) |
| 416 | 446 | } |
| 417 | 447 | |
internal/web/templates/layout.html +1 −1
| @@ -93,7 +93,7 @@ | ||
| 93 | 93 | {{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}} |
| 94 | 94 | |
| 95 | 95 | {{define "reporow"}}<li> |
| 96 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Settings.Archived}} <span class="chip">Archived</span>{{end}}</p> | |
| 96 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p> | |
| 97 | 97 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} |
| 98 | 98 | {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} |
| 99 | 99 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> |
internal/web/templates/owner.html +4 −4
| @@ -5,8 +5,8 @@ | ||
| 5 | 5 | {{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}} |
| 6 | 6 | {{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}} |
| 7 | 7 | {{if .Profile.Links}}<p class="meta">{{range $i, $l := .Profile.Links}}{{if $i}} · {{end}}<a href="{{$l.URL}}" rel="nofollow me">{{if $l.Label}}{{$l.Label}}{{else}}{{$l.URL}}{{end}}</a>{{end}}</p>{{end}} |
| 8 | {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} | |
| 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} | |
| 8 | {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Name}}">{{.Name}}</a> {{end}}</p>{{end}} | |
| 9 | {{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} | |
| 10 | 10 | </section> |
| 11 | 11 | {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} |
| 12 | 12 | <section class="activity"> |
| @@ -29,9 +29,9 @@ | ||
| 29 | 29 | <div class="tablewrap"><table class="keys"> |
| 30 | 30 | <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr> |
| 31 | 31 | {{range .Members}}<tr> |
| 32 | <td><a href="/{{.Username}}">{{.Username}}</a></td> | |
| 32 | <td><a href="/{{.Name}}">{{.Name}}</a></td> | |
| 33 | 33 | <td>{{.Role}}</td> |
| 34 | <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Username}}"><button type="submit" class="linklike">Remove</button></form></td> | |
| 34 | <td class="act"><form method="post" action="/{{$org}}"><input type="hidden" name="field" value="member-remove"><input type="hidden" name="user" value="{{.Name}}"><button type="submit" class="linklike">Remove</button></form></td> | |
| 35 | 35 | </tr> |
| 36 | 36 | {{end}}</table></div> |
| 37 | 37 | <details class="editbox"> |