Commit 5a73145992

5a73145992b91c1559bdae8848fa5e626f90ec6b

parent: 93b38a063b

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-30 06:26 UTC

web: the owner page dispatches profile show

profile show's repo rows now carry topics, license, default branch and
last-commit date, so the profile page can dispatch the command instead of
recomputing membership, the visible-repo filter and the activity year from
the store. That listing metadata is not web-specific: a native client
rendering a profile wants it too, and iOS omits topics there today only
because the command did not report them.

detectLicense moves to internal/control, since the command and the web's
listings both need it. reporow reads a flat .Archived so it can render a
describedRepo and a profile row alike; activitySince had no callers left.

Closes #47

Layout: unified · split

e2e/profile_test.go +41 −1
@@ -1,6 +1,8 @@
1package e2e 1package e2e
2 2
3import ( 3import (
4 "os"
5 "path/filepath"
4 "strings" 6 "strings"
5 "testing" 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 // A stranger sees the public repository and not the private one. 89 // A stranger sees the public repository and not the private one.
50 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") 90 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
51 if !strings.Contains(out, `"path":"alice/public-tool"`) { 91 if !strings.Contains(out, `"path":"alice/public-tool"`) {
@@ -123,7 +163,7 @@ func TestOwnerProfiles(t *testing.T) {
123 } 163 }
124 // Org emphasis parsed, not left as literal slashes the way the 164 // Org emphasis parsed, not left as literal slashes the way the
125 // markdown renderer would. 165 // markdown renderer would.
126 status, body := inst.get(t, "/alice") 166 _, body = inst.get(t, "/alice")
127 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { 167 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
128 t.Fatalf("org about not rendered as org: %s", body) 168 t.Fatalf("org about not rendered as org: %s", body)
129 } 169 }
internal/control/license.go added +50
@@ -0,0 +1,50 @@
1package control
2
3import (
4 "strings"
5
6 "gitbay.org/gitbay/internal/gitutil"
7)
8
9// licenseNames maps content markers to display names, checked in order.
10var 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.
34func 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 Role string `json:"role,omitempty"` 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.
187type profileRepo struct { 191type profileRepo struct {
188 Path string `json:"path"` 192 Path string `json:"path"`
189 Visibility string `json:"visibility"` 193 Visibility string `json:"visibility"`
190 Description string `json:"description,omitempty"` 194 Description string `json:"description,omitempty"`
191 Archived bool `json:"archived,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// activityDay is one day's contribution count. Days with nothing are 202// activityDay is one day's contribution count. Days with nothing are
@@ -293,11 +301,20 @@ func runProfileShow(c *Ctx, args []string) int {
293 if !policy.CanRead(c.User, repo, grant) { 301 if !policy.CanRead(c.User, repo, grant) {
294 continue 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 d.Repos = append(d.Repos, profileRepo{ 309 d.Repos = append(d.Repos, profileRepo{
297 Path: repo.Path(), 310 Path: repo.Path(),
298 Visibility: repo.Visibility, 311 Visibility: repo.Visibility,
299 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)), 312 Description: gitutil.ReadDescription(dir),
300 Archived: repo.Settings.Archived, 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 @@
1package httpd 1package httpd
2 2
3import ( 3import "time"
4 "time"
5
6 "gitbay.org/gitbay/internal/control"
7)
8 4
9// activityDay is one cell of the graph; Level buckets Count into the five 5// activityDay is one cell of the graph; Level buckets Count into the five
10// intensity classes the stylesheet colors. 6// intensity classes the stylesheet colors.
@@ -59,8 +55,3 @@ func activityLevel(n int) int {
59 return 4 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.
66func 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// target. Read handlers use it so the web renders exactly what the CLI 70// target. Read handlers use it so the web renders exactly what the CLI
71// and the API return, rather than reaching past the registry into git. 71// and the API return, rather than reaching past the registry into git.
72func (s *Server) runControlInto(u store.User, argv []string, target any) (msg string, ok bool) { 72func (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.
80func (s *Server) runControlIntoCode(u store.User, argv []string, target any) (code int, msg string) {
81 return s.dispatchInto(u, argv, target)
82}
83
84func (s *Server) dispatchInto(u store.User, argv []string, target any) (int, string) {
73 var stdout, stderr bytes.Buffer 85 var stdout, stderr bytes.Buffer
74 ctx := &control.Ctx{ 86 ctx := &control.Ctx{
75 User: u, 87 User: u,
@@ -94,14 +106,14 @@ func (s *Server) runControlInto(u store.User, argv []string, target any) (msg st
94 if m == "" { 106 if m == "" {
95 m = strings.TrimSpace(stderr.String()) 107 m = strings.TrimSpace(stderr.String())
96 } 108 }
97 return m, false 109 return code, m
98 } 110 }
99 if len(env.Data) > 0 { 111 if len(env.Data) > 0 {
100 if err := json.Unmarshal(env.Data, target); err != nil { 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// runControlJSON runs a command in JSON mode and returns its data object. 119// runControlJSON runs a command in JSON mode and returns its data object.
internal/httpd/facts.go +2 −1
@@ -1,6 +1,7 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "gitbay.org/gitbay/internal/control"
4 "gitbay.org/gitbay/internal/gitutil" 5 "gitbay.org/gitbay/internal/gitutil"
5) 6)
6 7
@@ -29,7 +30,7 @@ const maxContributors = 12
29func (s *Server) factsFor(p repoPage) repoFacts { 30func (s *Server) factsFor(p repoPage) repoFacts {
30 f := repoFacts{ 31 f := repoFacts{
31 Commits: gitutil.CountCommits(p.Dir, p.Ref), 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 if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil { 35 if heads, err := gitutil.Refs(p.Dir, "heads"); err == nil {
35 f.Branches = len(heads) 36 f.Branches = len(heads)
internal/httpd/readme.go −42
@@ -11,48 +11,6 @@ import (
11 "gitbay.org/gitbay/internal/gitutil" 11 "gitbay.org/gitbay/internal/gitutil"
12) 12)
13 13
14// licenseNames maps content markers to display names, checked in order.
15var 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 "".
38func 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// rewriteRelativeLinks makes relative hrefs and srcs in rendered repo 14// rewriteRelativeLinks makes relative hrefs and srcs in rendered repo
57// content resolve on the forge: links go to blob pages, images to raw. 15// content resolve on the forge: links go to blob pages, images to raw.
58// go-org exports .org links as .html, so an .html target whose .org (or 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 "path/filepath" 11 "path/filepath"
12 12
13 "gitbay.org/gitbay/internal/policy" 13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
14 "html/template" 15 "html/template"
15 "net/http" 16 "net/http"
16 "net/url" 17 "net/url"
@@ -107,6 +108,10 @@ type describedRepo struct {
107 Updated string 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.
113func (d describedRepo) Archived() bool { return d.Settings.Archived }
114
110func (s *Server) describeAll(repos []store.Repo) []describedRepo { 115func (s *Server) describeAll(repos []store.Repo) []describedRepo {
111 var out []describedRepo 116 var out []describedRepo
112 for _, r := range repos { 117 for _, r := range repos {
@@ -114,7 +119,7 @@ func (s *Server) describeAll(repos []store.Repo) []describedRepo {
114 d := describedRepo{ 119 d := describedRepo{
115 Repo: r, 120 Repo: r,
116 Desc: gitutil.ReadDescription(dir), 121 Desc: gitutil.ReadDescription(dir),
117 License: detectLicense(dir, r.DefaultBranch), 122 License: control.DetectLicense(dir, r.DefaultBranch),
118 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch), 123 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
119 } 124 }
120 d.Topics, _ = s.st.ListTopics(r.ID) 125 d.Topics, _ = s.st.ListTopics(r.ID)
@@ -346,6 +351,47 @@ func crumbs(p repoPage, kind, filePath string) []crumb {
346 return cs 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.
357type 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
374type 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.
381type 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
392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394
349// ownerPage renders /{owner} for users and orgs: the repositories the 395// ownerPage renders /{owner} for users and orgs: the repositories the
350// viewer may see, org membership either direction. Owner names are not 396// viewer may see, org membership either direction. Owner names are not
351// secret (they are on every commit); repository visibility rules hold. 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 viewer = s.viewer(r) 402 viewer = s.viewer(r)
357 } 403 }
358 404
359 kind := "user" 405 // Everything on this page — membership, the repositories this viewer
360 var ownerID int64 406 // may see, the activity year — comes from profile show, so the page
361 var members []store.OrgMember 407 // and the command cannot report different things.
362 var orgs []store.OrgMember 408 var d profileView
363 if u, err := s.st.UserByUsername(name); err == nil { 409 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
364 ownerID = u.ID 410 switch {
365 orgs, _ = s.st.ListOrgsForUser(u.ID) 411 case code == protocol.ExitNotFound:
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 {
370 s.notFound(w, r) 412 s.notFound(w, r)
371 return 413 return
372 } 414 case code != protocol.ExitOK:
373 profile, _ := s.st.OwnerProfile(kind, ownerID) 415 log.Printf("profile %s: %s", name, msg)
374
375 all, err := s.st.ListReposForOwner(kind, ownerID)
376 if err != nil {
377 http.Error(w, "internal error", http.StatusInternalServerError) 416 http.Error(w, "internal error", http.StatusInternalServerError)
378 return 417 return
379 } 418 }
380 var visible []store.Repo 419
381 for _, repo := range all { 420 counts := make(map[string]int, len(d.Activity))
382 grant := "" 421 for _, day := range d.Activity {
383 if viewer.ID != 0 { 422 counts[day.Date] = day.Count
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())
395 } 423 }
396 weeks, activityTotal := activityGrid(counts) 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 s.render(w, "owner.html", struct { 429 s.render(w, "owner.html", struct {
400 basePage 430 basePage
401 Owner string 431 Owner string
402 Kind string 432 Kind string
403 Profile store.Profile 433 Profile store.Profile
404 AboutHTML template.HTML 434 AboutHTML template.HTML
405 Repos []describedRepo 435 Repos []profileRepoRow
406 Members []store.OrgMember 436 Members []profileMember
407 Orgs []store.OrgMember 437 Orgs []profileMember
408 Activity []activityWeek 438 Activity []activityWeek
409 ActivityTotal int 439 ActivityTotal int
410 Teams []teamView 440 Teams []teamView
411 CanAdmin bool 441 CanAdmin bool
412 Notice string 442 Notice string
413 }{s.baseFor(viewer), name, kind, profile, aboutHTML(profile), 443 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
414 s.describeAll(visible), members, orgs, 444 d.Repos, d.Members, d.Orgs,
415 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")}) 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{{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}} 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{{define "reporow"}}<li> 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 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} 97 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
98 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} 98 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
99 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> 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{{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}} 5{{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}}
6{{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}} 6{{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}}
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}} 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}} 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="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></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</section> 10</section>
11{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} 11{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
12<section class="activity"> 12<section class="activity">
@@ -29,9 +29,9 @@
29<div class="tablewrap"><table class="keys"> 29<div class="tablewrap"><table class="keys">
30<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr> 30<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr>
31{{range .Members}}<tr> 31{{range .Members}}<tr>
32 <td><a href="/{{.Username}}">{{.Username}}</a></td> 32 <td><a href="/{{.Name}}">{{.Name}}</a></td>
33 <td>{{.Role}}</td> 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</tr> 35</tr>
36{{end}}</table></div> 36{{end}}</table></div>
37<details class="editbox"> 37<details class="editbox">