A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

control: profile aggregate and repo commit !100

merged cmc wants to merge krz/gitbay:profile-aggregate into main

5 files changed, +285 −10

e2e/blame_test.go +17
@@ -58,6 +58,23 @@ func TestBlameView(t *testing.T) {
5858 t.Errorf("ranged blame leaked lines outside the window: %s", out)
5959 }
6060
61 // One commit, with its patch — the capability behind a tappable log
62 // entry, which the web read straight from git.
63 out, errOut, code = inst.ssh(t, aliceKey, "", "repo", "commit", "alice/app", "HEAD", "--json")
64 if code != 0 {
65 t.Fatalf("repo commit: %s", errOut)
66 }
67 for _, want := range []string{
68 `"subject":"change line two"`, `"signature"`, `"diff"`, "TWO CHANGED",
69 } {
70 if !strings.Contains(out, want) {
71 t.Errorf("repo commit missing %q: %s", want, out)
72 }
73 }
74 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "commit", "alice/app", "deadbeef"); code == 0 {
75 t.Error("an unknown sha resolved")
76 }
77
6178 // Binary files have nothing to attribute, and say so rather than 500.
6279 os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0, 1, 2, 0, 3}, 0o644)
6380 mustGit(t, dir, env, "add", ".")
e2e/profile_test.go +36
@@ -25,6 +25,42 @@ func TestOwnerProfiles(t *testing.T) {
2525 t.Fatalf("profile show: %s", out)
2626 }
2727
28 // A profile is the whole page's worth: repositories the caller may
29 // see, org membership, and the activity window — all previously
30 // readable only by the web, which went straight to the store.
31 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/public-tool"); code != 0 {
32 t.Fatal("repo create")
33 }
34 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
35 t.Fatal("private repo create")
36 }
37 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "toolmakers"); code != 0 {
38 t.Fatal("org create")
39 }
40
41 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
42 for _, want := range []string{`"path":"alice/public-tool"`, `"path":"alice/secret"`,
43 `"name":"toolmakers"`, `"activity_total"`} {
44 if !strings.Contains(out, want) {
45 t.Errorf("own profile missing %q: %s", want, out)
46 }
47 }
48
49 // A stranger sees the public repository and not the private one.
50 out, _, _ = inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
51 if !strings.Contains(out, `"path":"alice/public-tool"`) {
52 t.Errorf("stranger cannot see a public repo on a profile: %s", out)
53 }
54 if strings.Contains(out, `"alice/secret"`) {
55 t.Errorf("a private repository leaked onto a profile: %s", out)
56 }
57
58 // An org profile lists its members rather than org memberships.
59 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "toolmakers", "--json")
60 if !strings.Contains(out, `"kind":"org"`) || !strings.Contains(out, `"name":"alice"`) {
61 t.Errorf("org profile members: %s", out)
62 }
63
2864 // Partial update leaves the other field untouched; empty clears.
2965 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 {
3066 t.Fatal("partial set failed")
internal/control/profile.go +122 −3
@@ -4,8 +4,12 @@ import (
44 "errors"
55 "fmt"
66 "io"
7 "sort"
78 "strings"
9 "time"
810
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy"
913 "gitbay.org/gitbay/internal/protocol"
1014 "gitbay.org/gitbay/internal/store"
1115 )
@@ -72,6 +76,43 @@ type profileOut struct {
7276 Kind string `json:"kind"`
7377 Description string `json:"description,omitempty"`
7478 Website string `json:"website,omitempty"`
79 // The rest is what a profile page shows: who they work with, what
80 // they own that you can see, and how active they have been. The web
81 // read these straight out of the store, which kept them off every
82 // other surface.
83 Orgs []profileMember `json:"orgs,omitempty"` // for a user
84 Members []profileMember `json:"members,omitempty"` // for an org
85 Repos []profileRepo `json:"repos"`
86 Activity []activityDay `json:"activity,omitempty"`
87 // ActivityTotal counts the same window the days cover.
88 ActivityTotal int `json:"activity_total"`
89}
90
91type profileMember struct {
92 Name string `json:"name"`
93 Role string `json:"role,omitempty"`
94}
95
96type profileRepo struct {
97 Path string `json:"path"`
98 Visibility string `json:"visibility"`
99 Description string `json:"description,omitempty"`
100 Archived bool `json:"archived,omitempty"`
101}
102
103// activityDay is one day's contribution count. Days with nothing are
104// omitted; a client fills the calendar it wants to draw.
105type activityDay struct {
106 Date string `json:"date"`
107 Count int `json:"count"`
108}
109
110// ActivityWindow is the span a profile reports: the start of the web's
111// 53-week calendar, so every surface shows the same year.
112func ActivityWindow() string {
113 today := time.Now().UTC()
114 end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
115 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
75116 }
76117
77118 func emitProfile(c *Ctx, d profileOut) int {
@@ -83,6 +124,18 @@ func emitProfile(c *Ctx, d profileOut) int {
83124 if d.Website != "" {
84125 fmt.Fprintf(w, "%s\n", d.Website)
85126 }
127 for _, m := range d.Orgs {
128 fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role)
129 }
130 for _, m := range d.Members {
131 fmt.Fprintf(w, "member\t%s\t%s\n", m.Name, m.Role)
132 }
133 for _, r := range d.Repos {
134 fmt.Fprintf(w, "repo\t%s\t%s\t%s\n", r.Path, r.Visibility, r.Description)
135 }
136 if d.ActivityTotal > 0 {
137 fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal)
138 }
86139 })
87140 }
88141
@@ -105,7 +158,71 @@ func runProfileShow(c *Ctx, args []string) int {
105158 if err != nil {
106159 return c.fail(protocol.ExitFailure, "%v", err)
107160 }
108 return emitProfile(c, profileOut{name, kind, p.Description, p.Website})
161 d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
162 Repos: []profileRepo{}}
163
164 // Who they work with. Both lists are public on a profile — the web
165 // has always shown them — and neither exposes anything a member
166 // listing would not.
167 if kind == "user" {
168 orgs, err := c.Store.ListOrgsForUser(id)
169 if err != nil {
170 return c.fail(protocol.ExitFailure, "%v", err)
171 }
172 for _, o := range orgs {
173 d.Orgs = append(d.Orgs, profileMember{Name: o.Username, Role: o.Role})
174 }
175 } else {
176 members, err := c.Store.OrgMembers(id)
177 if err != nil {
178 return c.fail(protocol.ExitFailure, "%v", err)
179 }
180 for _, m := range members {
181 d.Members = append(d.Members, profileMember{Name: m.Username, Role: m.Role})
182 }
183 }
184
185 // Only repositories this caller may read: a private repo must not
186 // surface on a profile any more than it does in a listing.
187 all, err := c.Store.ListReposForOwner(kind, id)
188 if err != nil {
189 return c.fail(protocol.ExitFailure, "%v", err)
190 }
191 for _, repo := range all {
192 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
193 if err != nil {
194 return c.fail(protocol.ExitFailure, "%v", err)
195 }
196 if !policy.CanRead(c.User, repo, grant) {
197 continue
198 }
199 d.Repos = append(d.Repos, profileRepo{
200 Path: repo.Path(),
201 Visibility: repo.Visibility,
202 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)),
203 Archived: repo.Settings.Archived,
204 })
205 }
206
207 var counts map[string]int
208 if kind == "user" {
209 counts, err = c.Store.ActivityByDay(id, ActivityWindow())
210 } else {
211 counts, err = c.Store.OrgActivityByDay(id, ActivityWindow())
212 }
213 if err != nil {
214 return c.fail(protocol.ExitFailure, "%v", err)
215 }
216 days := make([]string, 0, len(counts))
217 for day := range counts {
218 days = append(days, day)
219 }
220 sort.Strings(days)
221 for _, day := range days {
222 d.Activity = append(d.Activity, activityDay{Date: day, Count: counts[day]})
223 d.ActivityTotal += counts[day]
224 }
225 return emitProfile(c, d)
109226 }
110227
111228 func runProfileSet(c *Ctx, args []string) int {
@@ -127,7 +244,8 @@ func runProfileSet(c *Ctx, args []string) int {
127244 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
128245 return c.fail(protocol.ExitFailure, "%v", err)
129246 }
130 return emitProfile(c, profileOut{c.User.Username, "user", p.Description, p.Website})
247 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user",
248 Description: p.Description, Website: p.Website, Repos: []profileRepo{}})
131249 }
132250
133251 func runOrgProfile(c *Ctx, args []string) int {
@@ -154,5 +272,6 @@ func runOrgProfile(c *Ctx, args []string) int {
154272 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
155273 return c.fail(protocol.ExitFailure, "%v", err)
156274 }
157 return emitProfile(c, profileOut{org.Name, "org", p.Description, p.Website})
275 return emitProfile(c, profileOut{Name: org.Name, Kind: "org",
276 Description: p.Description, Website: p.Website, Repos: []profileRepo{}})
158277 }
internal/control/sig.go +101
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "strconv"
9 "strings"
910 "time"
1011
1112 "gitbay.org/gitbay/internal/gitutil"
@@ -22,6 +23,9 @@ func init() {
2223 Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList})
2324 register(Command{Path: []string{"pgp", "remove"},
2425 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
26 register(Command{Path: []string{"repo", "commit"},
27 Summary: "show one commit with its patch: repo commit <owner/name> <sha>",
28 ReadOnly: true, Run: runRepoCommit})
2529 register(Command{Path: []string{"repo", "log"},
2630 Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
2731 }
@@ -216,3 +220,100 @@ func runRepoLog(c *Ctx, args []string) int {
216220 }
217221 })
218222 }
223
224// runRepoCommit shows one commit: its metadata, signature verdict, check
225// statuses, and its patch. The web's commit page read these straight from
226// git, which is why no other surface could open a commit.
227func runRepoCommit(c *Ctx, args []string) int {
228 const usage = "repo commit <owner/name> <sha>"
229 if len(args) != 2 {
230 return c.fail(protocol.ExitUsage, "usage: %s", usage)
231 }
232 repo, code := resolveRepo(c, args[0], policy.CanRead)
233 if code >= 0 {
234 return code
235 }
236 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
237 full, err := gitutil.ResolveRef(dir, args[1])
238 if err != nil {
239 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
240 }
241 raw, err := gitutil.ReadCommit(dir, full)
242 if err != nil {
243 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
244 }
245 parsed, err := sig.ParseCommit(raw)
246 if err != nil {
247 return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
248 }
249 res, err := VerifyCommitCached(c.Store, repo, parsed, full)
250 if err != nil {
251 return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
252 }
253 patch, err := gitutil.ShowPatch(dir, full, 4<<20)
254 if err != nil {
255 return c.fail(protocol.ExitFailure, "%v", err)
256 }
257 statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
258 if err != nil {
259 return c.fail(protocol.ExitFailure, "%v", err)
260 }
261
262 // The message body is everything after the subject line.
263 message := ""
264 if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
265 message = string(parsed.Payload)[i+2:]
266 }
267
268 type checkOut struct {
269 Context string `json:"context"`
270 State string `json:"state"`
271 URL string `json:"url,omitempty"`
272 }
273 type sigOut struct {
274 State string `json:"state"`
275 Signer string `json:"signer,omitempty"`
276 Fingerprint string `json:"key_fingerprint,omitempty"`
277 }
278 type out struct {
279 Path string `json:"path"`
280 SHA string `json:"sha"`
281 Subject string `json:"subject"`
282 Message string `json:"message,omitempty"`
283 AuthorName string `json:"author_name"`
284 AuthorEmail string `json:"author_email"`
285 CommitterEmail string `json:"committer_email,omitempty"`
286 Date string `json:"date"`
287 Signature sigOut `json:"signature"`
288 Checks []checkOut `json:"checks,omitempty"`
289 // Diff is the unified patch, parsed by the client the same way
290 // mr diff is.
291 Diff string `json:"diff"`
292 }
293 d := out{
294 Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
295 AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
296 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
297 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
298 Diff: patch,
299 }
300 if parsed.CommitterEmail != parsed.AuthorEmail {
301 d.CommitterEmail = parsed.CommitterEmail
302 }
303 if res.SignerUserID != 0 {
304 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
305 d.Signature.Signer = u.Username
306 }
307 }
308 for _, st := range statuses {
309 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
310 }
311 return c.emit(d, func(w io.Writer) {
312 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
313 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
314 if d.Message != "" {
315 fmt.Fprintf(w, "\n%s\n", d.Message)
316 }
317 fmt.Fprintf(w, "\n%s", d.Diff)
318 })
319}
internal/httpd/activity.go +9 −7
@@ -1,13 +1,17 @@
11 package httpd
22
3import "time"
3import (
4 "time"
5
6 "gitbay.org/gitbay/internal/control"
7)
48
59 // activityDay is one cell of the graph; Level buckets Count into the five
610 // intensity classes the stylesheet colors.
711 type activityDay struct {
812 Date string
913 Count int
10 Level int // 0..4
14 Level int // 0..4
1115 Pad bool // before the range start / after today
1216 }
1317
@@ -57,8 +61,6 @@ func activityLevel(n int) int {
5761 }
5862
5963 // activitySince is the first day the grid can show, for the query bound.
60func activitySince() string {
61 today := time.Now().UTC()
62 end := today.AddDate(0, 0, int(time.Saturday-today.Weekday()))
63 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
64}
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() }