control: profile aggregate and repo commit !100
5 files changed, +285 −10
e2e/blame_test.go +17
| @@ -58,6 +58,23 @@ func TestBlameView(t *testing.T) { | ||
| 58 | 58 | t.Errorf("ranged blame leaked lines outside the window: %s", out) |
| 59 | 59 | } |
| 60 | 60 | |
| 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 | ||
| 61 | 78 | // Binary files have nothing to attribute, and say so rather than 500. |
| 62 | 79 | os.WriteFile(filepath.Join(dir, "logo.bin"), []byte{0, 1, 2, 0, 3}, 0o644) |
| 63 | 80 | mustGit(t, dir, env, "add", ".") |
e2e/profile_test.go +36
| @@ -25,6 +25,42 @@ func TestOwnerProfiles(t *testing.T) { | ||
| 25 | 25 | t.Fatalf("profile show: %s", out) |
| 26 | 26 | } |
| 27 | 27 | |
| 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 | ||
| 28 | 64 | // Partial update leaves the other field untouched; empty clears. |
| 29 | 65 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 { |
| 30 | 66 | t.Fatal("partial set failed") |
internal/control/profile.go +122 −3
| @@ -4,8 +4,12 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "sort" | |
| 7 | 8 | "strings" |
| 9 | "time" | |
| 8 | 10 | |
| 11 | "gitbay.org/gitbay/internal/gitutil" | |
| 12 | "gitbay.org/gitbay/internal/policy" | |
| 9 | 13 | "gitbay.org/gitbay/internal/protocol" |
| 10 | 14 | "gitbay.org/gitbay/internal/store" |
| 11 | 15 | ) |
| @@ -72,6 +76,43 @@ type profileOut struct { | ||
| 72 | 76 | Kind string `json:"kind"` |
| 73 | 77 | Description string `json:"description,omitempty"` |
| 74 | 78 | 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 | ||
| 91 | type profileMember struct { | |
| 92 | Name string `json:"name"` | |
| 93 | Role string `json:"role,omitempty"` | |
| 94 | } | |
| 95 | ||
| 96 | type 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. | |
| 105 | type 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. | |
| 112 | func 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") | |
| 75 | 116 | } |
| 76 | 117 | |
| 77 | 118 | func emitProfile(c *Ctx, d profileOut) int { |
| @@ -83,6 +124,18 @@ func emitProfile(c *Ctx, d profileOut) int { | ||
| 83 | 124 | if d.Website != "" { |
| 84 | 125 | fmt.Fprintf(w, "%s\n", d.Website) |
| 85 | 126 | } |
| 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 | } | |
| 86 | 139 | }) |
| 87 | 140 | } |
| 88 | 141 | |
| @@ -105,7 +158,71 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 105 | 158 | if err != nil { |
| 106 | 159 | return c.fail(protocol.ExitFailure, "%v", err) |
| 107 | 160 | } |
| 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) | |
| 109 | 226 | } |
| 110 | 227 | |
| 111 | 228 | func runProfileSet(c *Ctx, args []string) int { |
| @@ -127,7 +244,8 @@ func runProfileSet(c *Ctx, args []string) int { | ||
| 127 | 244 | if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil { |
| 128 | 245 | return c.fail(protocol.ExitFailure, "%v", err) |
| 129 | 246 | } |
| 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{}}) | |
| 131 | 249 | } |
| 132 | 250 | |
| 133 | 251 | func runOrgProfile(c *Ctx, args []string) int { |
| @@ -154,5 +272,6 @@ func runOrgProfile(c *Ctx, args []string) int { | ||
| 154 | 272 | if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil { |
| 155 | 273 | return c.fail(protocol.ExitFailure, "%v", err) |
| 156 | 274 | } |
| 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{}}) | |
| 158 | 277 | } |
internal/control/sig.go +101
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "io" |
| 8 | 8 | "strconv" |
| 9 | "strings" | |
| 9 | 10 | "time" |
| 10 | 11 | |
| 11 | 12 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -22,6 +23,9 @@ func init() { | ||
| 22 | 23 | Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList}) |
| 23 | 24 | register(Command{Path: []string{"pgp", "remove"}, |
| 24 | 25 | 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}) | |
| 25 | 29 | register(Command{Path: []string{"repo", "log"}, |
| 26 | 30 | Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog}) |
| 27 | 31 | } |
| @@ -216,3 +220,100 @@ func runRepoLog(c *Ctx, args []string) int { | ||
| 216 | 220 | } |
| 217 | 221 | }) |
| 218 | 222 | } |
| 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. | |
| 227 | func 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 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | import "time" | |
| 3 | import ( | |
| 4 | "time" | |
| 5 | ||
| 6 | "gitbay.org/gitbay/internal/control" | |
| 7 | ) | |
| 4 | 8 | |
| 5 | 9 | // activityDay is one cell of the graph; Level buckets Count into the five |
| 6 | 10 | // intensity classes the stylesheet colors. |
| 7 | 11 | type activityDay struct { |
| 8 | 12 | Date string |
| 9 | 13 | Count int |
| 10 | Level int // 0..4 | |
| 14 | Level int // 0..4 | |
| 11 | 15 | Pad bool // before the range start / after today |
| 12 | 16 | } |
| 13 | 17 | |
| @@ -57,8 +61,6 @@ func activityLevel(n int) int { | ||
| 57 | 61 | } |
| 58 | 62 | |
| 59 | 63 | // activitySince is the first day the grid can show, for the query bound. |
| 60 | func 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. | |
| 66 | func activitySince() string { return control.ActivityWindow() } | |