A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 3af6fd1aaf

3af6fd1aaf69413d258a4131a627d048d2f1d290

parent: cb449390bc

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-08-31T19:49:07Z

Merge requests say when things happened, and who resolved them

Reviews and checks carried no times on the MR page, and a check's
duration meant opening the build. The header said "X wants to merge"
whatever the state.

- merge_requests gains merged_at/merged_by and closed_at/closed_by,
  backfilled for merged MRs from the mr.merged events. The header now
  reads "cmc merged krz/hutch:branch into main on <date>", or "closed
  this without merging", and drops the claim when no stamp exists.
- store.ChecksForCommit pairs ci/<job> statuses with their builds, so
  every surface reports a check's time and how long it ran from the same
  two queries.
- mr show carries the review timestamps, check times and durations, and
  the resolution stamps in both JSON and text.
e2e/mr_test.go +8 −3
@@ -18,10 +18,15 @@ type mrShow struct {
1818 Source string `json:"source"`
1919 TargetRef string `json:"target_ref"`
2020 HeadSHA string `json:"head_sha"`
21 MergedAt string `json:"merged_at"`
22 MergedBy string `json:"merged_by"`
23 ClosedAt string `json:"closed_at"`
24 ClosedBy string `json:"closed_by"`
2125 Reviews []struct {
22 Reviewer string `json:"reviewer"`
23 Verdict string `json:"verdict"`
24 Stale bool `json:"stale"`
26 Reviewer string `json:"reviewer"`
27 Verdict string `json:"verdict"`
28 Stale bool `json:"stale"`
29 CreatedAt string `json:"created_at"`
2530 } `json:"reviews"`
2631}
2732
e2e/mrweb_test.go +17 −2
@@ -133,8 +133,23 @@ func TestMRWebReviewLoop(t *testing.T) {
133133 if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}); status != 200 {
134134 t.Fatalf("merge post: %d", status)
135135 }
136 if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "merged" {
137 t.Fatalf("MR state after web merge: %s", st)
136 merged := inst.mrShow(t, aliceKey, "alice/lib", "1")
137 if merged.State != "merged" {
138 t.Fatalf("MR state after web merge: %s", merged.State)
139 }
140 // Who merged it and when, so the page can stop saying alice wants to.
141 if merged.MergedBy != "alice" || merged.MergedAt == "" {
142 t.Fatalf("merge not attributed: %+v", merged)
143 }
144 if merged.Reviews[0].CreatedAt == "" {
145 t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0])
146 }
147 _, body = browserGet(t, alice, mrURL)
148 if strings.Contains(body, "wants to merge") {
149 t.Fatalf("merged MR still wants to merge:\n%s", body)
150 }
151 if !strings.Contains(body, ">alice</a> merged") {
152 t.Fatalf("merged MR does not name the merger:\n%s", body)
138153 }
139154 mustGit(t, dir, env, "pull", "-q", "origin", "main")
140155 if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
internal/control/ghimport.go +3 −2
@@ -35,6 +35,7 @@ type ghIssue struct {
3535 Body string `json:"body"`
3636 State string `json:"state"`
3737 CreatedAt string `json:"created_at"`
38 ClosedAt string `json:"closed_at"`
3839 User ghUser `json:"user"`
3940 Labels []struct{ Name string } `json:"labels"`
4041 PullRequest *struct{} `json:"pull_request"`
@@ -194,9 +195,9 @@ func runImportIssues(c *Ctx, args []string) int {
194195 return c.fail(protocol.ExitFailure, "%v", err)
195196 }
196197 if pr.MergedAt != "" {
197 c.Store.MarkMerged(mr.ID, pr.Base.SHA)
198 c.Store.MarkMerged(mr.ID, pr.Base.SHA, 0, pr.MergedAt)
198199 } else {
199 c.Store.SetMRState(mr.ID, "closed")
200 c.Store.MarkClosed(mr.ID, 0, it.ClosedAt)
200201 }
201202 // Point the MR head ref at the PR head when the mirror
202203 // already holds the objects (refs/pull backups).
internal/control/mr.go +46 −15
@@ -332,6 +332,10 @@ type mrOut struct {
332332 BodyFormat string `json:"body_format,omitempty"`
333333 Milestone string `json:"milestone,omitempty"`
334334 CreatedAt string `json:"created_at"`
335 MergedAt string `json:"merged_at,omitempty"`
336 MergedBy string `json:"merged_by,omitempty"`
337 ClosedAt string `json:"closed_at,omitempty"`
338 ClosedBy string `json:"closed_by,omitempty"`
335339}
336340
337341func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
@@ -345,7 +349,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
345349 }
346350 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
347351 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
348 CreatedAt: m.CreatedAt}
352 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
353 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy}
349354 if withBody {
350355 o.Body = m.Body
351356 o.BodyFormat = m.BodyFormat
@@ -401,6 +406,15 @@ func runMRList(c *Ctx, args []string) int {
401406 })
402407}
403408
409// byWhom renders " by <user>", or nothing when the actor is unknown — an
410// imported merge request carries a time but no local account.
411func byWhom(user string) string {
412 if user == "" {
413 return ""
414 }
415 return " by " + user
416}
417
404418func runMRShow(c *Ctx, args []string) int {
405419 repo, mr, code := mrRef(c, args, policy.CanRead)
406420 if code >= 0 {
@@ -417,7 +431,7 @@ func runMRShow(c *Ctx, args []string) int {
417431 if err != nil {
418432 return c.fail(protocol.ExitFailure, "%v", err)
419433 }
420 statuses, err := c.Store.ListCommitStatuses(repo.ID, mr.HeadSHA)
434 statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA)
421435 if err != nil {
422436 return c.fail(protocol.ExitFailure, "%v", err)
423437 }
@@ -432,18 +446,25 @@ func runMRShow(c *Ctx, args []string) int {
432446 CreatedAt string `json:"created_at"`
433447 }
434448 type reviewOut struct {
435 Reviewer string `json:"reviewer"`
436 Verdict string `json:"verdict"`
437 Stale bool `json:"stale"`
449 Reviewer string `json:"reviewer"`
450 Verdict string `json:"verdict"`
451 Stale bool `json:"stale"`
452 CreatedAt string `json:"created_at"`
438453 }
439454 type checkOut struct {
440 Context string `json:"context"`
441 State string `json:"state"`
442 URL string `json:"url,omitempty"`
455 Context string `json:"context"`
456 State string `json:"state"`
457 URL string `json:"url,omitempty"`
458 UpdatedAt string `json:"updated_at"`
459 Duration string `json:"duration,omitempty"` // CI checks only, once finished
443460 }
444461 var checks []checkOut
445462 for _, st := range statuses {
446 checks = append(checks, checkOut{st.Context, st.State, st.TargetURL})
463 out := checkOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
464 if st.Duration > 0 {
465 out.Duration = st.Duration.String()
466 }
467 checks = append(checks, out)
447468 }
448469 var cs []commentOut
449470 for _, cm := range comments {
@@ -451,7 +472,7 @@ func runMRShow(c *Ctx, args []string) int {
451472 }
452473 var rs []reviewOut
453474 for _, r := range reviews {
454 rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale})
475 rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt})
455476 }
456477 // The commits this MR carries: base..head, the diff's range.
457478 type commitOut struct {
@@ -487,9 +508,15 @@ func runMRShow(c *Ctx, args []string) int {
487508 Commits []commitOut `json:"commits,omitempty"`
488509 Comments []commentOut `json:"comments,omitempty"`
489510 Reviews []reviewOut `json:"reviews,omitempty"`
490 }{mrToOut(repo, mr, true), checks, store.CombinedStatus(statuses), unresolved, commits, cs, rs}
511 }{mrToOut(repo, mr, true), checks, combined, unresolved, commits, cs, rs}
491512 return c.emit(d, func(w io.Writer) {
492513 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
514 if d.MergedAt != "" {
515 fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy))
516 }
517 if d.ClosedAt != "" {
518 fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy))
519 }
493520 if d.Body != "" {
494521 fmt.Fprintf(w, "\n%s\n", d.Body)
495522 }
@@ -497,7 +524,11 @@ func runMRShow(c *Ctx, args []string) int {
497524 fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject)
498525 }
499526 for _, x := range checks {
500 fmt.Fprintf(w, "check: %s %s\n", x.Context, x.State)
527 dur := ""
528 if x.Duration != "" {
529 dur = " in " + x.Duration
530 }
531 fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur)
501532 }
502533 if d.UnresolvedThreads > 0 {
503534 fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
@@ -507,7 +538,7 @@ func runMRShow(c *Ctx, args []string) int {
507538 if r.Stale {
508539 stale = " (stale)"
509540 }
510 fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale)
541 fmt.Fprintf(w, "review: %s %s%s at %s\n", r.Reviewer, r.Verdict, stale, r.CreatedAt)
511542 }
512543 for _, cm := range cs {
513544 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
@@ -980,7 +1011,7 @@ func runMRMerge(c *Ctx, args []string) int {
9801011 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
9811012 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
9821013 }
983 if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
1014 if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
9841015 return c.fail(protocol.ExitFailure, "%v", err)
9851016 }
9861017 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
@@ -1131,7 +1162,7 @@ func runMRClose(c *Ctx, args []string) int {
11311162 if mr.State == "merged" || mr.State == "closed" {
11321163 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
11331164 }
1134 if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
1165 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
11351166 return c.fail(protocol.ExitFailure, "%v", err)
11361167 }
11371168 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
internal/httpd/mrpage_test.go added +104
@@ -0,0 +1,104 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/store"
11 "gitbay.org/gitbay/internal/web"
12)
13
14// mrPageData mirrors the anonymous struct the mr handler renders with.
15type mrPageData struct {
16 repoPage
17 MR store.MR
18 View string
19 BodyHTML template.HTML
20 Checks []store.Check
21 Combined string
22 Comments []renderedComment
23 Reviews []store.MRReview
24 DiffFiles []diffFile
25 Stat diffStat
26 Commits []struct{}
27 Branches []gitutil.Ref
28 CanEdit bool
29 CanWrite bool
30 Unresolved int
31 Notice string
32 DetachedThreads []diffThread
33}
34
35func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
36 t.Helper()
37 var sb strings.Builder
38 if err := web.Render(&sb, "mr.html", mrPageData{
39 repoPage: testRepoPage(), MR: m, View: "conversation",
40 Reviews: reviews, Checks: checks, Combined: "",
41 }); err != nil {
42 t.Fatalf("render: %v", err)
43 }
44 return sb.String()
45}
46
47func testMR(state string) store.MR {
48 return store.MR{Number: 42, Title: "org native rendering", Author: "cmc", State: state,
49 SourcePath: "krz/hutch", SourceRef: "org-native-rendering", TargetRef: "main",
50 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
51}
52
53// The header states what happened to the MR. "wants to merge" is only true
54// while it is still open.
55func TestMRHeaderByState(t *testing.T) {
56 open := renderMR(t, testMR("open"), nil, nil)
57 if !strings.Contains(open, "wants to merge") {
58 t.Errorf("open MR does not say wants to merge:\n%s", open)
59 }
60
61 merged := testMR("merged")
62 merged.MergedAt, merged.MergedBy = "2026-08-27T14:03:11.000Z", "cmc"
63 out := renderMR(t, merged, nil, nil)
64 for _, want := range []string{"merged", "krz/hutch:org-native-rendering", "2026-08-27 14:03"} {
65 if !strings.Contains(out, want) {
66 t.Errorf("merged header missing %q:\n%s", want, out)
67 }
68 }
69 if strings.Contains(out, "wants to merge") {
70 t.Errorf("merged MR still wants to merge:\n%s", out)
71 }
72
73 closed := testMR("closed")
74 closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
75 out = renderMR(t, closed, nil, nil)
76 if !strings.Contains(out, "without merging") || strings.Contains(out, "wants to merge") {
77 t.Errorf("closed header:\n%s", out)
78 }
79
80 // Imports and pre-0029 merges carry no stamp; the wording drops the
81 // claim rather than inventing a time.
82 out = renderMR(t, testMR("merged"), nil, nil)
83 if strings.Contains(out, "wants to merge") || strings.Contains(out, " on 20") {
84 t.Errorf("unstamped merged header:\n%s", out)
85 }
86}
87
88// Approvals and checks carry their times in the aside, so reading the MR
89// does not mean opening the build.
90func TestMRAsideTimestamps(t *testing.T) {
91 out := renderMR(t, testMR("open"),
92 []store.MRReview{{Reviewer: "cmc", Verdict: "approve", CreatedAt: "2026-08-27T14:03:11.000Z"}},
93 []store.Check{
94 {CommitStatus: store.CommitStatus{Context: "ci/test", State: "success",
95 UpdatedAt: "2026-08-27T14:05:00.000Z"}, Duration: 72 * time.Second, Build: 60},
96 {CommitStatus: store.CommitStatus{Context: "external/lint", State: "success",
97 UpdatedAt: "2026-08-27T14:06:00.000Z"}},
98 })
99 for _, want := range []string{"2026-08-27 14:03", "2026-08-27 14:05", "1m12s", "2026-08-27 14:06"} {
100 if !strings.Contains(out, want) {
101 t.Errorf("aside missing %q:\n%s", want, out)
102 }
103 }
104}
internal/httpd/web.go +3 −3
@@ -1632,7 +1632,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
16321632 }
16331633 comments, _ := s.st.ListMRComments(m.ID)
16341634 reviews, _ := s.st.ListMRReviews(m.ID)
1635 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1635 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
16361636 diffComments, _ := s.st.ListDiffComments(m.ID)
16371637
16381638 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
@@ -1696,7 +1696,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
16961696 MR store.MR
16971697 View string
16981698 BodyHTML template.HTML
1699 Checks []store.CommitStatus
1699 Checks []store.Check
17001700 Combined string
17011701 Comments []renderedComment
17021702 Reviews []store.MRReview
@@ -1709,7 +1709,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
17091709 Unresolved int
17101710 Notice string
17111711 DetachedThreads []diffThread
1712 }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md),
1712 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
17131713 reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author),
17141714 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads})
17151715}
internal/store/builds.go +38
@@ -206,3 +206,41 @@ func (s *Store) LatestBuild(repoID int64, job string) (Build, error) {
206206 }
207207 return b, err
208208}
209
210// BuildsForCommit returns the newest build per job for one commit. A merge
211// request's checks are ci/<job> statuses; this is where their timing comes
212// from, in one query rather than one per check.
213func (s *Store) BuildsForCommit(repoID int64, sha string) (map[string]Build, error) {
214 rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? AND sha = ? ORDER BY number ASC", repoID, sha)
215 if err != nil {
216 return nil, err
217 }
218 defer rows.Close()
219 out := map[string]Build{}
220 for rows.Next() {
221 b, err := scanBuild(rows)
222 if err != nil {
223 return nil, err
224 }
225 out[b.Job] = b // ascending: the last row for a job wins
226 }
227 return out, rows.Err()
228}
229
230// Elapsed reports how long a build ran. Zero until it has both a start and
231// a finish, which is every state but success and failure.
232func (b Build) Elapsed() time.Duration {
233 const layout = "2006-01-02T15:04:05Z"
234 start, err := time.Parse(layout, b.StartedAt)
235 if err != nil {
236 return 0
237 }
238 end, err := time.Parse(layout, b.FinishedAt)
239 if err != nil {
240 return 0
241 }
242 if d := end.Sub(start); d > 0 {
243 return d.Round(time.Second)
244 }
245 return 0
246}
internal/store/builds_test.go +49
@@ -69,3 +69,52 @@ func TestReapStaleBuilds(t *testing.T) {
6969 t.Fatalf("fresh build is %v (%v), want running", b.Status, err)
7070 }
7171}
72
73// Checks on a merge request report how long their build ran, which means
74// pairing ci/<job> statuses with builds on the same commit.
75func TestBuildsForCommitTiming(t *testing.T) {
76 s := open(t)
77 if err := s.MigrateUp(); err != nil {
78 t.Fatal(err)
79 }
80 uid, err := s.CreateUser("cmc", true)
81 if err != nil {
82 t.Fatal(err)
83 }
84 repoID, err := s.CreateRepo("user", uid, "lib", "public")
85 if err != nil {
86 t.Fatal(err)
87 }
88 // Two runs of the same job on one commit: the retry is what counts.
89 for range 2 {
90 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`); err != nil {
91 t.Fatal(err)
92 }
93 }
94 if _, err := s.CreateBuild(repoID, "lint", "def456", "main", `["true"]`); err != nil {
95 t.Fatal(err)
96 }
97 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',
98 finished_at = '2026-08-28T04:44:06Z', status = 'success' WHERE number = 2`); err != nil {
99 t.Fatal(err)
100 }
101
102 byJob, err := s.BuildsForCommit(repoID, "abc123")
103 if err != nil {
104 t.Fatal(err)
105 }
106 if len(byJob) != 1 {
107 t.Fatalf("builds for commit: %+v", byJob)
108 }
109 b := byJob["test"]
110 if b.Number != 2 {
111 t.Fatalf("older run won: %d", b.Number)
112 }
113 if got := b.Elapsed().String(); got != "1m12s" {
114 t.Fatalf("elapsed: %s", got)
115 }
116 // A build that never finished has no duration to report.
117 if d := byJob["lint"].Elapsed(); d != 0 {
118 t.Fatalf("unfinished build reported %s", d)
119 }
120}
internal/store/migrations/0029_mr_resolution.down.sql added +4
@@ -0,0 +1,4 @@
1ALTER TABLE merge_requests DROP COLUMN closed_by;
2ALTER TABLE merge_requests DROP COLUMN closed_at;
3ALTER TABLE merge_requests DROP COLUMN merged_by;
4ALTER TABLE merge_requests DROP COLUMN merged_at;
internal/store/migrations/0029_mr_resolution.up.sql added +23
@@ -0,0 +1,23 @@
1-- Who resolved a merge request and when. The state alone cannot say it:
2-- updated_at moves for every edit, and the actor only survived in the
3-- events table.
4ALTER TABLE merge_requests ADD COLUMN merged_at TEXT NOT NULL DEFAULT '';
5ALTER TABLE merge_requests ADD COLUMN merged_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
6ALTER TABLE merge_requests ADD COLUMN closed_at TEXT NOT NULL DEFAULT '';
7ALTER TABLE merge_requests ADD COLUMN closed_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
8
9-- Backfill from the mr.merged events already recorded. json_extract is
10-- available in the SQLite builds gitbay links; the payload is
11-- {"number":N,"sha":"..."} written by the merge path.
12UPDATE merge_requests SET
13 merged_at = COALESCE((
14 SELECT e.created_at FROM events e
15 WHERE e.repo_id = merge_requests.repo_id AND e.kind = 'mr.merged'
16 AND json_extract(e.data_json, '$.number') = merge_requests.number
17 ORDER BY e.id DESC LIMIT 1), ''),
18 merged_by = (
19 SELECT e.actor_id FROM events e
20 WHERE e.repo_id = merge_requests.repo_id AND e.kind = 'mr.merged'
21 AND json_extract(e.data_json, '$.number') = merge_requests.number
22 ORDER BY e.id DESC LIMIT 1)
23WHERE state = 'merged';
internal/store/mrs.go +38 −7
@@ -22,6 +22,10 @@ type MR struct {
2222 Milestone string
2323 HeadSHA string
2424 MergedBase string // target tip at merge time; base for historical diffs
25 MergedAt string // "" unless merged
26 MergedBy string // "" when unknown (imports) or the account is gone
27 ClosedAt string // "" unless closed without merging
28 ClosedBy string
2529 CreatedAt string
2630 UpdatedAt string
2731}
@@ -62,9 +66,12 @@ const mrSelect = `
6266 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
6367 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state,
6468 COALESCE(ms.title, ''), m.head_sha,
65 m.merged_base, m.created_at, m.updated_at
69 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
70 m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at
6671 FROM merge_requests m
6772 JOIN users u ON u.id = m.author_id
73 LEFT JOIN users mu ON mu.id = m.merged_by
74 LEFT JOIN users cu ON cu.id = m.closed_by
6875 LEFT JOIN repos sr ON sr.id = m.source_repo_id
6976 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
7077 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id
@@ -73,7 +80,8 @@ const mrSelect = `
7380func scanMR(row interface{ Scan(...any) error }) (MR, error) {
7481 var m MR
7582 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
76 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
83 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase,
84 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt)
7785 return m, err
7886}
7987
@@ -142,17 +150,40 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err
142150}
143151
144152// MarkMerged records the merge along with the target tip it landed on, so
145// the MR's diff stays reconstructable after fast-forwards.
146func (s *Store) MarkMerged(mrID int64, baseSHA string) error {
153// the MR's diff stays reconstructable after fast-forwards. actorID 0 and an
154// empty at leave the merger unknown and stamp the current time, which is
155// what an import that carries neither can say.
156func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error {
147157 _, err := s.DB.Exec(
148 "UPDATE merge_requests SET state = 'merged', merged_base = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
149 baseSHA, mrID)
158 `UPDATE merge_requests SET state = 'merged', merged_base = ?,
159 merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
160 merged_by = NULLIF(?, 0),
161 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
162 baseSHA, at, actorID, mrID)
150163 return err
151164}
152165
166// MarkClosed is MarkMerged's counterpart for a merge request closed without
167// merging.
168func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
169 _, err := s.DB.Exec(
170 `UPDATE merge_requests SET state = 'closed',
171 closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
172 closed_by = NULLIF(?, 0),
173 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
174 at, actorID, mrID)
175 return err
176}
177
178// SetMRState moves an MR between states that carry no resolution stamp.
179// Returning to open (a source branch that came back) clears one.
153180func (s *Store) SetMRState(mrID int64, state string) error {
181 stamp := ""
182 if state == "open" || state == "source_gone" {
183 stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL"
184 }
154185 res, err := s.DB.Exec(
155 "UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
186 "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
156187 state, mrID)
157188 if err != nil {
158189 return err
internal/store/mrs_test.go added +73
@@ -0,0 +1,73 @@
1package store
2
3import "testing"
4
5func mrFixture(t *testing.T) (*Store, int64, int64) {
6 t.Helper()
7 s := open(t)
8 if err := s.MigrateUp(); err != nil {
9 t.Fatal(err)
10 }
11 uid, err := s.CreateUser("cmc", true)
12 if err != nil {
13 t.Fatal(err)
14 }
15 repoID, err := s.CreateRepo("user", uid, "lib", "public")
16 if err != nil {
17 t.Fatal(err)
18 }
19 if _, err := s.CreateMR(repoID, uid, repoID, "feature", "main", "t", "", "abc123", "md"); err != nil {
20 t.Fatal(err)
21 }
22 return s, repoID, uid
23}
24
25// A merged or closed MR records who resolved it and when: the state alone
26// cannot say it, and updated_at moves for every edit.
27func TestResolutionStamps(t *testing.T) {
28 s, repoID, uid := mrFixture(t)
29 mr, err := s.MRByNumber(repoID, 1)
30 if err != nil {
31 t.Fatal(err)
32 }
33 if mr.MergedAt != "" || mr.ClosedAt != "" {
34 t.Fatalf("open MR carries a stamp: %+v", mr)
35 }
36 if err := s.MarkMerged(mr.ID, "base1", uid, ""); err != nil {
37 t.Fatal(err)
38 }
39 mr, _ = s.MRByNumber(repoID, 1)
40 if mr.State != "merged" || mr.MergedAt == "" || mr.MergedBy != "cmc" {
41 t.Fatalf("merge stamp: %+v", mr)
42 }
43
44 // Reopening — a source branch that came back — clears the stamp.
45 if err := s.SetMRState(mr.ID, "open"); err != nil {
46 t.Fatal(err)
47 }
48 mr, _ = s.MRByNumber(repoID, 1)
49 if mr.MergedAt != "" || mr.MergedBy != "" {
50 t.Fatalf("reopen kept the merge stamp: %+v", mr)
51 }
52
53 if err := s.MarkClosed(mr.ID, uid, ""); err != nil {
54 t.Fatal(err)
55 }
56 mr, _ = s.MRByNumber(repoID, 1)
57 if mr.State != "closed" || mr.ClosedAt == "" || mr.ClosedBy != "cmc" {
58 t.Fatalf("close stamp: %+v", mr)
59 }
60}
61
62// An import carries the upstream time but no local account for the actor.
63func TestResolutionStampImported(t *testing.T) {
64 s, repoID, _ := mrFixture(t)
65 mr, _ := s.MRByNumber(repoID, 1)
66 if err := s.MarkMerged(mr.ID, "base1", 0, "2024-03-02T10:00:00Z"); err != nil {
67 t.Fatal(err)
68 }
69 mr, _ = s.MRByNumber(repoID, 1)
70 if mr.MergedAt != "2024-03-02T10:00:00Z" || mr.MergedBy != "" {
71 t.Fatalf("imported merge stamp: %+v", mr)
72 }
73}
internal/store/statuses.go +42 −1
@@ -1,6 +1,9 @@
11package store
22
3import "strings"
3import (
4 "strings"
5 "time"
6)
47
58type CommitStatus struct {
69 Context string
@@ -106,3 +109,41 @@ func CombinedStatus(statuses []CommitStatus) string {
106109 }
107110 return combined
108111}
112
113// Check is a commit status paired with the build behind it. Only ci/<job>
114// statuses have one; anything posted through `status set` reports just the
115// state and when it last changed.
116type Check struct {
117 CommitStatus
118 Build int64 // build number, 0 when the check is not CI's
119 Duration time.Duration // 0 until that build has finished
120}
121
122// ChecksForCommit lists a commit's checks with their timing, and reduces
123// them to one combined state. Two queries at most, whatever the surface.
124func (s *Store) ChecksForCommit(repoID int64, sha string) ([]Check, string, error) {
125 statuses, err := s.ListCommitStatuses(repoID, sha)
126 if err != nil {
127 return nil, "", err
128 }
129 var builds map[string]Build
130 for _, st := range statuses {
131 if strings.HasPrefix(st.Context, "ci/") {
132 if builds, err = s.BuildsForCommit(repoID, sha); err != nil {
133 return nil, "", err
134 }
135 break
136 }
137 }
138 checks := make([]Check, 0, len(statuses))
139 for _, st := range statuses {
140 c := Check{CommitStatus: st}
141 if job, ok := strings.CutPrefix(st.Context, "ci/"); ok {
142 if b, ok := builds[job]; ok {
143 c.Build, c.Duration = b.Number, b.Elapsed()
144 }
145 }
146 checks = append(checks, c)
147 }
148 return checks, CombinedStatus(statuses), nil
149}
internal/store/statuses_test.go added +59
@@ -0,0 +1,59 @@
1package store
2
3import "testing"
4
5// Checks carry the timing of the build behind them. Statuses posted from
6// outside CI have no build, and must not borrow one.
7func TestChecksForCommit(t *testing.T) {
8 s := open(t)
9 if err := s.MigrateUp(); err != nil {
10 t.Fatal(err)
11 }
12 uid, err := s.CreateUser("cmc", true)
13 if err != nil {
14 t.Fatal(err)
15 }
16 repoID, err := s.CreateRepo("user", uid, "lib", "public")
17 if err != nil {
18 t.Fatal(err)
19 }
20 if _, err := s.CreateBuild(repoID, "test", "abc123", "main", `["true"]`); err != nil {
21 t.Fatal(err)
22 }
23 if _, err := s.DB.Exec(`UPDATE builds SET started_at = '2026-08-28T04:42:54Z',
24 finished_at = '2026-08-28T04:44:06Z', status = 'success' WHERE number = 1`); err != nil {
25 t.Fatal(err)
26 }
27 for _, c := range []struct{ context, state string }{
28 {"ci/test", "success"},
29 {"ci/absent", "pending"}, // a status with no build of its own
30 {"external/lint", "success"},
31 } {
32 if err := s.SetCommitStatus(repoID, "abc123", c.context, c.state, "", "", uid); err != nil {
33 t.Fatal(err)
34 }
35 }
36
37 checks, combined, err := s.ChecksForCommit(repoID, "abc123")
38 if err != nil {
39 t.Fatal(err)
40 }
41 if combined != "pending" {
42 t.Fatalf("combined: %s", combined)
43 }
44 byContext := map[string]Check{}
45 for _, c := range checks {
46 if c.UpdatedAt == "" {
47 t.Errorf("%s has no timestamp", c.Context)
48 }
49 byContext[c.Context] = c
50 }
51 if got := byContext["ci/test"]; got.Build != 1 || got.Duration.String() != "1m12s" {
52 t.Errorf("ci/test timing: build %d, %s", got.Build, got.Duration)
53 }
54 for _, ctx := range []string{"ci/absent", "external/lint"} {
55 if got := byContext[ctx]; got.Build != 0 || got.Duration != 0 {
56 t.Errorf("%s borrowed timing: build %d, %s", ctx, got.Build, got.Duration)
57 }
58 }
59}
internal/web/static/style.css +4
@@ -1492,6 +1492,10 @@ p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-
14921492.aside .row { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-1); flex-wrap: wrap; }
14931493.aside .row:last-child { margin-bottom: 0; }
14941494.aside .none { color: var(--muted); }
1495/* a row's second line: when it happened, under what it happened to. The
1496 extra gap keeps the pair reading as one entry. */
1497.aside .row .sub { flex-basis: 100%; color: var(--muted); font-size: var(--fs-0); }
1498.aside .row:has(.sub) { margin-bottom: var(--sp-2); }
14951499.aside .dot { width: 0.45rem; height: 0.45rem; flex: none; border-radius: 50%; background: var(--muted); }
14961500.aside .dot.ok { background: var(--ok); }
14971501.aside .dot.bad { background: var(--bad); }
internal/web/templates/mr.html +14 −3
@@ -1,9 +1,20 @@
1{{define "mrrange"}}<code>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}}</code> into <code>{{.TargetRef}}</code>{{end}}
12{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
23{{define "content"}}
34{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
45<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
56<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6<a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
7{{if and (eq .MR.State "merged") .MR.MergedAt}}
8 {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}}
9 {{template "mrrange" .MR}} on {{when .MR.MergedAt}}
10{{else if and (eq .MR.State "closed") .MR.ClosedAt}}
11 {{if .MR.ClosedBy}}<a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> closed this{{else}}Closed{{end}}
12 without merging {{template "mrrange" .MR}} on {{when .MR.ClosedAt}}
13{{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}
14 {{template "mrrange" .MR}}
15{{else}}
16 <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{template "mrrange" .MR}}
17{{end}}</p>
718
819{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
920
@@ -109,13 +120,13 @@
109120 {{end}}
110121 <div class="grp">
111122 <h2>Reviews</h2>
112 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>
123 {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p>
113124 {{else}}<p class="none">No reviews yet</p>{{end}}
114125 </div>
115126 <div class="grp">
116127 <h2>Checks</h2>
117128 {{if .Checks}}<p class="row"><span class="badge check-{{.Combined}}">{{.Combined}}</span></p>
118 {{range .Checks}}<p class="row"><span class="dot {{if eq .State "success"}}ok{{else if eq .State "failure"}}bad{{else}}pend{{end}}"></span>{{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="none">{{.State}}</span></p>{{end}}
129 {{range .Checks}}<p class="row"><span class="dot {{if eq .State "success"}}ok{{else if eq .State "failure"}}bad{{else}}pend{{end}}"></span>{{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="none">{{.State}}</span><span class="sub">{{when .UpdatedAt}}{{if .Duration}} · {{.Duration}}{{end}}</span></p>{{end}}
119130 {{else}}<p class="none">No checks reported</p>{{end}}
120131 </div>
121132 <div class="grp">