detached 74b956d

#12 fix(web): round the list cursor frame at the card corners

state
merged · cc7ba64 · merge commit
branch
fix/list-cursor-corners → main
opened
bnema ·
updated

The row cursor in .list tables was drawn with an outline on the . A row takes no border-radius, so its square frame showed inside the rounded card.

The frame and background now sit on the cells (inset box-shadow). The cells round only where the cursor row meets a corner of the card (first or last row), with the radius of the card minus its border; middle rows stay square. The divider above the cursor row is hidden so it does not cross the frame.

Checked with Playwright on the dev server: inbox with 4 rows (first, middle, last) and a single-row MR list.

CommitSubjectAuthorDateSignatureStatus
74b956dfix(web): round the list cursor frame at the card cornersbriceGPG Verified

Checks 4

5 files · +21 -160
4/4
M gen/ermite/storage/ci.pb.go +1 -2 generated file: collapsed
M internal/adapters/kvstore/ci.go +6 -43
@@ -26,7 +26,6 @@ import (
2626 // run/<repo>/<id:020d> CIRun
2727 // runsrc/<id:020d> CIRunSource
2828 // job/<run:020d>/<attempt:010d>/<id> CIJob
29-// jobctx/<run:020d>/<attempt:010d>/<id> contexts JSON
3029 // check/<repo>/<sha>/<check id> CICheck
3130 // runseq Sequence: last run id
3231 // runnum/<repo>/<workflow path> Sequence: last run number
@@ -47,7 +46,6 @@ const (
4746 runPrefix = "run/"
4847 runSrcPrefix = "runsrc/"
4948 jobPrefix = "job/"
50- jobCtxPrefix = "jobctx/"
5149 checkPrefix = "check/"
5250 runSeqKey = "runseq"
5351 runNumPrefix = "runnum/"
@@ -87,10 +85,6 @@ func jobRef(runID int64, attempt int32, id string) string {
8785 return jobAttemptPrefix(runID, attempt) + id
8886 }
8987 func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }
90-func jobContextsKey(runID int64, attempt int32, id string) []byte {
91- return []byte(jobCtxPrefix + strings.TrimPrefix(jobRef(runID, attempt, id), jobPrefix))
92-}
93-
9488 func checksPrefix(repo domain.RepoName, sha string) string {
9589 return checkPrefix + repo.String() + "/" + sha + "/"
9690 }
@@ -341,7 +335,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
341335 Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,
342336 Isolation: encodeIsolation(j.Isolation), Steps: steps,
343337 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
344- Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
338+ Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
345339 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
346340 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
347341 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
@@ -391,19 +385,6 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
391385 return j, nil
392386 }
393387
394-// decodeJobWithContexts reads both parts from the same snapshot. The inline
395-// field is retained only as a fallback for records written before the split.
396-func decodeJobWithContexts(ctx context.Context, snap *kv.Snapshot, item kv.Item) (domain.CIJob, error) {
397- j, err := decodeJob(item)
398- if err != nil {
399- return domain.CIJob{}, err
400- }
401- if contexts, ok := snap.Get(ctx, jobContextsKey(j.RunID, j.RunAttempt, j.ID)); ok {
402- j.ContextsJSON = string(contexts.Value)
403- }
404- return j, nil
405-}
406-
407388 func encodeCheck(c domain.CICheck) []byte {
408389 return mustMarshal(&storagepb.CICheck{
409390 Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),
@@ -534,12 +515,6 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
534515 old = &o
535516 }
536517 }
537- // Compare the separate value, not the legacy inline field: an old
538- // record must materialize contexts before encodeJob drops that field.
539- contextsKey := jobContextsKey(j.RunID, j.RunAttempt, j.ID)
540- if contexts, ok := c.db.Get(ctx, contextsKey); !ok || string(contexts.Value) != j.ContextsJSON {
541- b.Put(contextsKey, []byte(j.ContextsJSON))
542- }
543518 stage(b, key, val, j.Version)
544519 c.jobIndexes(b, j, old)
545520 if j.Version == 0 {
@@ -771,21 +746,19 @@ func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
771746
772747 // GetJob reads one job record.
773748 func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
774- snap := c.db.Snapshot(ctx)
775- item, ok := snap.Get(ctx, jobKey(runID, runAttempt, jobID))
749+ item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID))
776750 if !ok {
777751 return domain.CIJob{}, domain.ErrNotFound
778752 }
779- return decodeJobWithContexts(ctx, snap, item)
753+ return decodeJob(item)
780754 }
781755
782756 // ListJobs returns the jobs of a run at an attempt, ordered by index then
783757 // combination.
784758 func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
785759 var out []domain.CIJob
786- snap := c.db.Snapshot(ctx)
787- for key, item := range snap.Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
788- j, err := decodeJobWithContexts(ctx, snap, item)
760+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
761+ j, err := decodeJob(item)
789762 if err != nil {
790763 return nil, fmt.Errorf("decode %s: %w", key, err)
791764 }
@@ -805,7 +778,7 @@ func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domai
805778 if !ok {
806779 return domain.CIJob{}, domain.ErrNotFound
807780 }
808- return decodeJobWithContexts(ctx, snap, item)
781+ return decodeJob(item)
809782 }
810783
811784 // FindJobByToken resolves the token index, then reads the job.
@@ -889,21 +862,11 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
889862
890863 // --- retention and deletion ---
891864
892-// jobCtxKeys lists the contexts keys of every attempt of a run.
893-func jobCtxKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
894- var keys [][]byte
895- for key := range snap.Prefix(ctx, []byte(jobCtxPrefix+fmt.Sprintf(idFormat, runID)+"/")) {
896- keys = append(keys, slices.Clone(key))
897- }
898- return keys
899-}
900-
901865 // runKeys lists every key of a run except its record: source, jobs, indexes.
902866 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
903867 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
904868 keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
905869 keys = append(keys, annotationKeys(ctx, snap, runID)...)
906- keys = append(keys, jobCtxKeys(ctx, snap, runID)...)
907870 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
908871 keys = append(keys, slices.Clone(key))
909872 if j, err := decodeJob(item); err == nil {
@@ -26,7 +26,6 @@ import (
26 // run/<repo>/<id:020d> CIRun26 // run/<repo>/<id:020d> CIRun
27 // runsrc/<id:020d> CIRunSource27 // runsrc/<id:020d> CIRunSource
28 // job/<run:020d>/<attempt:010d>/<id> CIJob28 // job/<run:020d>/<attempt:010d>/<id> CIJob
29-// jobctx/<run:020d>/<attempt:010d>/<id> contexts JSON
30 // check/<repo>/<sha>/<check id> CICheck29 // check/<repo>/<sha>/<check id> CICheck
31 // runseq Sequence: last run id30 // runseq Sequence: last run id
32 // runnum/<repo>/<workflow path> Sequence: last run number31 // runnum/<repo>/<workflow path> Sequence: last run number
@@ -47,7 +46,6 @@ const (
47 runPrefix = "run/"46 runPrefix = "run/"
48 runSrcPrefix = "runsrc/"47 runSrcPrefix = "runsrc/"
49 jobPrefix = "job/"48 jobPrefix = "job/"
50- jobCtxPrefix = "jobctx/"
51 checkPrefix = "check/"49 checkPrefix = "check/"
52 runSeqKey = "runseq"50 runSeqKey = "runseq"
53 runNumPrefix = "runnum/"51 runNumPrefix = "runnum/"
@@ -87,10 +85,6 @@ func jobRef(runID int64, attempt int32, id string) string {
87 return jobAttemptPrefix(runID, attempt) + id85 return jobAttemptPrefix(runID, attempt) + id
88 }86 }
89 func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }87 func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }
90-func jobContextsKey(runID int64, attempt int32, id string) []byte {
91- return []byte(jobCtxPrefix + strings.TrimPrefix(jobRef(runID, attempt, id), jobPrefix))
92-}
93-
94 func checksPrefix(repo domain.RepoName, sha string) string {88 func checksPrefix(repo domain.RepoName, sha string) string {
95 return checkPrefix + repo.String() + "/" + sha + "/"89 return checkPrefix + repo.String() + "/" + sha + "/"
96 }90 }
@@ -341,7 +335,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
341 Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,335 Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,
342 Isolation: encodeIsolation(j.Isolation), Steps: steps,336 Isolation: encodeIsolation(j.Isolation), Steps: steps,
343 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),337 CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
344- Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit338+ Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
345 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning339 TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, ContinueOnError: j.ContinueOnError, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
346 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),340 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
347 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3341 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
@@ -391,19 +385,6 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
391 return j, nil385 return j, nil
392 }386 }
393 387
394-// decodeJobWithContexts reads both parts from the same snapshot. The inline
395-// field is retained only as a fallback for records written before the split.
396-func decodeJobWithContexts(ctx context.Context, snap *kv.Snapshot, item kv.Item) (domain.CIJob, error) {
397- j, err := decodeJob(item)
398- if err != nil {
399- return domain.CIJob{}, err
400- }
401- if contexts, ok := snap.Get(ctx, jobContextsKey(j.RunID, j.RunAttempt, j.ID)); ok {
402- j.ContextsJSON = string(contexts.Value)
403- }
404- return j, nil
405-}
406-
407 func encodeCheck(c domain.CICheck) []byte {388 func encodeCheck(c domain.CICheck) []byte {
408 return mustMarshal(&storagepb.CICheck{389 return mustMarshal(&storagepb.CICheck{
409 Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),390 Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),
@@ -534,12 +515,6 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
534 old = &o515 old = &o
535 }516 }
536 }517 }
537- // Compare the separate value, not the legacy inline field: an old
538- // record must materialize contexts before encodeJob drops that field.
539- contextsKey := jobContextsKey(j.RunID, j.RunAttempt, j.ID)
540- if contexts, ok := c.db.Get(ctx, contextsKey); !ok || string(contexts.Value) != j.ContextsJSON {
541- b.Put(contextsKey, []byte(j.ContextsJSON))
542- }
543 stage(b, key, val, j.Version)518 stage(b, key, val, j.Version)
544 c.jobIndexes(b, j, old)519 c.jobIndexes(b, j, old)
545 if j.Version == 0 {520 if j.Version == 0 {
@@ -771,21 +746,19 @@ func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
771 746
772 // GetJob reads one job record.747 // GetJob reads one job record.
773 func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {748 func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
774- snap := c.db.Snapshot(ctx)749+ item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID))
775- item, ok := snap.Get(ctx, jobKey(runID, runAttempt, jobID))
776 if !ok {750 if !ok {
777 return domain.CIJob{}, domain.ErrNotFound751 return domain.CIJob{}, domain.ErrNotFound
778 }752 }
779- return decodeJobWithContexts(ctx, snap, item)753+ return decodeJob(item)
780 }754 }
781 755
782 // ListJobs returns the jobs of a run at an attempt, ordered by index then756 // ListJobs returns the jobs of a run at an attempt, ordered by index then
783 // combination.757 // combination.
784 func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {758 func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
785 var out []domain.CIJob759 var out []domain.CIJob
786- snap := c.db.Snapshot(ctx)760+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
787- for key, item := range snap.Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {761+ j, err := decodeJob(item)
788- j, err := decodeJobWithContexts(ctx, snap, item)
789 if err != nil {762 if err != nil {
790 return nil, fmt.Errorf("decode %s: %w", key, err)763 return nil, fmt.Errorf("decode %s: %w", key, err)
791 }764 }
@@ -805,7 +778,7 @@ func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domai
805 if !ok {778 if !ok {
806 return domain.CIJob{}, domain.ErrNotFound779 return domain.CIJob{}, domain.ErrNotFound
807 }780 }
808- return decodeJobWithContexts(ctx, snap, item)781+ return decodeJob(item)
809 }782 }
810 783
811 // FindJobByToken resolves the token index, then reads the job.784 // FindJobByToken resolves the token index, then reads the job.
@@ -889,21 +862,11 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
889 862
890 // --- retention and deletion ---863 // --- retention and deletion ---
891 864
892-// jobCtxKeys lists the contexts keys of every attempt of a run.
893-func jobCtxKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
894- var keys [][]byte
895- for key := range snap.Prefix(ctx, []byte(jobCtxPrefix+fmt.Sprintf(idFormat, runID)+"/")) {
896- keys = append(keys, slices.Clone(key))
897- }
898- return keys
899-}
900-
901 // runKeys lists every key of a run except its record: source, jobs, indexes.865 // runKeys lists every key of a run except its record: source, jobs, indexes.
902 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {866 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
903 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}867 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
904 keys = append(keys, repoTokenKeys(ctx, snap, runID)...)868 keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
905 keys = append(keys, annotationKeys(ctx, snap, runID)...)869 keys = append(keys, annotationKeys(ctx, snap, runID)...)
906- keys = append(keys, jobCtxKeys(ctx, snap, runID)...)
907 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {870 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
908 keys = append(keys, slices.Clone(key))871 keys = append(keys, slices.Clone(key))
909 if j, err := decodeJob(item); err == nil {872 if j, err := decodeJob(item); err == nil {
D internal/adapters/kvstore/ci_contexts_test.go +0 -111
@@ -1,111 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package kvstore
4-
5-import (
6- "context"
7- "strings"
8- "testing"
9- "time"
10-
11- kv "bnema.dev/ermite-kv"
12- storagepb "bnema.dev/ermite/gen/ermite/storage"
13- "bnema.dev/ermite/internal/domain"
14- "bnema.dev/ermite/internal/ports"
15- "github.com/stretchr/testify/require"
16- "google.golang.org/protobuf/proto"
17-)
18-
19-func TestCIJobContextsStorage(t *testing.T) {
20- ctx := context.Background()
21- s, err := Open(ctx, t.TempDir())
22- require.NoError(t, err)
23- t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
24- repo, err := domain.ParseRepoName("proj")
25- require.NoError(t, err)
26- require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
27- ci := s.CI()
28- payload := `{"event":"` + strings.Repeat("payload", 3500) + `"}`
29- j := domain.CIJob{ID: "build", Repo: repo, RunID: 1, RunAttempt: 1, Attempt: 1, Phase: domain.CIPhaseReady, ContextsJSON: payload}
30- save := func() {
31- v, err := ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
32- require.NoError(t, err)
33- j.Version = v
34- }
35- save()
36- contextsKey := jobContextsKey(1, 1, j.ID)
37- initial, ok := ci.db.Get(ctx, contextsKey)
38- require.True(t, ok)
39- for i := range 5 {
40- j.Steps = []domain.CIStep{{Number: int32(i + 1), Name: "step", State: domain.CIStepInProgress}}
41- save()
42- stored, ok := ci.db.Get(ctx, jobKey(1, 1, j.ID))
43- require.True(t, ok)
44- require.NotContains(t, string(stored.Value), payload)
45- var pb storagepb.CIJob
46- require.NoError(t, proto.Unmarshal(stored.Value, &pb))
47- require.Empty(t, pb.GetContextsJson())
48- contexts, ok := ci.db.Get(ctx, contextsKey)
49- require.True(t, ok)
50- require.Equal(t, initial.Version, contexts.Version, "unchanged contexts must not be rewritten")
51- got, err := ci.GetJob(ctx, 1, 1, j.ID)
52- require.NoError(t, err)
53- require.Equal(t, payload, got.ContextsJSON)
54- jobs, err := ci.ListJobs(ctx, 1, 1)
55- require.NoError(t, err)
56- require.Equal(t, payload, jobs[0].ContextsJSON)
57- ready, err := ci.ListReadyJobs(ctx, 0)
58- require.NoError(t, err)
59- require.Equal(t, payload, ready[0].ContextsJSON)
60- }
61- // A kept job in a re-run gets an independent contexts key.
62- j.RunAttempt = 2
63- j.Version = 0
64- save()
65- copied, ok := ci.db.Get(ctx, jobContextsKey(1, 2, j.ID))
66- require.True(t, ok)
67- require.Equal(t, payload, string(copied.Value))
68- j.ContextsJSON = ""
69- save()
70- got, err := ci.GetJob(ctx, 1, 2, j.ID)
71- require.NoError(t, err)
72- require.Empty(t, got.ContextsJSON)
73- // Sweep must remove contexts from every attempt as well as job records.
74- _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{{Repo: repo, ID: 1, Status: domain.CICompleted, CompletedAt: time.Now().Add(-time.Hour)}}})
75- require.NoError(t, err)
76- ids, err := ci.Sweep(ctx, time.Now())
77- require.NoError(t, err)
78- require.Equal(t, []int64{1}, ids)
79- for range ci.db.Snapshot(ctx).Prefix(ctx, []byte(jobCtxPrefix)) {
80- t.Fatal("orphan contexts key")
81- }
82-}
83-
84-func TestCIJobLegacyContexts(t *testing.T) {
85- ctx := context.Background()
86- s, err := Open(ctx, t.TempDir())
87- require.NoError(t, err)
88- t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
89- ci := s.CI()
90- var b kv.Batch
91- b.Put(jobKey(1, 1, "build"), mustMarshal(&storagepb.CIJob{Id: "build", Repo: "proj", RunId: 1, RunAttempt: 1, ContextsJson: `{"legacy":true}`}))
92- _, err = ci.db.Apply(ctx, &b)
93- require.NoError(t, err)
94- j, err := ci.GetJob(ctx, 1, 1, "build")
95- require.NoError(t, err)
96- require.Equal(t, `{"legacy":true}`, j.ContextsJSON)
97- jobs, err := ci.ListJobs(ctx, 1, 1)
98- require.NoError(t, err)
99- require.Equal(t, j.ContextsJSON, jobs[0].ContextsJSON)
100- // Updating the legacy job moves its contexts out of the protobuf.
101- _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
102- require.NoError(t, err)
103- got, err := ci.GetJob(ctx, 1, 1, "build")
104- require.NoError(t, err)
105- require.Equal(t, j.ContextsJSON, got.ContextsJSON)
106- item, ok := ci.db.Get(ctx, jobKey(1, 1, "build"))
107- require.True(t, ok)
108- pbJob, err := decodeJob(item)
109- require.NoError(t, err)
110- require.Empty(t, pbJob.ContextsJSON)
111-}
@@ -1,111 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package kvstore
4-
5-import (
6- "context"
7- "strings"
8- "testing"
9- "time"
10-
11- kv "bnema.dev/ermite-kv"
12- storagepb "bnema.dev/ermite/gen/ermite/storage"
13- "bnema.dev/ermite/internal/domain"
14- "bnema.dev/ermite/internal/ports"
15- "github.com/stretchr/testify/require"
16- "google.golang.org/protobuf/proto"
17-)
18-
19-func TestCIJobContextsStorage(t *testing.T) {
20- ctx := context.Background()
21- s, err := Open(ctx, t.TempDir())
22- require.NoError(t, err)
23- t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
24- repo, err := domain.ParseRepoName("proj")
25- require.NoError(t, err)
26- require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
27- ci := s.CI()
28- payload := `{"event":"` + strings.Repeat("payload", 3500) + `"}`
29- j := domain.CIJob{ID: "build", Repo: repo, RunID: 1, RunAttempt: 1, Attempt: 1, Phase: domain.CIPhaseReady, ContextsJSON: payload}
30- save := func() {
31- v, err := ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
32- require.NoError(t, err)
33- j.Version = v
34- }
35- save()
36- contextsKey := jobContextsKey(1, 1, j.ID)
37- initial, ok := ci.db.Get(ctx, contextsKey)
38- require.True(t, ok)
39- for i := range 5 {
40- j.Steps = []domain.CIStep{{Number: int32(i + 1), Name: "step", State: domain.CIStepInProgress}}
41- save()
42- stored, ok := ci.db.Get(ctx, jobKey(1, 1, j.ID))
43- require.True(t, ok)
44- require.NotContains(t, string(stored.Value), payload)
45- var pb storagepb.CIJob
46- require.NoError(t, proto.Unmarshal(stored.Value, &pb))
47- require.Empty(t, pb.GetContextsJson())
48- contexts, ok := ci.db.Get(ctx, contextsKey)
49- require.True(t, ok)
50- require.Equal(t, initial.Version, contexts.Version, "unchanged contexts must not be rewritten")
51- got, err := ci.GetJob(ctx, 1, 1, j.ID)
52- require.NoError(t, err)
53- require.Equal(t, payload, got.ContextsJSON)
54- jobs, err := ci.ListJobs(ctx, 1, 1)
55- require.NoError(t, err)
56- require.Equal(t, payload, jobs[0].ContextsJSON)
57- ready, err := ci.ListReadyJobs(ctx, 0)
58- require.NoError(t, err)
59- require.Equal(t, payload, ready[0].ContextsJSON)
60- }
61- // A kept job in a re-run gets an independent contexts key.
62- j.RunAttempt = 2
63- j.Version = 0
64- save()
65- copied, ok := ci.db.Get(ctx, jobContextsKey(1, 2, j.ID))
66- require.True(t, ok)
67- require.Equal(t, payload, string(copied.Value))
68- j.ContextsJSON = ""
69- save()
70- got, err := ci.GetJob(ctx, 1, 2, j.ID)
71- require.NoError(t, err)
72- require.Empty(t, got.ContextsJSON)
73- // Sweep must remove contexts from every attempt as well as job records.
74- _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{{Repo: repo, ID: 1, Status: domain.CICompleted, CompletedAt: time.Now().Add(-time.Hour)}}})
75- require.NoError(t, err)
76- ids, err := ci.Sweep(ctx, time.Now())
77- require.NoError(t, err)
78- require.Equal(t, []int64{1}, ids)
79- for range ci.db.Snapshot(ctx).Prefix(ctx, []byte(jobCtxPrefix)) {
80- t.Fatal("orphan contexts key")
81- }
82-}
83-
84-func TestCIJobLegacyContexts(t *testing.T) {
85- ctx := context.Background()
86- s, err := Open(ctx, t.TempDir())
87- require.NoError(t, err)
88- t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
89- ci := s.CI()
90- var b kv.Batch
91- b.Put(jobKey(1, 1, "build"), mustMarshal(&storagepb.CIJob{Id: "build", Repo: "proj", RunId: 1, RunAttempt: 1, ContextsJson: `{"legacy":true}`}))
92- _, err = ci.db.Apply(ctx, &b)
93- require.NoError(t, err)
94- j, err := ci.GetJob(ctx, 1, 1, "build")
95- require.NoError(t, err)
96- require.Equal(t, `{"legacy":true}`, j.ContextsJSON)
97- jobs, err := ci.ListJobs(ctx, 1, 1)
98- require.NoError(t, err)
99- require.Equal(t, j.ContextsJSON, jobs[0].ContextsJSON)
100- // Updating the legacy job moves its contexts out of the protobuf.
101- _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
102- require.NoError(t, err)
103- got, err := ci.GetJob(ctx, 1, 1, "build")
104- require.NoError(t, err)
105- require.Equal(t, j.ContextsJSON, got.ContextsJSON)
106- item, ok := ci.db.Get(ctx, jobKey(1, 1, "build"))
107- require.True(t, ok)
108- pbJob, err := decodeJob(item)
109- require.NoError(t, err)
110- require.Empty(t, pbJob.ContextsJSON)
111-}
M internal/adapters/web/static/css/30-list.css +13 -2
@@ -15,8 +15,19 @@
1515 & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }
1616 & tbody tr:first-child td { border-top: 0; }
1717 & tbody tr:hover { background: var(--bg-raised); }
18- /* the cursor: the row raised, framed by one even line */
19- & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--accent); outline-offset: -1px; }
18+ /* the cursor: the row raised, framed by one even line. The frame is drawn
19+ on the cells (a row takes no radius), so it can follow the card's
20+ curve when the row sits at a corner. */
21+ & tbody tr[data-cursor] > td {
22+ background: var(--bg-raised); border-top-color: transparent;
23+ box-shadow: inset 0 1px var(--accent), inset 0 -1px var(--accent);
24+ }
25+ & tbody tr[data-cursor] > td:first-child { box-shadow: inset 1px 0 var(--accent), inset 0 1px var(--accent), inset 0 -1px var(--accent); }
26+ & tbody tr[data-cursor] > td:last-child { box-shadow: inset -1px 0 var(--accent), inset 0 1px var(--accent), inset 0 -1px var(--accent); }
27+ & > tbody:first-child > tr[data-cursor]:first-child > td:first-child { border-top-left-radius: calc(var(--r) - 1px); }
28+ & > tbody:first-child > tr[data-cursor]:first-child > td:last-child { border-top-right-radius: calc(var(--r) - 1px); }
29+ & > tbody:last-child > tr[data-cursor]:last-child > td:first-child { border-bottom-left-radius: calc(var(--r) - 1px); }
30+ & > tbody:last-child > tr[data-cursor]:last-child > td:last-child { border-bottom-right-radius: calc(var(--r) - 1px); }
2031 & tbody tr[hidden] { display: none; }
2132 & td > a { text-decoration: none; }
2233 & td > a:first-child { color: var(--ink-hi); }
@@ -15,8 +15,19 @@
15 & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }15 & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }
16 & tbody tr:first-child td { border-top: 0; }16 & tbody tr:first-child td { border-top: 0; }
17 & tbody tr:hover { background: var(--bg-raised); }17 & tbody tr:hover { background: var(--bg-raised); }
18- /* the cursor: the row raised, framed by one even line */18+ /* the cursor: the row raised, framed by one even line. The frame is drawn
19- & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--accent); outline-offset: -1px; }19+ on the cells (a row takes no radius), so it can follow the card's
20+ curve when the row sits at a corner. */
21+ & tbody tr[data-cursor] > td {
22+ background: var(--bg-raised); border-top-color: transparent;
23+ box-shadow: inset 0 1px var(--accent), inset 0 -1px var(--accent);
24+ }
25+ & tbody tr[data-cursor] > td:first-child { box-shadow: inset 1px 0 var(--accent), inset 0 1px var(--accent), inset 0 -1px var(--accent); }
26+ & tbody tr[data-cursor] > td:last-child { box-shadow: inset -1px 0 var(--accent), inset 0 1px var(--accent), inset 0 -1px var(--accent); }
27+ & > tbody:first-child > tr[data-cursor]:first-child > td:first-child { border-top-left-radius: calc(var(--r) - 1px); }
28+ & > tbody:first-child > tr[data-cursor]:first-child > td:last-child { border-top-right-radius: calc(var(--r) - 1px); }
29+ & > tbody:last-child > tr[data-cursor]:last-child > td:first-child { border-bottom-left-radius: calc(var(--r) - 1px); }
30+ & > tbody:last-child > tr[data-cursor]:last-child > td:last-child { border-bottom-right-radius: calc(var(--r) - 1px); }
20 & tbody tr[hidden] { display: none; }31 & tbody tr[hidden] { display: none; }
21 & td > a { text-decoration: none; }32 & td > a { text-decoration: none; }
22 & td > a:first-child { color: var(--ink-hi); }33 & td > a:first-child { color: var(--ink-hi); }
M proto/ermite/storage/ci.proto +1 -2
@@ -203,8 +203,7 @@ message CIJob {
203203 // keeps its own attempt.
204204 int32 run_attempt = 38;
205205 // The contexts JSON given to the runner, computed when the job is planned
206- // (needs outputs are final then). No longer written: stored under jobctx/
207- // instead. Retained as a read fallback for legacy records.
206+ // (needs outputs are final then).
208207 string contexts_json = 39;
209208 // The job's continue-on-error, evaluated when the job is planned: a failure
210209 // of the job does not fail the run, does not cancel the siblings of its
@@ -203,8 +203,7 @@ message CIJob {
203 // keeps its own attempt.203 // keeps its own attempt.
204 int32 run_attempt = 38;204 int32 run_attempt = 38;
205 // The contexts JSON given to the runner, computed when the job is planned205 // The contexts JSON given to the runner, computed when the job is planned
206- // (needs outputs are final then). No longer written: stored under jobctx/206+ // (needs outputs are final then).
207- // instead. Retained as a read fallback for legacy records.
208 string contexts_json = 39;207 string contexts_json = 39;
209 // The job's continue-on-error, evaluated when the job is planned: a failure208 // The job's continue-on-error, evaluated when the job is planned: a failure
210 // of the job does not fail the run, does not cancel the siblings of its209 // of the job does not fail the run, does not cancel the siblings of its

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close