detached 19fda0c

feat(ci): store and show annotations and job summaries

commit
19fda0c57ddb3fe28c33e4f85e0ff540c71334d9
author
brice · · 2026-10-13 12:45 UTC
parent
52acaf4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
42 files · +5045 -565
M README.md +1 -1
@@ -126,7 +126,7 @@ The Actions that run here call a small part of GitHub's REST API, served at `git
126126
127127 Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
128128
129-`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.
129+`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown.
130130
131131 ## Web sign-in
132132
M gen/ermite/storage/ci.pb.go +291 -54
@@ -369,6 +369,59 @@ func (CICancelReason) EnumDescriptor() ([]byte, []int) {
369369 return file_ermite_storage_ci_proto_rawDescGZIP(), []int{5}
370370 }
371371
372+// CIAnnotationLevel is the severity of an annotation.
373+type CIAnnotationLevel int32
374+
375+const (
376+ CIAnnotationLevel_CI_ANNOTATION_LEVEL_UNSPECIFIED CIAnnotationLevel = 0
377+ CIAnnotationLevel_CI_ANNOTATION_LEVEL_NOTICE CIAnnotationLevel = 1
378+ CIAnnotationLevel_CI_ANNOTATION_LEVEL_WARNING CIAnnotationLevel = 2
379+ CIAnnotationLevel_CI_ANNOTATION_LEVEL_FAILURE CIAnnotationLevel = 3
380+)
381+
382+// Enum value maps for CIAnnotationLevel.
383+var (
384+ CIAnnotationLevel_name = map[int32]string{
385+ 0: "CI_ANNOTATION_LEVEL_UNSPECIFIED",
386+ 1: "CI_ANNOTATION_LEVEL_NOTICE",
387+ 2: "CI_ANNOTATION_LEVEL_WARNING",
388+ 3: "CI_ANNOTATION_LEVEL_FAILURE",
389+ }
390+ CIAnnotationLevel_value = map[string]int32{
391+ "CI_ANNOTATION_LEVEL_UNSPECIFIED": 0,
392+ "CI_ANNOTATION_LEVEL_NOTICE": 1,
393+ "CI_ANNOTATION_LEVEL_WARNING": 2,
394+ "CI_ANNOTATION_LEVEL_FAILURE": 3,
395+ }
396+)
397+
398+func (x CIAnnotationLevel) Enum() *CIAnnotationLevel {
399+ p := new(CIAnnotationLevel)
400+ *p = x
401+ return p
402+}
403+
404+func (x CIAnnotationLevel) String() string {
405+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
406+}
407+
408+func (CIAnnotationLevel) Descriptor() protoreflect.EnumDescriptor {
409+ return file_ermite_storage_ci_proto_enumTypes[6].Descriptor()
410+}
411+
412+func (CIAnnotationLevel) Type() protoreflect.EnumType {
413+ return &file_ermite_storage_ci_proto_enumTypes[6]
414+}
415+
416+func (x CIAnnotationLevel) Number() protoreflect.EnumNumber {
417+ return protoreflect.EnumNumber(x)
418+}
419+
420+// Deprecated: Use CIAnnotationLevel.Descriptor instead.
421+func (CIAnnotationLevel) EnumDescriptor() ([]byte, []int) {
422+ return file_ermite_storage_ci_proto_rawDescGZIP(), []int{6}
423+}
424+
372425 // CIRun is stored under "run/<repo>/<id:020d>".
373426 type CIRun struct {
374427 state protoimpl.MessageState `protogen:"open.v1"`
@@ -674,8 +727,11 @@ type CIStep struct {
674727 // Number of the first log line of the step; meaningful with has_lines. The
675728 // end of a step is the start of the next step that has lines, or the line
676729 // count of the job: it is never stored.
677- LogStart int64 `protobuf:"varint,7,opt,name=log_start,json=logStart,proto3" json:"log_start,omitempty"`
678- HasLines bool `protobuf:"varint,9,opt,name=has_lines,json=hasLines,proto3" json:"has_lines,omitempty"`
730+ LogStart int64 `protobuf:"varint,7,opt,name=log_start,json=logStart,proto3" json:"log_start,omitempty"`
731+ HasLines bool `protobuf:"varint,9,opt,name=has_lines,json=hasLines,proto3" json:"has_lines,omitempty"`
732+ // How the step ended before continue-on-error was applied; unspecified for
733+ // a step stored before the field existed (read it as conclusion).
734+ Outcome CIConclusion `protobuf:"varint,11,opt,name=outcome,proto3,enum=ermite.storage.CIConclusion" json:"outcome,omitempty"`
679735 unknownFields protoimpl.UnknownFields
680736 sizeCache protoimpl.SizeCache
681737 }
@@ -766,6 +822,13 @@ func (x *CIStep) GetHasLines() bool {
766822 return false
767823 }
768824
825+func (x *CIStep) GetOutcome() CIConclusion {
826+ if x != nil {
827+ return x.Outcome
828+ }
829+ return CIConclusion_CI_CONCLUSION_UNSPECIFIED
830+}
831+
769832 // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".
770833 type CIJob struct {
771834 state protoimpl.MessageState `protogen:"open.v1"`
@@ -1578,6 +1641,155 @@ func (x *CIActionResolution) GetSource() int32 {
15781641 return 0
15791642 }
15801643
1644+// CIAnnotation is a message attached to a step of a job.
1645+type CIAnnotation struct {
1646+ state protoimpl.MessageState `protogen:"open.v1"`
1647+ Level CIAnnotationLevel `protobuf:"varint,1,opt,name=level,proto3,enum=ermite.storage.CIAnnotationLevel" json:"level,omitempty"`
1648+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
1649+ Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
1650+ File string `protobuf:"bytes,4,opt,name=file,proto3" json:"file,omitempty"`
1651+ StartLine int32 `protobuf:"varint,5,opt,name=start_line,json=startLine,proto3" json:"start_line,omitempty"`
1652+ EndLine int32 `protobuf:"varint,6,opt,name=end_line,json=endLine,proto3" json:"end_line,omitempty"`
1653+ StartColumn int32 `protobuf:"varint,7,opt,name=start_column,json=startColumn,proto3" json:"start_column,omitempty"`
1654+ EndColumn int32 `protobuf:"varint,8,opt,name=end_column,json=endColumn,proto3" json:"end_column,omitempty"`
1655+ unknownFields protoimpl.UnknownFields
1656+ sizeCache protoimpl.SizeCache
1657+}
1658+
1659+func (x *CIAnnotation) Reset() {
1660+ *x = CIAnnotation{}
1661+ mi := &file_ermite_storage_ci_proto_msgTypes[8]
1662+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1663+ ms.StoreMessageInfo(mi)
1664+}
1665+
1666+func (x *CIAnnotation) String() string {
1667+ return protoimpl.X.MessageStringOf(x)
1668+}
1669+
1670+func (*CIAnnotation) ProtoMessage() {}
1671+
1672+func (x *CIAnnotation) ProtoReflect() protoreflect.Message {
1673+ mi := &file_ermite_storage_ci_proto_msgTypes[8]
1674+ if x != nil {
1675+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1676+ if ms.LoadMessageInfo() == nil {
1677+ ms.StoreMessageInfo(mi)
1678+ }
1679+ return ms
1680+ }
1681+ return mi.MessageOf(x)
1682+}
1683+
1684+// Deprecated: Use CIAnnotation.ProtoReflect.Descriptor instead.
1685+func (*CIAnnotation) Descriptor() ([]byte, []int) {
1686+ return file_ermite_storage_ci_proto_rawDescGZIP(), []int{8}
1687+}
1688+
1689+func (x *CIAnnotation) GetLevel() CIAnnotationLevel {
1690+ if x != nil {
1691+ return x.Level
1692+ }
1693+ return CIAnnotationLevel_CI_ANNOTATION_LEVEL_UNSPECIFIED
1694+}
1695+
1696+func (x *CIAnnotation) GetTitle() string {
1697+ if x != nil {
1698+ return x.Title
1699+ }
1700+ return ""
1701+}
1702+
1703+func (x *CIAnnotation) GetMessage() string {
1704+ if x != nil {
1705+ return x.Message
1706+ }
1707+ return ""
1708+}
1709+
1710+func (x *CIAnnotation) GetFile() string {
1711+ if x != nil {
1712+ return x.File
1713+ }
1714+ return ""
1715+}
1716+
1717+func (x *CIAnnotation) GetStartLine() int32 {
1718+ if x != nil {
1719+ return x.StartLine
1720+ }
1721+ return 0
1722+}
1723+
1724+func (x *CIAnnotation) GetEndLine() int32 {
1725+ if x != nil {
1726+ return x.EndLine
1727+ }
1728+ return 0
1729+}
1730+
1731+func (x *CIAnnotation) GetStartColumn() int32 {
1732+ if x != nil {
1733+ return x.StartColumn
1734+ }
1735+ return 0
1736+}
1737+
1738+func (x *CIAnnotation) GetEndColumn() int32 {
1739+ if x != nil {
1740+ return x.EndColumn
1741+ }
1742+ return 0
1743+}
1744+
1745+// CIStepAnnotations holds all the annotations of one step of one execution of
1746+// a job, in the order the step produced them. It is stored under
1747+// "annot/<run:020d>/<job attempt:010d>/<job id>/<step:010d>"; a step without
1748+// annotation has no key. The runner replaces the whole value at each report.
1749+type CIStepAnnotations struct {
1750+ state protoimpl.MessageState `protogen:"open.v1"`
1751+ Annotations []*CIAnnotation `protobuf:"bytes,1,rep,name=annotations,proto3" json:"annotations,omitempty"`
1752+ unknownFields protoimpl.UnknownFields
1753+ sizeCache protoimpl.SizeCache
1754+}
1755+
1756+func (x *CIStepAnnotations) Reset() {
1757+ *x = CIStepAnnotations{}
1758+ mi := &file_ermite_storage_ci_proto_msgTypes[9]
1759+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1760+ ms.StoreMessageInfo(mi)
1761+}
1762+
1763+func (x *CIStepAnnotations) String() string {
1764+ return protoimpl.X.MessageStringOf(x)
1765+}
1766+
1767+func (*CIStepAnnotations) ProtoMessage() {}
1768+
1769+func (x *CIStepAnnotations) ProtoReflect() protoreflect.Message {
1770+ mi := &file_ermite_storage_ci_proto_msgTypes[9]
1771+ if x != nil {
1772+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1773+ if ms.LoadMessageInfo() == nil {
1774+ ms.StoreMessageInfo(mi)
1775+ }
1776+ return ms
1777+ }
1778+ return mi.MessageOf(x)
1779+}
1780+
1781+// Deprecated: Use CIStepAnnotations.ProtoReflect.Descriptor instead.
1782+func (*CIStepAnnotations) Descriptor() ([]byte, []int) {
1783+ return file_ermite_storage_ci_proto_rawDescGZIP(), []int{9}
1784+}
1785+
1786+func (x *CIStepAnnotations) GetAnnotations() []*CIAnnotation {
1787+ if x != nil {
1788+ return x.Annotations
1789+ }
1790+ return nil
1791+}
1792+
15811793 var File_ermite_storage_ci_proto protoreflect.FileDescriptor
15821794
15831795 const file_ermite_storage_ci_proto_rawDesc = "" +
@@ -1617,7 +1829,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
16171829 "\x04repo\x18\x03 \x01(\tR\x04repo\x12\x1f\n" +
16181830 "\vpusher_kind\x18\x05 \x01(\tR\n" +
16191831 "pusherKind\x12&\n" +
1620- "\x0fpusher_token_id\x18\x06 \x01(\tR\rpusherTokenId\"\xe5\x02\n" +
1832+ "\x0fpusher_token_id\x18\x06 \x01(\tR\rpusherTokenId\"\x9d\x03\n" +
16211833 "\x06CIStep\x12\x16\n" +
16221834 "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" +
16231835 "\x04name\x18\x02 \x01(\tR\x04name\x121\n" +
@@ -1629,7 +1841,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
16291841 "started_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12=\n" +
16301842 "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" +
16311843 "\tlog_start\x18\a \x01(\x03R\blogStart\x12\x1b\n" +
1632- "\thas_lines\x18\t \x01(\bR\bhasLinesJ\x04\b\b\x10\tJ\x04\b\n" +
1844+ "\thas_lines\x18\t \x01(\bR\bhasLines\x126\n" +
1845+ "\aoutcome\x18\v \x01(\x0e2\x1c.ermite.storage.CIConclusionR\aoutcomeJ\x04\b\b\x10\tJ\x04\b\n" +
16331846 "\x10\v\"\xe6\f\n" +
16341847 "\x05CIJob\x12\x0e\n" +
16351848 "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" +
@@ -1734,7 +1947,20 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
17341947 "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" +
17351948 "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" +
17361949 "\fresolved_sha\x18\x03 \x01(\tR\vresolvedSha\x12\x16\n" +
1737- "\x06source\x18\x04 \x01(\x05R\x06source*o\n" +
1950+ "\x06source\x18\x04 \x01(\x05R\x06source\"\x87\x02\n" +
1951+ "\fCIAnnotation\x127\n" +
1952+ "\x05level\x18\x01 \x01(\x0e2!.ermite.storage.CIAnnotationLevelR\x05level\x12\x14\n" +
1953+ "\x05title\x18\x02 \x01(\tR\x05title\x12\x18\n" +
1954+ "\amessage\x18\x03 \x01(\tR\amessage\x12\x12\n" +
1955+ "\x04file\x18\x04 \x01(\tR\x04file\x12\x1d\n" +
1956+ "\n" +
1957+ "start_line\x18\x05 \x01(\x05R\tstartLine\x12\x19\n" +
1958+ "\bend_line\x18\x06 \x01(\x05R\aendLine\x12!\n" +
1959+ "\fstart_column\x18\a \x01(\x05R\vstartColumn\x12\x1d\n" +
1960+ "\n" +
1961+ "end_column\x18\b \x01(\x05R\tendColumn\"S\n" +
1962+ "\x11CIStepAnnotations\x12>\n" +
1963+ "\vannotations\x18\x01 \x03(\v2\x1c.ermite.storage.CIAnnotationR\vannotations*o\n" +
17381964 "\bCIStatus\x12\x19\n" +
17391965 "\x15CI_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" +
17401966 "\x10CI_STATUS_QUEUED\x10\x01\x12\x19\n" +
@@ -1771,7 +1997,12 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
17711997 "\x1cCI_CANCEL_REASON_UNSPECIFIED\x10\x00\x12\x19\n" +
17721998 "\x15CI_CANCEL_REASON_USER\x10\x01\x12\x1e\n" +
17731999 "\x1aCI_CANCEL_REASON_FAIL_FAST\x10\x02\x12\x1c\n" +
1774- "\x18CI_CANCEL_REASON_TIMEOUT\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
2000+ "\x18CI_CANCEL_REASON_TIMEOUT\x10\x03*\x9a\x01\n" +
2001+ "\x11CIAnnotationLevel\x12#\n" +
2002+ "\x1fCI_ANNOTATION_LEVEL_UNSPECIFIED\x10\x00\x12\x1e\n" +
2003+ "\x1aCI_ANNOTATION_LEVEL_NOTICE\x10\x01\x12\x1f\n" +
2004+ "\x1bCI_ANNOTATION_LEVEL_WARNING\x10\x02\x12\x1f\n" +
2005+ "\x1bCI_ANNOTATION_LEVEL_FAILURE\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
17752006
17762007 var (
17772008 file_ermite_storage_ci_proto_rawDescOnce sync.Once
@@ -1785,8 +2016,8 @@ func file_ermite_storage_ci_proto_rawDescGZIP() []byte {
17852016 return file_ermite_storage_ci_proto_rawDescData
17862017 }
17872018
1788-var file_ermite_storage_ci_proto_enumTypes = make([]protoimpl.EnumInfo, 6)
1789-var file_ermite_storage_ci_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
2019+var file_ermite_storage_ci_proto_enumTypes = make([]protoimpl.EnumInfo, 7)
2020+var file_ermite_storage_ci_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
17902021 var file_ermite_storage_ci_proto_goTypes = []any{
17912022 (CIStatus)(0), // 0: ermite.storage.CIStatus
17922023 (CIConclusion)(0), // 1: ermite.storage.CIConclusion
@@ -1794,56 +2025,62 @@ var file_ermite_storage_ci_proto_goTypes = []any{
17942025 (CIJobPhase)(0), // 3: ermite.storage.CIJobPhase
17952026 (CIIsolation)(0), // 4: ermite.storage.CIIsolation
17962027 (CICancelReason)(0), // 5: ermite.storage.CICancelReason
1797- (*CIRun)(nil), // 6: ermite.storage.CIRun
1798- (*CIRunSource)(nil), // 7: ermite.storage.CIRunSource
1799- (*CIStep)(nil), // 8: ermite.storage.CIStep
1800- (*CIJob)(nil), // 9: ermite.storage.CIJob
1801- (*CICheck)(nil), // 10: ermite.storage.CICheck
1802- (*CIRunner)(nil), // 11: ermite.storage.CIRunner
1803- (*CIRepoToken)(nil), // 12: ermite.storage.CIRepoToken
1804- (*CIActionResolution)(nil), // 13: ermite.storage.CIActionResolution
1805- nil, // 14: ermite.storage.CIJob.OutputsEntry
1806- (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp
2028+ (CIAnnotationLevel)(0), // 6: ermite.storage.CIAnnotationLevel
2029+ (*CIRun)(nil), // 7: ermite.storage.CIRun
2030+ (*CIRunSource)(nil), // 8: ermite.storage.CIRunSource
2031+ (*CIStep)(nil), // 9: ermite.storage.CIStep
2032+ (*CIJob)(nil), // 10: ermite.storage.CIJob
2033+ (*CICheck)(nil), // 11: ermite.storage.CICheck
2034+ (*CIRunner)(nil), // 12: ermite.storage.CIRunner
2035+ (*CIRepoToken)(nil), // 13: ermite.storage.CIRepoToken
2036+ (*CIActionResolution)(nil), // 14: ermite.storage.CIActionResolution
2037+ (*CIAnnotation)(nil), // 15: ermite.storage.CIAnnotation
2038+ (*CIStepAnnotations)(nil), // 16: ermite.storage.CIStepAnnotations
2039+ nil, // 17: ermite.storage.CIJob.OutputsEntry
2040+ (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp
18072041 }
18082042 var file_ermite_storage_ci_proto_depIdxs = []int32{
18092043 0, // 0: ermite.storage.CIRun.status:type_name -> ermite.storage.CIStatus
18102044 1, // 1: ermite.storage.CIRun.conclusion:type_name -> ermite.storage.CIConclusion
1811- 15, // 2: ermite.storage.CIRun.created_at:type_name -> google.protobuf.Timestamp
1812- 15, // 3: ermite.storage.CIRun.started_at:type_name -> google.protobuf.Timestamp
1813- 15, // 4: ermite.storage.CIRun.completed_at:type_name -> google.protobuf.Timestamp
1814- 15, // 5: ermite.storage.CIRun.cancel_requested_at:type_name -> google.protobuf.Timestamp
2045+ 18, // 2: ermite.storage.CIRun.created_at:type_name -> google.protobuf.Timestamp
2046+ 18, // 3: ermite.storage.CIRun.started_at:type_name -> google.protobuf.Timestamp
2047+ 18, // 4: ermite.storage.CIRun.completed_at:type_name -> google.protobuf.Timestamp
2048+ 18, // 5: ermite.storage.CIRun.cancel_requested_at:type_name -> google.protobuf.Timestamp
18152049 2, // 6: ermite.storage.CIStep.state:type_name -> ermite.storage.CIStepState
18162050 1, // 7: ermite.storage.CIStep.conclusion:type_name -> ermite.storage.CIConclusion
1817- 15, // 8: ermite.storage.CIStep.started_at:type_name -> google.protobuf.Timestamp
1818- 15, // 9: ermite.storage.CIStep.completed_at:type_name -> google.protobuf.Timestamp
1819- 3, // 10: ermite.storage.CIJob.phase:type_name -> ermite.storage.CIJobPhase
1820- 1, // 11: ermite.storage.CIJob.conclusion:type_name -> ermite.storage.CIConclusion
1821- 4, // 12: ermite.storage.CIJob.isolation:type_name -> ermite.storage.CIIsolation
1822- 8, // 13: ermite.storage.CIJob.steps:type_name -> ermite.storage.CIStep
1823- 15, // 14: ermite.storage.CIJob.created_at:type_name -> google.protobuf.Timestamp
1824- 15, // 15: ermite.storage.CIJob.started_at:type_name -> google.protobuf.Timestamp
1825- 15, // 16: ermite.storage.CIJob.completed_at:type_name -> google.protobuf.Timestamp
1826- 14, // 17: ermite.storage.CIJob.outputs:type_name -> ermite.storage.CIJob.OutputsEntry
1827- 15, // 18: ermite.storage.CIJob.ready_at:type_name -> google.protobuf.Timestamp
1828- 15, // 19: ermite.storage.CIJob.assigned_at:type_name -> google.protobuf.Timestamp
1829- 15, // 20: ermite.storage.CIJob.acked_at:type_name -> google.protobuf.Timestamp
1830- 15, // 21: ermite.storage.CIJob.token_valid_until:type_name -> google.protobuf.Timestamp
1831- 5, // 22: ermite.storage.CIJob.cancel_reason:type_name -> ermite.storage.CICancelReason
1832- 15, // 23: ermite.storage.CIJob.cancel_requested_at:type_name -> google.protobuf.Timestamp
1833- 0, // 24: ermite.storage.CICheck.status:type_name -> ermite.storage.CIStatus
1834- 1, // 25: ermite.storage.CICheck.conclusion:type_name -> ermite.storage.CIConclusion
1835- 15, // 26: ermite.storage.CICheck.created_at:type_name -> google.protobuf.Timestamp
1836- 15, // 27: ermite.storage.CICheck.started_at:type_name -> google.protobuf.Timestamp
1837- 15, // 28: ermite.storage.CICheck.completed_at:type_name -> google.protobuf.Timestamp
1838- 4, // 29: ermite.storage.CIRunner.isolation:type_name -> ermite.storage.CIIsolation
1839- 15, // 30: ermite.storage.CIRunner.registered_at:type_name -> google.protobuf.Timestamp
1840- 15, // 31: ermite.storage.CIRepoToken.expires_at:type_name -> google.protobuf.Timestamp
1841- 15, // 32: ermite.storage.CIRepoToken.created_at:type_name -> google.protobuf.Timestamp
1842- 33, // [33:33] is the sub-list for method output_type
1843- 33, // [33:33] is the sub-list for method input_type
1844- 33, // [33:33] is the sub-list for extension type_name
1845- 33, // [33:33] is the sub-list for extension extendee
1846- 0, // [0:33] is the sub-list for field type_name
2051+ 18, // 8: ermite.storage.CIStep.started_at:type_name -> google.protobuf.Timestamp
2052+ 18, // 9: ermite.storage.CIStep.completed_at:type_name -> google.protobuf.Timestamp
2053+ 1, // 10: ermite.storage.CIStep.outcome:type_name -> ermite.storage.CIConclusion
2054+ 3, // 11: ermite.storage.CIJob.phase:type_name -> ermite.storage.CIJobPhase
2055+ 1, // 12: ermite.storage.CIJob.conclusion:type_name -> ermite.storage.CIConclusion
2056+ 4, // 13: ermite.storage.CIJob.isolation:type_name -> ermite.storage.CIIsolation
2057+ 9, // 14: ermite.storage.CIJob.steps:type_name -> ermite.storage.CIStep
2058+ 18, // 15: ermite.storage.CIJob.created_at:type_name -> google.protobuf.Timestamp
2059+ 18, // 16: ermite.storage.CIJob.started_at:type_name -> google.protobuf.Timestamp
2060+ 18, // 17: ermite.storage.CIJob.completed_at:type_name -> google.protobuf.Timestamp
2061+ 17, // 18: ermite.storage.CIJob.outputs:type_name -> ermite.storage.CIJob.OutputsEntry
2062+ 18, // 19: ermite.storage.CIJob.ready_at:type_name -> google.protobuf.Timestamp
2063+ 18, // 20: ermite.storage.CIJob.assigned_at:type_name -> google.protobuf.Timestamp
2064+ 18, // 21: ermite.storage.CIJob.acked_at:type_name -> google.protobuf.Timestamp
2065+ 18, // 22: ermite.storage.CIJob.token_valid_until:type_name -> google.protobuf.Timestamp
2066+ 5, // 23: ermite.storage.CIJob.cancel_reason:type_name -> ermite.storage.CICancelReason
2067+ 18, // 24: ermite.storage.CIJob.cancel_requested_at:type_name -> google.protobuf.Timestamp
2068+ 0, // 25: ermite.storage.CICheck.status:type_name -> ermite.storage.CIStatus
2069+ 1, // 26: ermite.storage.CICheck.conclusion:type_name -> ermite.storage.CIConclusion
2070+ 18, // 27: ermite.storage.CICheck.created_at:type_name -> google.protobuf.Timestamp
2071+ 18, // 28: ermite.storage.CICheck.started_at:type_name -> google.protobuf.Timestamp
2072+ 18, // 29: ermite.storage.CICheck.completed_at:type_name -> google.protobuf.Timestamp
2073+ 4, // 30: ermite.storage.CIRunner.isolation:type_name -> ermite.storage.CIIsolation
2074+ 18, // 31: ermite.storage.CIRunner.registered_at:type_name -> google.protobuf.Timestamp
2075+ 18, // 32: ermite.storage.CIRepoToken.expires_at:type_name -> google.protobuf.Timestamp
2076+ 18, // 33: ermite.storage.CIRepoToken.created_at:type_name -> google.protobuf.Timestamp
2077+ 6, // 34: ermite.storage.CIAnnotation.level:type_name -> ermite.storage.CIAnnotationLevel
2078+ 15, // 35: ermite.storage.CIStepAnnotations.annotations:type_name -> ermite.storage.CIAnnotation
2079+ 36, // [36:36] is the sub-list for method output_type
2080+ 36, // [36:36] is the sub-list for method input_type
2081+ 36, // [36:36] is the sub-list for extension type_name
2082+ 36, // [36:36] is the sub-list for extension extendee
2083+ 0, // [0:36] is the sub-list for field type_name
18472084 }
18482085
18492086 func init() { file_ermite_storage_ci_proto_init() }
@@ -1856,8 +2093,8 @@ func file_ermite_storage_ci_proto_init() {
18562093 File: protoimpl.DescBuilder{
18572094 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
18582095 RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_ci_proto_rawDesc), len(file_ermite_storage_ci_proto_rawDesc)),
1859- NumEnums: 6,
1860- NumMessages: 9,
2096+ NumEnums: 7,
2097+ NumMessages: 11,
18612098 NumExtensions: 0,
18622099 NumServices: 0,
18632100 },
A internal/adapters/cisummaries/cisummaries.go +175 -0
@@ -0,0 +1,175 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package cisummaries implements ports.CISummaryStore with one Markdown file per
4+// step under $ERMITE_DATA_DIR/ci/summaries/<run id>/<job id>/<attempt>/<step>.md.
5+//
6+// A summary is written to a temporary file of the same directory, synced, then
7+// renamed over the final name: a reader sees the old summary or the new one,
8+// never a part. The files of a run go with the logs of the run: the use case
9+// that removes the logs removes them too.
10+package cisummaries
11+
12+import (
13+ "context"
14+ "errors"
15+ "fmt"
16+ "io/fs"
17+ "os"
18+ "path/filepath"
19+ "slices"
20+ "strconv"
21+ "strings"
22+
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+)
26+
27+// Dir is the directory of the summaries under the data directory.
28+func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "summaries") }
29+
30+const ext = ".md"
31+
32+// Store is the file-backed summary store.
33+type Store struct{ root string }
34+
35+var _ ports.CISummaryStore = (*Store)(nil)
36+
37+// New returns a store rooted at dir, which need not exist yet.
38+func New(dir string) *Store { return &Store{root: dir} }
39+
40+// validRef rejects a reference that could escape the directory.
41+func validRef(ref ports.CILogRef) bool {
42+ if ref.RunID < 1 || ref.Attempt < 1 {
43+ return false
44+ }
45+ _, _, _, ok := domain.ParseJobID(ref.JobID)
46+ return ok
47+}
48+
49+func (s *Store) jobDir(ref ports.CILogRef) string {
50+ return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt)))
51+}
52+
53+func (s *Store) path(ref ports.CILogRef, step int32) string {
54+ return filepath.Join(s.jobDir(ref), strconv.Itoa(int(step))+ext)
55+}
56+
57+// Put stores the summary of a step, or removes it when markdown is empty.
58+func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
59+ if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
60+ return domain.ErrNotFound
61+ }
62+ if markdown == "" {
63+ if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) {
64+ return fmt.Errorf("remove summary: %w", err)
65+ }
66+ return nil
67+ }
68+ dir := s.jobDir(ref)
69+ if err := os.MkdirAll(dir, 0o700); err != nil {
70+ return fmt.Errorf("create summary directory: %w", err)
71+ }
72+ f, err := os.CreateTemp(dir, ".tmp-*")
73+ if err != nil {
74+ return fmt.Errorf("create summary: %w", err)
75+ }
76+ tmp := f.Name()
77+ _, werr := f.WriteString(markdown)
78+ serr := f.Sync()
79+ cerr := f.Close()
80+ if err := errors.Join(werr, serr, cerr); err != nil {
81+ _ = os.Remove(tmp)
82+ return fmt.Errorf("write summary: %w", err)
83+ }
84+ if err := os.Rename(tmp, s.path(ref, step)); err != nil {
85+ _ = os.Remove(tmp)
86+ return fmt.Errorf("store summary: %w", err)
87+ }
88+ return nil
89+}
90+
91+// List returns the summaries of the steps that have one, by step number.
92+func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error) {
93+ if !validRef(ref) {
94+ return nil, false, nil
95+ }
96+ entries, err := os.ReadDir(s.jobDir(ref))
97+ if errors.Is(err, fs.ErrNotExist) {
98+ return nil, false, nil
99+ }
100+ if err != nil {
101+ return nil, false, fmt.Errorf("list summaries: %w", err)
102+ }
103+ var steps []int32
104+ for _, e := range entries {
105+ name, ok := strings.CutSuffix(e.Name(), ext)
106+ if !ok || e.IsDir() {
107+ continue // a temporary file, or something else
108+ }
109+ n, err := strconv.ParseInt(name, 10, 32)
110+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != name {
111+ continue
112+ }
113+ steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size
114+ }
115+ slices.Sort(steps)
116+ more := limit > 0 && len(steps) > limit
117+ if more {
118+ steps = steps[:limit]
119+ }
120+ out := make([]domain.CIStepSummary, 0, len(steps))
121+ for _, n := range steps {
122+ b, err := os.ReadFile(s.path(ref, n))
123+ if errors.Is(err, fs.ErrNotExist) {
124+ continue // removed meanwhile
125+ }
126+ if err != nil {
127+ return nil, false, fmt.Errorf("read summary: %w", err)
128+ }
129+ out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
130+ }
131+ return out, more, nil
132+}
133+
134+// RemoveJob deletes the summaries of one execution of a job.
135+func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
136+ if !validRef(ref) {
137+ return nil
138+ }
139+ if err := os.RemoveAll(s.jobDir(ref)); err != nil {
140+ return fmt.Errorf("remove summaries: %w", err)
141+ }
142+ return nil
143+}
144+
145+// RemoveRuns deletes the summaries of the runs.
146+func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
147+ var errs []error
148+ for _, id := range runIDs {
149+ if id < 1 {
150+ continue
151+ }
152+ if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
153+ errs = append(errs, err)
154+ }
155+ }
156+ return errors.Join(errs...)
157+}
158+
159+// RunIDs lists the runs that have a summary directory.
160+func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
161+ entries, err := os.ReadDir(s.root)
162+ if errors.Is(err, fs.ErrNotExist) {
163+ return nil, nil
164+ }
165+ if err != nil {
166+ return nil, fmt.Errorf("list summaries: %w", err)
167+ }
168+ var ids []int64
169+ for _, e := range entries {
170+ if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
171+ ids = append(ids, id)
172+ }
173+ }
174+ return ids, nil
175+}
A internal/adapters/cisummaries/cisummaries_test.go +157 -0
@@ -0,0 +1,157 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package cisummaries_test
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/adapters/cisummaries"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+var ref = ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 1}
20+
21+func TestPutListReplaceAndRemove(t *testing.T) {
22+ ctx := t.Context()
23+ root := t.TempDir()
24+ s := cisummaries.New(root)
25+
26+ got, more, err := s.List(ctx, ref, 20)
27+ require.NoError(t, err)
28+ assert.Empty(t, got)
29+ assert.False(t, more)
30+
31+ require.NoError(t, s.Put(ctx, ref, 10, "# ten"))
32+ require.NoError(t, s.Put(ctx, ref, 2, "two"))
33+ // The layout is the documented one.
34+ b, err := os.ReadFile(filepath.Join(root, "7", "7-0-0", "1", "2.md"))
35+ require.NoError(t, err)
36+ assert.Equal(t, "two", string(b))
37+
38+ got, _, err = s.List(ctx, ref, 20)
39+ require.NoError(t, err)
40+ assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name")
41+
42+ // Replace, and an empty markdown removes.
43+ require.NoError(t, s.Put(ctx, ref, 2, "again"))
44+ got, _, _ = s.List(ctx, ref, 20)
45+ assert.Equal(t, "again", got[0].Markdown)
46+ require.NoError(t, s.Put(ctx, ref, 2, ""))
47+ require.NoError(t, s.Put(ctx, ref, 3, ""), "removing what is not there is fine")
48+ got, _, _ = s.List(ctx, ref, 20)
49+ require.Len(t, got, 1)
50+ assert.EqualValues(t, 10, got[0].Step)
51+
52+ // Another attempt and another job have their own.
53+ other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2}
54+ got, _, _ = s.List(ctx, other, 20)
55+ assert.Empty(t, got)
56+
57+ // A write leaves no temporary file behind.
58+ entries, err := os.ReadDir(filepath.Join(root, "7", "7-0-0", "1"))
59+ require.NoError(t, err)
60+ for _, e := range entries {
61+ assert.False(t, strings.HasPrefix(e.Name(), ".tmp"), e.Name())
62+ }
63+}
64+
65+func TestListStopsAtTheLimit(t *testing.T) {
66+ ctx := t.Context()
67+ s := cisummaries.New(t.TempDir())
68+ for i := int32(1); i <= 25; i++ {
69+ require.NoError(t, s.Put(ctx, ref, i, "s"))
70+ }
71+ got, more, err := s.List(ctx, ref, 20)
72+ require.NoError(t, err)
73+ assert.Len(t, got, 20)
74+ assert.True(t, more)
75+ assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one")
76+ got, more, _ = s.List(ctx, ref, 25)
77+ assert.Len(t, got, 25)
78+ assert.False(t, more)
79+}
80+
81+// A temporary file left by a crash, or a stray file, is not a summary.
82+func TestListIgnoresStrayFiles(t *testing.T) {
83+ ctx := t.Context()
84+ root := t.TempDir()
85+ s := cisummaries.New(root)
86+ require.NoError(t, s.Put(ctx, ref, 1, "one"))
87+ dir := filepath.Join(root, "7", "7-0-0", "1")
88+ for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} {
89+ require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600))
90+ }
91+ got, _, err := s.List(ctx, ref, 20)
92+ require.NoError(t, err)
93+ require.Len(t, got, 1)
94+ assert.Equal(t, "one", got[0].Markdown)
95+}
96+
97+func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) {
98+ ctx := t.Context()
99+ s := cisummaries.New(t.TempDir())
100+ ids, err := s.RunIDs(ctx)
101+ require.NoError(t, err)
102+ assert.Empty(t, ids, "no directory yet")
103+
104+ second := ports.CILogRef{RunID: 7, JobID: "7-1-0", Attempt: 1}
105+ third := ports.CILogRef{RunID: 8, JobID: "8-0-0", Attempt: 1}
106+ for _, r := range []ports.CILogRef{ref, second, third} {
107+ require.NoError(t, s.Put(ctx, r, 1, "x"))
108+ }
109+ ids, _ = s.RunIDs(ctx)
110+ assert.ElementsMatch(t, []int64{7, 8}, ids)
111+
112+ require.NoError(t, s.RemoveJob(ctx, ref))
113+ got, _, _ := s.List(ctx, ref, 20)
114+ assert.Empty(t, got)
115+ got, _, _ = s.List(ctx, second, 20)
116+ assert.Len(t, got, 1, "the other job of the run stays")
117+
118+ require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99}))
119+ got, _, _ = s.List(ctx, second, 20)
120+ assert.Empty(t, got)
121+ got, _, _ = s.List(ctx, third, 20)
122+ assert.Len(t, got, 1)
123+ ids, _ = s.RunIDs(ctx)
124+ assert.Equal(t, []int64{8}, ids)
125+}
126+
127+// A reference that could leave the directory is refused, never joined.
128+func TestRefsCannotEscape(t *testing.T) {
129+ ctx := t.Context()
130+ root := t.TempDir()
131+ s := cisummaries.New(filepath.Join(root, "summaries"))
132+ for _, bad := range []ports.CILogRef{
133+ {RunID: 7, JobID: "../../x", Attempt: 1},
134+ {RunID: 7, JobID: "7-0-0/../..", Attempt: 1},
135+ {RunID: 0, JobID: "7-0-0", Attempt: 1},
136+ {RunID: 7, JobID: "7-0-0", Attempt: 0},
137+ } {
138+ assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound)
139+ got, _, err := s.List(ctx, bad, 20)
140+ require.NoError(t, err)
141+ assert.Empty(t, got)
142+ }
143+ assert.ErrorIs(t, s.Put(ctx, ref, 0, "x"), domain.ErrNotFound)
144+ assert.ErrorIs(t, s.Put(ctx, ref, domain.CIMaxSteps+1, "x"), domain.ErrNotFound)
145+ entries, _ := os.ReadDir(root)
146+ assert.Empty(t, entries, "nothing was written")
147+}
148+
149+func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) {
150+ ctx := t.Context()
151+ s := cisummaries.New(t.TempDir())
152+ big := strings.Repeat("x", domain.CIMaxSummaryBytes)
153+ require.NoError(t, s.Put(ctx, ref, 1, big))
154+ got, _, err := s.List(ctx, ref, 20)
155+ require.NoError(t, err)
156+ assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes)
157+}
M internal/adapters/kvstore/ci.go +6 -3
@@ -38,7 +38,8 @@ import (
3838 // ciactive/<job key> job assigned or running
3939 // jobtoken/<hash> job token (value: job key)
4040 //
41-// The job repository tokens and the action resolutions are in ci_token.go.
41+// The job repository tokens and the action resolutions are in ci_token.go, the
42+// annotations in ci_annotation.go.
4243 const (
4344 runPrefix = "run/"
4445 runSrcPrefix = "runsrc/"
@@ -315,7 +316,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
315316 steps = append(steps, &storagepb.CIStep{
316317 Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion),
317318 StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt),
318- LogStart: s.LogStart, HasLines: s.HasLines,
319+ LogStart: s.LogStart, HasLines: s.HasLines, Outcome: encodeConclusion(s.Outcome),
319320 })
320321 }
321322 pb := &storagepb.CIJob{
@@ -367,7 +368,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
367368 j.Steps = append(j.Steps, domain.CIStep{
368369 Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()),
369370 StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()),
370- LogStart: s.GetLogStart(), HasLines: s.GetHasLines(),
371+ LogStart: s.GetLogStart(), HasLines: s.GetHasLines(), Outcome: decodeConclusion(s.GetOutcome()),
371372 })
372373 }
373374 return j, nil
@@ -510,6 +511,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
510511 }
511512 }
512513 c.stageRepoTokens(ctx, b, w, guarded)
514+ c.stageAnnotations(ctx, b, w, guarded)
513515 // Checks are derived from the jobs, written in the same batch and under
514516 // the lock of their run: they are upserted without a precondition.
515517 for _, ck := range w.Checks {
@@ -836,6 +838,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
836838 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
837839 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
838840 keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
841+ keys = append(keys, annotationKeys(ctx, snap, runID)...)
839842 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
840843 keys = append(keys, slices.Clone(key))
841844 if j, err := decodeJob(item); err == nil {
A internal/adapters/kvstore/ci_annotation.go +150 -0
@@ -0,0 +1,150 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "strconv"
10+ "strings"
11+
12+ kv "bnema.dev/ermite-kv"
13+ "google.golang.org/protobuf/proto"
14+
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// Key layout of the annotations, written by CI.Apply with the job they belong
21+// to, under the lock of its run:
22+//
23+// annot/<run:020d>/<attempt:010d>/<job id>/<step:010d> CIStepAnnotations
24+//
25+// The attempt is the one in which the job executed (CIJob.Attempt, the one of
26+// its log), not the attempt of the run that keeps the record. One value holds
27+// all the annotations of one step, so that a report replaces them whole; a
28+// step without annotation has no key.
29+const annotPrefix = "annot/"
30+
31+func annotRunPrefix(runID int64) string { return fmt.Sprintf("%s%020d/", annotPrefix, runID) }
32+func annotJobPrefix(runID int64, attempt int32, jobID string) string {
33+ return fmt.Sprintf("%s%010d/%s/", annotRunPrefix(runID), attempt, jobID)
34+}
35+func annotKey(runID int64, attempt int32, jobID string, step int32) []byte {
36+ return []byte(fmt.Sprintf("%s%010d", annotJobPrefix(runID, attempt, jobID), step))
37+}
38+
39+var annotationLevels = []struct {
40+ d domain.AnnotationLevel
41+ p storagepb.CIAnnotationLevel
42+}{
43+ {domain.AnnotationNotice, storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_NOTICE},
44+ {domain.AnnotationWarning, storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_WARNING},
45+ {domain.AnnotationFailure, storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_FAILURE},
46+}
47+
48+func encodeAnnotationLevel(l domain.AnnotationLevel) storagepb.CIAnnotationLevel {
49+ for _, e := range annotationLevels {
50+ if e.d == l {
51+ return e.p
52+ }
53+ }
54+ return storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_UNSPECIFIED
55+}
56+
57+func decodeAnnotationLevel(l storagepb.CIAnnotationLevel) domain.AnnotationLevel {
58+ for _, e := range annotationLevels {
59+ if e.p == l {
60+ return e.d
61+ }
62+ }
63+ return domain.AnnotationNotice
64+}
65+
66+func encodeAnnotations(items []domain.CIAnnotation) []byte {
67+ pb := &storagepb.CIStepAnnotations{Annotations: make([]*storagepb.CIAnnotation, 0, len(items))}
68+ for _, a := range items {
69+ pb.Annotations = append(pb.Annotations, &storagepb.CIAnnotation{
70+ Level: encodeAnnotationLevel(a.Level), Title: a.Title, Message: a.Message, File: a.File,
71+ StartLine: a.StartLine, EndLine: a.EndLine, StartColumn: a.StartColumn, EndColumn: a.EndColumn,
72+ })
73+ }
74+ return mustMarshal(pb)
75+}
76+
77+func decodeAnnotations(val []byte, step int32) ([]domain.CIAnnotation, error) {
78+ var pb storagepb.CIStepAnnotations
79+ if err := proto.Unmarshal(val, &pb); err != nil {
80+ return nil, fmt.Errorf("decode annotations: %w", err)
81+ }
82+ out := make([]domain.CIAnnotation, 0, len(pb.GetAnnotations()))
83+ for _, a := range pb.GetAnnotations() {
84+ out = append(out, domain.CIAnnotation{
85+ Level: decodeAnnotationLevel(a.GetLevel()), Title: a.GetTitle(), Message: a.GetMessage(), File: a.GetFile(),
86+ StartLine: a.GetStartLine(), EndLine: a.GetEndLine(), StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
87+ Step: step,
88+ })
89+ }
90+ return out, nil
91+}
92+
93+// stageAnnotations stages the deletion of the annotations of the executions to
94+// clear, then the replacement of the annotations of the steps to set.
95+//
96+// Annotations normally travel with the write of their job, whose version
97+// precondition fails once the job is gone; guarding the repository key as well
98+// would make the jobs of one repository conflict with one another at every
99+// report. Only a batch without a job guards it. A report that races the
100+// deletion of its repository can leave its annotation keys behind, under a run
101+// id that is never reused: harmless, and a few kilobytes at most.
102+func (c *CI) stageAnnotations(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) {
103+ if len(w.ClearAnnotations) > 0 {
104+ snap := c.db.Snapshot(ctx)
105+ for _, e := range w.ClearAnnotations {
106+ for key := range snap.Prefix(ctx, []byte(annotJobPrefix(e.RunID, e.Attempt, e.JobID))) {
107+ b.Delete(slices.Clone(key))
108+ }
109+ }
110+ }
111+ for _, s := range w.Annotations {
112+ key := annotKey(s.Exec.RunID, s.Exec.Attempt, s.Exec.JobID, s.Step)
113+ if len(s.Items) == 0 {
114+ b.Delete(key)
115+ continue
116+ }
117+ b.Put(key, encodeAnnotations(s.Items))
118+ if len(w.Jobs) == 0 {
119+ guarded[s.Repo] = true
120+ }
121+ }
122+}
123+
124+// JobAnnotations returns the annotations of one execution of a job, ordered by
125+// step then by the order the step produced them.
126+func (c *CI) JobAnnotations(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error) {
127+ prefix := annotJobPrefix(runID, attempt, jobID)
128+ var out []domain.CIAnnotation
129+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
130+ n, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 32)
131+ if err != nil {
132+ return nil, fmt.Errorf("decode %s: %w", key, err)
133+ }
134+ items, err := decodeAnnotations(item.Value, int32(n)) //nolint:gosec // parsed with a 32-bit size
135+ if err != nil {
136+ return nil, fmt.Errorf("decode %s: %w", key, err)
137+ }
138+ out = append(out, items...)
139+ }
140+ return out, nil
141+}
142+
143+// annotationKeys lists the keys of the annotations of a run, for its deletion.
144+func annotationKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
145+ var keys [][]byte
146+ for key := range snap.Prefix(ctx, []byte(annotRunPrefix(runID))) {
147+ keys = append(keys, slices.Clone(key))
148+ }
149+ return keys
150+}
A internal/adapters/kvstore/ci_annotation_test.go +171 -0
@@ -0,0 +1,171 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+func note(level domain.AnnotationLevel, step int32, msg string) domain.CIAnnotation {
18+ return domain.CIAnnotation{Level: level, Message: msg, Step: step}
19+}
20+
21+func replace(n domain.RepoName, run int64, attempt int32, job string, step int32, items ...domain.CIAnnotation) ports.CIStepAnnotations {
22+ return ports.CIStepAnnotations{Repo: n, Exec: ports.CILogRef{RunID: run, JobID: job, Attempt: attempt}, Step: step, Items: items}
23+}
24+
25+// Annotations are kept per execution of a job and per step: a report replaces
26+// the annotations of the steps it names, keeps the others, and an empty report
27+// removes the step's.
28+func TestCIAnnotationsAreReplacedPerStep(t *testing.T) {
29+ ctx := context.Background()
30+ _, ci, n := ciRepo(t)
31+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
32+ require.NoError(t, err)
33+ a, b := d.Jobs[0].ID, d.Jobs[1].ID
34+
35+ full := domain.CIAnnotation{
36+ Level: domain.AnnotationFailure, Title: "t", Message: "boom", File: "a/b.go",
37+ StartLine: 3, EndLine: 5, StartColumn: 2, EndColumn: 9, Step: 2,
38+ }
39+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
40+ replace(n, d.Run.ID, 1, a, 2, full, note(domain.AnnotationNotice, 2, "second")),
41+ replace(n, d.Run.ID, 1, a, 10, note(domain.AnnotationWarning, 10, "late step")),
42+ replace(n, d.Run.ID, 1, b, 2, note(domain.AnnotationNotice, 2, "other job")),
43+ }})
44+ require.NoError(t, err)
45+
46+ got, err := ci.JobAnnotations(ctx, d.Run.ID, 1, a)
47+ require.NoError(t, err)
48+ require.Len(t, got, 3)
49+ assert.Equal(t, full, got[0], "every field round-trips, in the order the step produced them")
50+ assert.Equal(t, "second", got[1].Message)
51+ assert.EqualValues(t, 10, got[2].Step, "steps sort numerically, not as text")
52+
53+ // Replace step 2 only: step 10 and the other job keep theirs.
54+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
55+ replace(n, d.Run.ID, 1, a, 2, note(domain.AnnotationWarning, 2, "only one now")),
56+ }})
57+ require.NoError(t, err)
58+ got, _ = ci.JobAnnotations(ctx, d.Run.ID, 1, a)
59+ require.Len(t, got, 2)
60+ assert.Equal(t, "only one now", got[0].Message)
61+ assert.Equal(t, "late step", got[1].Message)
62+ other, _ := ci.JobAnnotations(ctx, d.Run.ID, 1, b)
63+ require.Len(t, other, 1)
64+
65+ // An empty list removes the step's annotations.
66+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{replace(n, d.Run.ID, 1, a, 2)}})
67+ require.NoError(t, err)
68+ got, _ = ci.JobAnnotations(ctx, d.Run.ID, 1, a)
69+ require.Len(t, got, 1)
70+ assert.EqualValues(t, 10, got[0].Step)
71+
72+ // Another attempt of the job has its own.
73+ none, err := ci.JobAnnotations(ctx, d.Run.ID, 2, a)
74+ require.NoError(t, err)
75+ assert.Empty(t, none)
76+}
77+
78+// The annotations are written in the batch of the job: a stale job version
79+// writes none of them, and a missing repository neither.
80+func TestCIAnnotationsAreAtomicWithTheJob(t *testing.T) {
81+ ctx := context.Background()
82+ _, ci, n := ciRepo(t)
83+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
84+ require.NoError(t, err)
85+ job, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
86+ require.NoError(t, err)
87+
88+ stale := job
89+ stale.Version--
90+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{stale}, Annotations: []ports.CIStepAnnotations{
91+ replace(n, d.Run.ID, 1, job.ID, 1, note(domain.AnnotationNotice, 1, "lost")),
92+ }})
93+ assert.ErrorIs(t, err, domain.ErrConflict)
94+ got, _ := ci.JobAnnotations(ctx, d.Run.ID, 1, job.ID)
95+ assert.Empty(t, got)
96+
97+ ghost := name(t, "ghost")
98+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
99+ replace(ghost, d.Run.ID, 1, job.ID, 1, note(domain.AnnotationNotice, 1, "no repository")),
100+ }})
101+ assert.ErrorIs(t, err, domain.ErrNotFound, "no key is created under a repository that is gone")
102+}
103+
104+// Annotations go with their run: the retention sweep, the deletion of the
105+// repository, and the clearing of an assignment that is taken back.
106+func TestCIAnnotationsAreDeleted(t *testing.T) {
107+ ctx := context.Background()
108+ s, ci, n := ciRepo(t)
109+ mk := func() ports.CIRunDraft {
110+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
111+ require.NoError(t, err)
112+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
113+ replace(n, d.Run.ID, 1, d.Jobs[0].ID, 1, note(domain.AnnotationNotice, 1, "x")),
114+ replace(n, d.Run.ID, 1, d.Jobs[0].ID, 2, note(domain.AnnotationNotice, 2, "y")),
115+ }})
116+ require.NoError(t, err)
117+ return d
118+ }
119+ count := func(d ports.CIRunDraft) int {
120+ got, err := ci.JobAnnotations(ctx, d.Run.ID, 1, d.Jobs[0].ID)
121+ require.NoError(t, err)
122+ return len(got)
123+ }
124+
125+ // clear
126+ d := mk()
127+ _, err := ci.Apply(ctx, ports.CIWrite{ClearAnnotations: []ports.CILogRef{{RunID: d.Run.ID, JobID: d.Jobs[0].ID, Attempt: 1}}})
128+ require.NoError(t, err)
129+ assert.Zero(t, count(d))
130+
131+ // sweep: an old completed run goes with its annotations, a recent one keeps them.
132+ oldRun, recent := mk(), mk()
133+ for _, tc := range []struct {
134+ d ports.CIRunDraft
135+ at time.Time
136+ }{{oldRun, created}, {recent, created.Add(100 * 24 * time.Hour)}} {
137+ run, _ := ci.GetRun(ctx, n, tc.d.Run.ID)
138+ run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, tc.at
139+ job, _ := ci.GetJob(ctx, run.ID, 1, tc.d.Jobs[0].ID)
140+ job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess
141+ _, err := ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}})
142+ require.NoError(t, err)
143+ }
144+ ids, err := ci.Sweep(ctx, created.Add(90*24*time.Hour))
145+ require.NoError(t, err)
146+ assert.Contains(t, ids, oldRun.Run.ID)
147+ assert.Zero(t, count(oldRun))
148+ assert.Equal(t, 2, count(recent))
149+
150+ // repository deletion
151+ require.NoError(t, s.Delete(ctx, n))
152+ assert.Zero(t, count(recent))
153+}
154+
155+func TestCIStepOutcomeRoundTrips(t *testing.T) {
156+ ctx := context.Background()
157+ _, ci, n := ciRepo(t)
158+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
159+ require.NoError(t, err)
160+ job, _ := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
161+ job.Steps = []domain.CIStep{
162+ {Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure, StartedAt: created, CompletedAt: created},
163+ {Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: created, CompletedAt: created},
164+ }
165+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{job}})
166+ require.NoError(t, err)
167+ got, err := ci.GetJob(ctx, d.Run.ID, 1, job.ID)
168+ require.NoError(t, err)
169+ assert.Equal(t, domain.CIFailure, got.Steps[0].Outcome, "continue-on-error: failed, then succeeded")
170+ assert.Equal(t, domain.CINoConclusion, got.Steps[1].Outcome, "stored without one: read as the conclusion")
171+}
M internal/adapters/rpc/api.go +8 -2
@@ -25,10 +25,15 @@ import (
2525 // bounded: a diff has its own limits and blobs are streamed.
2626 const MaxRequestBytes = 1 << 20
2727
28+// RunnerMaxRequestBytes bounds the size of a request message of RunnerService:
29+// a job summary of exactly 1 MiB (UploadSummary) must be accepted, so the limit
30+// leaves room for the envelope (job id, step number, framing).
31+const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
32+
2833 // Features are the optional capabilities of the contract that this server
2934 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3035 // when Config.Passkeys is set.
31-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}
36+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}
3237
3338 // FeaturePasskeys is listed by GetVersion when passkeys are available.
3439 const FeaturePasskeys = "passkeys"
@@ -100,7 +105,8 @@ func New(cfg Config) *API {
100105 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
101106 }
102107 if cfg.Runner != nil {
103- mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
108+ runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
109+ mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, runnerOpts...))
104110 }
105111 if cfg.Runs != nil {
106112 mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
M internal/adapters/rpc/ci.go +46 -13
@@ -105,7 +105,11 @@ func (s *runnerService) UpdateJob(ctx context.Context, req *connect.Request[pb.U
105105 if err != nil {
106106 return nil, s.failed(ctx, err)
107107 }
108- if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps); err != nil {
108+ anns, err := annotationsFromPB(req.Msg.GetAnnotations())
109+ if err != nil {
110+ return nil, s.failed(ctx, err)
111+ }
112+ if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps, anns); err != nil {
109113 return nil, s.failed(ctx, err)
110114 }
111115 return connect.NewResponse(&pb.UpdateJobResponse{}), nil
@@ -162,9 +166,13 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb
162166 if err != nil {
163167 return nil, s.failed(ctx, err)
164168 }
169+ anns, err := annotationsFromPB(req.Msg.GetAnnotations())
170+ if err != nil {
171+ return nil, s.failed(ctx, err)
172+ }
165173 res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{
166174 JobID: req.Msg.GetJobId(), Conclusion: concl, Outputs: req.Msg.GetOutputs(),
167- RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps,
175+ RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps, Annotations: anns,
168176 })
169177 if err != nil {
170178 return nil, s.failed(ctx, err)
@@ -190,10 +198,12 @@ func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request
190198 return connect.NewResponse(out), nil
191199 }
192200
193-// UploadSummary is the E2 work (summaries storage): until the server lists
194-// "ci.summaries" it answers unimplemented, as runner.proto says.
195-func (s *runnerService) UploadSummary(context.Context, *connect.Request[pb.UploadSummaryRequest]) (*connect.Response[pb.UploadSummaryResponse], error) {
196- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.UploadSummary is not implemented"))
201+// UploadSummary stores the summary of a step.
202+func (s *runnerService) UploadSummary(ctx context.Context, req *connect.Request[pb.UploadSummaryRequest]) (*connect.Response[pb.UploadSummaryResponse], error) {
203+ if err := s.runners.UploadSummary(ctx, secretFrom(ctx), req.Msg.GetJobId(), req.Msg.GetStepNumber(), req.Msg.GetMarkdown()); err != nil {
204+ return nil, s.failed(ctx, err)
205+ }
206+ return connect.NewResponse(&pb.UploadSummaryResponse{}), nil
197207 }
198208
199209 // runService implements ermiteconnect.RunServiceHandler.
@@ -334,13 +344,36 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R
334344 return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
335345 }
336346
337-// ListAnnotations and GetStepSummaries are the E2 work (annotations and
338-// summaries): unimplemented until the server lists "ci.annotations" and
339-// "ci.summaries".
340-func (s *runService) ListAnnotations(context.Context, *connect.Request[pb.ListAnnotationsRequest]) (*connect.Response[pb.ListAnnotationsResponse], error) {
341- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.ListAnnotations is not implemented"))
347+// ListAnnotations lists the annotations of a run attempt.
348+func (s *runService) ListAnnotations(ctx context.Context, req *connect.Request[pb.ListAnnotationsRequest]) (*connect.Response[pb.ListAnnotationsResponse], error) {
349+ name, err := repoOf(req.Msg.GetRepo())
350+ if err != nil {
351+ return nil, ciError(ctx, err)
352+ }
353+ res, err := s.runs.ListAnnotations(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetAttempt(), req.Msg.GetJobId())
354+ if err != nil {
355+ return nil, ciError(ctx, err)
356+ }
357+ out := &pb.ListAnnotationsResponse{Truncated: res.Truncated, Annotations: make([]*pb.JobAnnotation, 0, len(res.Annotations))}
358+ for _, a := range res.Annotations {
359+ out.Annotations = append(out.Annotations, &pb.JobAnnotation{JobId: a.JobID, Annotation: annotationToPB(a.Annotation)})
360+ }
361+ return connect.NewResponse(out), nil
342362 }
343363
344-func (s *runService) GetStepSummaries(context.Context, *connect.Request[pb.GetStepSummariesRequest]) (*connect.Response[pb.GetStepSummariesResponse], error) {
345- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.GetStepSummaries is not implemented"))
364+// GetStepSummaries returns the summaries of a job.
365+func (s *runService) GetStepSummaries(ctx context.Context, req *connect.Request[pb.GetStepSummariesRequest]) (*connect.Response[pb.GetStepSummariesResponse], error) {
366+ name, err := repoOf(req.Msg.GetRepo())
367+ if err != nil {
368+ return nil, ciError(ctx, err)
369+ }
370+ res, err := s.runs.GetStepSummaries(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt())
371+ if err != nil {
372+ return nil, ciError(ctx, err)
373+ }
374+ out := &pb.GetStepSummariesResponse{Truncated: res.Truncated, Summaries: make([]*pb.StepSummary, 0, len(res.Summaries))}
375+ for _, m := range res.Summaries {
376+ out.Summaries = append(out.Summaries, &pb.StepSummary{StepNumber: m.Step, Markdown: m.Markdown})
377+ }
378+ return connect.NewResponse(out), nil
346379 }
M internal/adapters/rpc/ci_convert.go +67 -2
@@ -149,13 +149,26 @@ func runToPB(r domain.CIRun) *pb.Run {
149149 }
150150 }
151151
152+// stepOutcome is Step.outcome: unset until the step is completed, and for a
153+// step stored without one (read as its conclusion) it is the conclusion, which
154+// is what the contract says UNSPECIFIED means.
155+func stepOutcome(s domain.CIStep) domain.CIConclusion {
156+ if s.State != domain.CIStepCompleted {
157+ return domain.CINoConclusion
158+ }
159+ if s.Outcome == domain.CINoConclusion {
160+ return s.Conclusion
161+ }
162+ return s.Outcome
163+}
164+
152165 func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step {
153166 ranges := domain.StepRanges(steps, lines)
154167 out := make([]*pb.Step, 0, len(steps))
155168 for i, s := range steps {
156169 out = append(out, &pb.Step{
157170 Number: s.Number, Name: s.Name, State: stepStateToPB(s.State), Conclusion: conclusionToPB(s.Conclusion),
158- StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
171+ Outcome: conclusionToPB(stepOutcome(s)), StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
159172 LogStart: ranges[i].Start, LogEnd: ranges[i].End,
160173 })
161174 }
@@ -231,8 +244,12 @@ func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
231244 if err != nil {
232245 return nil, err
233246 }
247+ outcome, err := conclusionFromPB(s.GetOutcome())
248+ if err != nil {
249+ return nil, err
250+ }
234251 out = append(out, ports.StepUpdate{
235- Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl,
252+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl, Outcome: outcome,
236253 StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()),
237254 })
238255 }
@@ -250,3 +267,51 @@ func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated {
250267 func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated {
251268 return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}}
252269 }
270+
271+var annotationLevelPairs = []struct {
272+ d domain.AnnotationLevel
273+ p pb.AnnotationLevel
274+}{
275+ {domain.AnnotationNotice, pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE},
276+ {domain.AnnotationWarning, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING},
277+ {domain.AnnotationFailure, pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE},
278+}
279+
280+func annotationLevelToPB(l domain.AnnotationLevel) pb.AnnotationLevel {
281+ for _, e := range annotationLevelPairs {
282+ if e.d == l {
283+ return e.p
284+ }
285+ }
286+ return pb.AnnotationLevel_ANNOTATION_LEVEL_UNSPECIFIED
287+}
288+
289+func annotationToPB(a domain.CIAnnotation) *pb.Annotation {
290+ return &pb.Annotation{
291+ Level: annotationLevelToPB(a.Level), Title: a.Title, Message: a.Message, File: a.File,
292+ StartLine: a.StartLine, EndLine: a.EndLine, StartColumn: a.StartColumn, EndColumn: a.EndColumn, StepNumber: a.Step,
293+ }
294+}
295+
296+// annotationsFromPB maps the annotations of a request; an unset or unknown
297+// level is invalid_argument. The other rules are the use case's.
298+func annotationsFromPB(anns []*pb.Annotation) ([]domain.CIAnnotation, error) {
299+ out := make([]domain.CIAnnotation, 0, len(anns))
300+ for i, a := range anns {
301+ var level domain.AnnotationLevel
302+ for _, e := range annotationLevelPairs {
303+ if e.p == a.GetLevel() {
304+ level = e.d
305+ }
306+ }
307+ if level == "" {
308+ return nil, fmt.Errorf("%w: annotation %d has no level", domain.ErrInvalid, i)
309+ }
310+ out = append(out, domain.CIAnnotation{
311+ Level: level, Title: a.GetTitle(), Message: a.GetMessage(), File: a.GetFile(),
312+ StartLine: a.GetStartLine(), EndLine: a.GetEndLine(), StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
313+ Step: a.GetStepNumber(),
314+ })
315+ }
316+ return out, nil
317+}
M internal/adapters/rpc/ci_test.go +117 -0
@@ -13,6 +13,7 @@ import (
1313 "github.com/stretchr/testify/require"
1414
1515 pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1617 "bnema.dev/ermite/internal/domain"
1718 "bnema.dev/ermite/internal/ports"
1819 "bnema.dev/ermite/internal/ports/mocks"
@@ -99,3 +100,119 @@ func TestRunnerServiceErrors(t *testing.T) {
99100 requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated))
100101 requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail
101102 }
103+
104+// The annotations of a request are mapped to the domain; a level that is not
105+// one of the three is invalid_argument before the use case sees the call.
106+func TestRunnerServiceAnnotationsAndSummary(t *testing.T) {
107+ t.Parallel()
108+ anns := []*pb.Annotation{
109+ {Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, StepNumber: 5},
110+ {Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "n", StepNumber: 1},
111+ }
112+ want := []domain.CIAnnotation{
113+ {Level: domain.AnnotationFailure, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, Step: 5},
114+ {Level: domain.AnnotationNotice, Message: "n", Step: 1},
115+ }
116+ ctx := context.WithValue(ownerCtx(), secretKey{}, "ermitejob_x")
117+
118+ runners := mocks.NewRunnerService(t)
119+ runners.EXPECT().UpdateJob(mock.Anything, "ermitejob_x", "7-0-0", mock.Anything, want).Return(nil).Once()
120+ runners.EXPECT().CompleteJob(mock.Anything, "ermitejob_x", mock.MatchedBy(func(p ports.CompleteJobParams) bool {
121+ return p.JobID == "7-0-0" && len(p.Annotations) == 2 && p.Annotations[0] == want[0]
122+ })).Return(ports.CompleteJobResult{Conclusion: domain.CISuccess, Applied: true}, nil).Once()
123+ runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "# md").Return(nil).Once()
124+ runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "").Return(&domain.JobEndedError{Conclusion: domain.CIFailure}).Once()
125+ s := &runnerService{runners: runners}
126+
127+ _, err := s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: anns}))
128+ require.NoError(t, err)
129+ _, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: anns}))
130+ require.NoError(t, err)
131+ _, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3, Markdown: "# md"}))
132+ require.NoError(t, err)
133+ _, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3}))
134+ requireCode(t, connect.CodeFailedPrecondition, err)
135+
136+ _, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Message: "no level", StepNumber: 1}}}))
137+ requireCode(t, connect.CodeInvalidArgument, err)
138+ _, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Level: 99, Message: "x", StepNumber: 1}}}))
139+ requireCode(t, connect.CodeInvalidArgument, err)
140+ _, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{{Message: "x"}}}))
141+ requireCode(t, connect.CodeInvalidArgument, err)
142+}
143+
144+// StepUpdate.outcome reaches the use case, and Step.outcome is the conclusion
145+// for a completed step stored without one, and unset before the end.
146+func TestStepOutcomeMapping(t *testing.T) {
147+ t.Parallel()
148+ ups, err := stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE}})
149+ require.NoError(t, err)
150+ assert.Equal(t, domain.CIFailure, ups[0].Outcome)
151+ _, err = stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, Outcome: 99}})
152+ assert.Error(t, err)
153+
154+ steps := stepsToPB([]domain.CIStep{
155+ {Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure},
156+ {Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CIFailure},
157+ {Number: 3, State: domain.CIStepInProgress},
158+ {Number: 4, State: domain.CIStepCompleted, Conclusion: domain.CISkipped, Outcome: domain.CISkipped},
159+ }, 0)
160+ got := make([]pb.CiConclusion, len(steps))
161+ for i, s := range steps {
162+ got[i] = s.GetOutcome()
163+ }
164+ assert.Equal(t, []pb.CiConclusion{
165+ pb.CiConclusion_CI_CONCLUSION_FAILURE, pb.CiConclusion_CI_CONCLUSION_FAILURE,
166+ pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED, pb.CiConclusion_CI_CONCLUSION_SKIPPED,
167+ }, got)
168+}
169+
170+func TestRunServiceAnnotationsAndSummaries(t *testing.T) {
171+ t.Parallel()
172+ repo := mustRepoName(t, "demo")
173+ runs := mocks.NewRunService(t)
174+ runs.EXPECT().ListAnnotations(mock.Anything, domain.Owner(), repo, int64(7), int32(2), "7-0-0").Return(ports.AnnotationList{
175+ Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}},
176+ Truncated: true,
177+ }, nil).Once()
178+ runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0)).Return(ports.SummaryList{
179+ Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true,
180+ }, nil).Once()
181+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once()
182+ s := &runService{runs: runs}
183+
184+ res, err := s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 7, Attempt: 2, JobId: "7-0-0"}))
185+ require.NoError(t, err)
186+ require.Len(t, res.Msg.GetAnnotations(), 1)
187+ a := res.Msg.GetAnnotations()[0]
188+ assert.Equal(t, "7-0-0", a.GetJobId())
189+ assert.Equal(t, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, a.GetAnnotation().GetLevel())
190+ assert.EqualValues(t, 3, a.GetAnnotation().GetStepNumber())
191+ assert.EqualValues(t, 4, a.GetAnnotation().GetStartLine())
192+ assert.True(t, res.Msg.GetTruncated())
193+
194+ sums, err := s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "demo", JobId: "7-0-0"}))
195+ require.NoError(t, err)
196+ require.Len(t, sums.Msg.GetSummaries(), 1)
197+ assert.EqualValues(t, 2, sums.Msg.GetSummaries()[0].GetStepNumber())
198+ assert.Equal(t, "# s", sums.Msg.GetSummaries()[0].GetMarkdown())
199+ assert.True(t, sums.Msg.GetTruncated())
200+
201+ _, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 8}))
202+ requireCode(t, connect.CodeNotFound, err)
203+ _, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "../x", RunId: 8}))
204+ requireCode(t, connect.CodeInvalidArgument, err)
205+ _, err = s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "../x"}))
206+ requireCode(t, connect.CodeInvalidArgument, err)
207+}
208+
209+// UploadSummary is a job call: its bearer secret is the job token, not a stored
210+// token, and a summary of exactly 1 MiB passes the size limit of the runner side.
211+func TestUploadSummaryIsAJobCallWithRoomForOneMiB(t *testing.T) {
212+ t.Parallel()
213+ assert.True(t, runnerSecretProcedures[ermiteconnect.RunnerServiceUploadSummaryProcedure])
214+ assert.Greater(t, RunnerMaxRequestBytes, 1<<20+len("7-0-0")+16, "1 MiB plus the envelope")
215+ assert.Equal(t, MaxRequestBytes, 1<<20, "the other services keep the 1 MiB limit")
216+ assert.Contains(t, Features, "ci.annotations")
217+ assert.Contains(t, Features, "ci.summaries")
218+}
M internal/adapters/web/actions.templ +103 -0
@@ -62,6 +62,7 @@ templ RunDetail(l Layout, p RunPage) {
6262 <div id="run-body">
6363 @RunBody(p)
6464 </div>
65+ @RunSummaries(p.Summaries)
6566 }
6667 }
6768
@@ -78,6 +79,12 @@ templ RunBody(p RunPage) {
7879 if p.Error != "" {
7980 <pre class="run-err">{ p.Error }</pre>
8081 }
82+ if len(p.Annotations) > 0 {
83+ <section class="annots" aria-labelledby="run-annots">
84+ <h3 id="run-annots">Annotations <span class="count">{ itoa(len(p.Annotations)) }</span></h3>
85+ @AnnotationList(p.Annotations, p.AnnotationsMore, false)
86+ </section>
87+ }
8188 <ul class="jobs">
8289 for _, j := range p.Jobs {
8390 <li id={ "job-" + j.ID }>
@@ -107,6 +114,101 @@ templ RunBody(p RunPage) {
107114 </ul>
108115 }
109116
117+// AnnotationList is a list of annotations: the level as a mark with its word,
118+// the job and the step, the title and the message, and file:line. Everything
119+// comes from the workflow and is text. Without grouped, each item names its job (the
120+// run page); grouped lists the annotations by step instead (the job page).
121+templ AnnotationList(anns []AnnotationView, more bool, grouped bool) {
122+ <ul class="annot-list">
123+ for _, a := range anns {
124+ <li class={ "annot", "annot-" + a.Kind }>
125+ if grouped && a.StepHead {
126+ <p class="annot-step">
127+ if a.StepHref != "" {
128+ <a href={ templ.SafeURL(a.StepHref) }>{ a.Step }</a>
129+ } else {
130+ { a.Step }
131+ }
132+ </p>
133+ }
134+ <span class={ "an", "an-" + a.Kind } role="img" aria-label={ a.Level } title={ a.Level }></span>
135+ <div class="annot-body">
136+ <p class="annot-head">
137+ <span class="annot-level">{ a.Level }</span>
138+ if !grouped {
139+ if a.JobHref != "" {
140+ <a href={ templ.SafeURL(a.JobHref) }>{ a.Job }</a>
141+ } else {
142+ <span>{ a.Job }</span>
143+ }
144+ <span class="mute">{ a.Step }</span>
145+ }
146+ if a.Title != "" {
147+ <strong>{ a.Title }</strong>
148+ }
149+ </p>
150+ <p class="annot-msg">{ a.Message }</p>
151+ if a.Where != "" {
152+ <p class="annot-where">
153+ if a.WhereHref != "" {
154+ <a href={ templ.SafeURL(a.WhereHref) }>{ a.Where }</a>
155+ } else {
156+ <span>{ a.Where }</span>
157+ }
158+ </p>
159+ }
160+ </div>
161+ </li>
162+ }
163+ </ul>
164+ if more {
165+ <p class="mute">more annotations exist than are shown.</p>
166+ }
167+}
168+
169+// JobAnnotations is the annotations of the job page, replaced by the live
170+// updates; the element exists even when there is none, so that the first one
171+// has something to replace.
172+templ JobAnnotations(anns []AnnotationView, more bool) {
173+ <section id="job-annotations" class="annots" aria-label="Annotations">
174+ if len(anns) > 0 {
175+ <h3>Annotations <span class="count">{ itoa(len(anns)) }</span></h3>
176+ @AnnotationList(anns, more, true)
177+ }
178+ </section>
179+}
180+
181+// RunSummaries is the job summaries of a run, one block per step that wrote
182+// one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs
183+// filtered, images shown as their alt text.
184+templ RunSummaries(sums []JobSummaries) {
185+ for _, j := range sums {
186+ <section class="summaries" aria-label={ "Summary of " + j.Job }>
187+ <h3>
188+ if j.JobHref != "" {
189+ <a href={ templ.SafeURL(j.JobHref) }>{ j.Job }</a>
190+ } else {
191+ { j.Job }
192+ }
193+ <span class="mute">summary</span>
194+ </h3>
195+ for _, s := range j.Steps {
196+ <article class="prose summary" aria-label={ s.Step } hx-ignore>
197+ <h4 class="summary-step">{ s.Step }</h4>
198+ if s.Note != "" {
199+ <p class="dim">{ s.Note }</p>
200+ } else {
201+ @templ.Raw(s.HTML)
202+ }
203+ </article>
204+ }
205+ if j.More {
206+ <p class="mute">the job has more summaries than the first 20, which are shown.</p>
207+ }
208+ </section>
209+ }
210+}
211+
110212 templ runRail(p RunPage) {
111213 <section aria-labelledby="r-run">
112214 <h2 id="r-run">Run</h2>
@@ -186,6 +288,7 @@ templ Job(l Layout, p JobPage) {
186288 @Shell(l, jobRail(p)) {
187289 @LiveSlot("job-notice")
188290 @JobHead(p.JobRow, p.Total)
291+ @JobAnnotations(p.Annotations, p.AnnotationsMore)
189292 <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }>
190293 if p.Note != "" {
191294 <p class="dim">{ p.Note }</p>
M internal/adapters/web/actions_log.go +3 -0
@@ -51,6 +51,9 @@ type JobPage struct {
5151 // StreamHref is the live feed of the lines (logStream).
5252 StreamHref string
5353 Note string
54+ // Annotations are those of the job, shown above the log.
55+ Annotations []AnnotationView
56+ AnnotationsMore bool
5457 }
5558
5659 // appendLogLine adds the line n of a job log to the lines shown. The markers
M internal/adapters/web/actions_pages.go +23 -2
@@ -173,7 +173,15 @@ type RunPage struct {
173173 Latest int32
174174 Attempts []AttemptLink
175175 Jobs []JobRow
176- Active bool
176+ // Annotations are those of the jobs of the run, AnnotationsMore says that
177+ // the server holds more than it returned.
178+ Annotations []AnnotationView
179+ AnnotationsMore bool
180+ // Summaries are the job summaries, rendered; only the full page has them.
181+ Summaries []JobSummaries
182+ Active bool
183+ // detail is what the page was built from.
184+ detail ports.RunDetail
177185 // Forms are the owner's actions; nil/empty when the visitor cannot.
178186 CancelAction, RerunAction, RerunFailedAction string
179187 CanRerunFailed bool
@@ -284,6 +292,11 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int
284292 if err != nil {
285293 return nil, 0, err
286294 }
295+ // The summaries are part of the page, not of the live body: the page
296+ // fetches itself again when the run completes (runBody), and they show then.
297+ if p.Summaries, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest); err != nil {
298+ return nil, 0, err
299+ }
287300 l := s.repoLayout(ctx, d, "actions")
288301 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
289302 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
@@ -303,7 +316,7 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int
303316 run := detail.Run
304317 p := RunPage{
305318 RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
306- Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
319+ Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, detail: detail,
307320 }
308321 for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
309322 p.Attempts = append(p.Attempts, AttemptLink{
@@ -318,6 +331,11 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int
318331 }
319332 p.Jobs = append(p.Jobs, row)
320333 }
334+ // The annotations of the attempt shown (the jobs kept from an earlier attempt
335+ // by a re-run bring their own).
336+ if p.Annotations, p.AnnotationsMore, err = s.runAnnotations(ctx, d, run, run.Attempt, detail.Jobs, run.Attempt == detail.LatestAttempt); err != nil {
337+ return RunPage{}, err
338+ }
321339 if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
322340 base := runHref(d.name(), id)
323341 if p.Active {
@@ -382,6 +400,9 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int
382400 p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
383401 }
384402 p.Follow = !view.Ended()
403+ if p.Annotations, p.AnnotationsMore, err = s.jobAnnotations(ctx, d, id, run.Run.Sha, view, attempt); err != nil {
404+ return nil, 0, err
405+ }
385406 p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
386407 lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
387408 if err != nil {
A internal/adapters/web/actions_results.go +227 -0
@@ -0,0 +1,227 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "html/template"
8+ "path"
9+ "strconv"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The results of a job on the CI pages: the annotations (the "Annotations" box
17+// of the run page, and at their step on the job page) and the job summaries
18+// under the run. Both are untrusted text of the workflow: annotations are
19+// escaped by the templates, summaries go through the Markdown renderer of the
20+// READMEs (raw HTML dropped, URLs filtered, images shown as their alt text).
21+
22+// maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries
23+// together; the ones past it are not shown, with a note.
24+const maxSummaryPageBytes = 4 << 20
25+
26+// AnnotationView is an annotation as shown.
27+type AnnotationView struct {
28+ // Kind is the colour class (notice, warning, failure) and Level its word.
29+ Kind, Level string
30+ Job, JobHref string
31+ // Step is the name of the step and StepHref jumps to its lines in the log
32+ // of the job.
33+ Step, StepHref string
34+ Title, Message string
35+ // Where is "file:line", WhereHref the file at the commit of the run, "" when
36+ // the path is not one of the repository.
37+ Where, WhereHref string
38+ // StepHead is set on the first annotation of a step in a list grouped by
39+ // step (the job page).
40+ StepHead bool
41+}
42+
43+// stepInfo is what the views need of the step an annotation belongs to.
44+type stepInfo struct {
45+ name string
46+ start, end int64
47+}
48+
49+// jobInfo is what the annotation views need of a job.
50+type jobInfo struct {
51+ name, href string
52+ steps map[int32]stepInfo
53+}
54+
55+func jobInfoOf(repo string, runID int64, j ports.JobView, linkJob bool) jobInfo {
56+ info := jobInfo{name: visibleText(j.Name), href: jobHref(repo, runID, j.ID), steps: map[int32]stepInfo{}}
57+ if !linkJob {
58+ info.href = ""
59+ }
60+ for i, st := range j.Steps {
61+ r := j.StepRanges()[i]
62+ info.steps[st.Number] = stepInfo{name: visibleText(st.Name), start: r.Start, end: r.End}
63+ }
64+ return info
65+}
66+
67+func annotationKind(l domain.AnnotationLevel) (kind, word string) {
68+ switch l {
69+ case domain.AnnotationFailure:
70+ return "failure", "error"
71+ case domain.AnnotationWarning:
72+ return "warning", "warning"
73+ }
74+ return "notice", "notice"
75+}
76+
77+// annotationWhere is the "file:line" of an annotation, and a link to the file
78+// at the commit when the path is a path of the repository.
79+func annotationWhere(repo, sha string, a domain.CIAnnotation) (text, href string) {
80+ if a.File == "" {
81+ return "", ""
82+ }
83+ text = visibleText(a.File)
84+ if a.StartLine > 0 {
85+ text += ":" + strconv.Itoa(int(a.StartLine))
86+ if a.EndLine > a.StartLine {
87+ text += "-" + strconv.Itoa(int(a.EndLine))
88+ }
89+ }
90+ if !repoPath(a.File) {
91+ return text, ""
92+ }
93+ href = repoHref(repo, "blob", sha, a.File)
94+ if a.StartLine > 0 {
95+ href += "#L" + strconv.Itoa(int(a.StartLine))
96+ if a.EndLine > a.StartLine {
97+ href += "-L" + strconv.Itoa(int(a.EndLine))
98+ }
99+ }
100+ return text, href
101+}
102+
103+// repoPath reports whether a path of an annotation is a clean path inside the
104+// repository: relative, without "." or ".." segments, backslashes or control
105+// characters.
106+func repoPath(p string) bool {
107+ if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00\r\n\t") || path.Clean(p) != p {
108+ return false
109+ }
110+ return !strings.HasPrefix(p, "../") && p != ".." && p != "."
111+}
112+
113+// annotationViews builds the views of annotations, in their order. jobs maps a
114+// job id to what is known of it; an annotation of an unknown job is skipped.
115+func annotationViews(repo, sha string, list ports.AnnotationList, jobs map[string]jobInfo) []AnnotationView {
116+ out := make([]AnnotationView, 0, len(list.Annotations))
117+ for _, ja := range list.Annotations {
118+ job, ok := jobs[ja.JobID]
119+ if !ok {
120+ continue
121+ }
122+ a := ja.Annotation
123+ v := AnnotationView{
124+ Job: job.name, JobHref: job.href, Title: visibleText(a.Title), Message: visibleText(a.Message),
125+ }
126+ v.Kind, v.Level = annotationKind(a.Level)
127+ if st, ok := job.steps[a.Step]; ok {
128+ v.Step = st.name
129+ if st.end > st.start && job.href != "" {
130+ v.StepHref = job.href + "#L" + strconv.FormatInt(st.start, 10)
131+ }
132+ }
133+ if v.Step == "" {
134+ v.Step = "step " + strconv.Itoa(int(a.Step))
135+ }
136+ v.Where, v.WhereHref = annotationWhere(repo, sha, a)
137+ out = append(out, v)
138+ }
139+ return out
140+}
141+
142+// SummaryView is the summary of one step, rendered.
143+type SummaryView struct {
144+ Step string
145+ HTML template.HTML
146+ // Note replaces the content when it is not shown.
147+ Note string
148+}
149+
150+// JobSummaries are the summaries of one job.
151+type JobSummaries struct {
152+ Job, JobHref string
153+ Steps []SummaryView
154+ // More says that the job has more summaries than the 20 shown.
155+ More bool
156+}
157+
158+// jobSummaries reads and renders the summaries of the jobs of a run, in the
159+// order of the jobs, within maxSummaryPageBytes of Markdown.
160+func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun, jobs []ports.JobView, linkJobs bool) ([]JobSummaries, error) {
161+ budget := maxSummaryPageBytes
162+ var out []JobSummaries
163+ for _, j := range jobs {
164+ if j.Phase != domain.CIPhaseRunning && !j.Ended() {
165+ continue // no step has run
166+ }
167+ res, err := s.cfg.Runs.GetStepSummaries(ctx, principal(ctx), d.repo.Name, j.ID, j.Attempt)
168+ if err != nil {
169+ return nil, err
170+ }
171+ if len(res.Summaries) == 0 {
172+ continue
173+ }
174+ info := jobInfoOf(d.name(), run.ID, j, linkJobs)
175+ js := JobSummaries{Job: info.name, JobHref: info.href, More: res.Truncated}
176+ for _, sm := range res.Summaries {
177+ name := "step " + strconv.Itoa(int(sm.Step))
178+ if st, ok := info.steps[sm.Step]; ok && st.name != "" {
179+ name = st.name
180+ }
181+ v := SummaryView{Step: name}
182+ if len(sm.Markdown) > budget {
183+ v.Note = "this summary is not shown: the summaries of the page are over " + humanSize(maxSummaryPageBytes) + "."
184+ js.Steps = append(js.Steps, v)
185+ continue
186+ }
187+ budget -= len(sm.Markdown)
188+ prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-"
189+ h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix)
190+ if err != nil {
191+ v.Note = "this summary cannot be shown."
192+ } else {
193+ v.HTML = h
194+ }
195+ js.Steps = append(js.Steps, v)
196+ }
197+ out = append(out, js)
198+ }
199+ return out, nil
200+}
201+
202+// runAnnotations reads the annotations of a run attempt and builds their views.
203+func (s *Server) runAnnotations(ctx context.Context, d *repoData, run domain.CIRun, attempt int32, jobs []ports.JobView, linkJobs bool) (annotations []AnnotationView, truncated bool, err error) {
204+ list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, run.ID, attempt, "")
205+ if err != nil {
206+ return nil, false, err
207+ }
208+ infos := make(map[string]jobInfo, len(jobs))
209+ for _, j := range jobs {
210+ infos[j.ID] = jobInfoOf(d.name(), run.ID, j, linkJobs)
211+ }
212+ return annotationViews(d.name(), run.Sha, list, infos), list.Truncated, nil
213+}
214+
215+// jobAnnotations reads the annotations of one job and builds their views,
216+// grouped by step.
217+func (s *Server) jobAnnotations(ctx context.Context, d *repoData, runID int64, sha string, view ports.JobView, attempt int32) ([]AnnotationView, bool, error) {
218+ list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, runID, attempt, view.ID)
219+ if err != nil {
220+ return nil, false, err
221+ }
222+ views := annotationViews(d.name(), sha, list, map[string]jobInfo{view.ID: jobInfoOf(d.name(), runID, view, true)})
223+ for i := range views {
224+ views[i].StepHead = i == 0 || views[i-1].Step != views[i].Step
225+ }
226+ return views, list.Truncated, nil
227+}
A internal/adapters/web/actions_results_test.go +130 -0
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+// AC6: a summary is untrusted Markdown. The renderer is the one of the READMEs:
22+// raw HTML is dropped and a javascript: link loses its link.
23+func TestSummariesAreRenderedSafely(t *testing.T) {
24+ t.Parallel()
25+ const jobID = "1-0-0"
26+ repo := repoName(t, "proj")
27+ job := ports.JobView{CIJob: domain.CIJob{
28+ ID: jobID, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build <script>",
29+ Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
30+ Steps: []domain.CIStep{{Number: 2, Name: "step <b>two</b>", State: domain.CIStepCompleted, Conclusion: domain.CISuccess}},
31+ }}
32+ md := strings.Join([]string{
33+ "# Title",
34+ "<script>alert(1)</script>",
35+ "",
36+ "[x](javascript:alert(1))",
37+ "",
38+ "[ok](https://example.org/)",
39+ "",
40+ "<a href=\"javascript:alert(2)\" onclick=\"alert(3)\">raw</a>",
41+ "",
42+ "[frag](#title)",
43+ }, "\n")
44+
45+ runs := mocks.NewRunService(t)
46+ runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
47+ Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
48+ Jobs: []ports.JobView{job}, LatestAttempt: 1,
49+ }, nil)
50+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
51+ runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, jobID, int32(1)).Return(ports.SummaryList{
52+ Summaries: []domain.CIStepSummary{{Step: 2, Markdown: md}, {Step: 7, Markdown: "second"}}, Truncated: true,
53+ }, nil)
54+ browse := mocks.NewBrowseService(t)
55+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
56+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
57+
58+ rec := httptest.NewRecorder()
59+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/proj/actions/runs/1", nil))
60+ require.Equal(t, http.StatusOK, rec.Code)
61+ body := rec.Body.String()
62+
63+ assert.Contains(t, body, "<h1", "Markdown is rendered")
64+ assert.Contains(t, body, `href="https://example.org/"`)
65+ assert.Contains(t, body, "second")
66+ assert.Contains(t, body, "step &lt;b&gt;two&lt;/b&gt;", "the name of a step is text")
67+ assert.Contains(t, body, "step 7", "a step the job does not list is named by its number")
68+ assert.Contains(t, body, "build &lt;script&gt;", "the job name is text")
69+ assert.Contains(t, body, "more summaries than the first 20")
70+ assert.Contains(t, body, `href="#md-s1-0-0-2-title"`, "fragments point at the headings of their own summary")
71+ assert.Contains(t, body, `id="md-s1-0-0-2-title"`)
72+
73+ assert.NotContains(t, body, "<script>alert")
74+ assert.NotContains(t, body, "javascript:")
75+ assert.NotContains(t, body, "onclick")
76+ assert.NotContains(t, body, `<a href="javascript`)
77+ assert.NotContains(t, body, "alert(1)</", "no script text left as markup")
78+ assert.Contains(t, body, ">x</", "the text of the dropped link stays")
79+}
80+
81+func TestAnnotationViews(t *testing.T) {
82+ t.Parallel()
83+ sha := strings.Repeat("b", 40)
84+ jobs := map[string]jobInfo{"1-0-0": {name: "build", href: "/proj/actions/runs/1/job/1-0-0", steps: map[int32]stepInfo{
85+ 2: {name: "Run vet", start: 4, end: 9},
86+ 3: {name: "no lines", start: 9, end: 9},
87+ }}}
88+ list := ports.AnnotationList{Annotations: []ports.JobAnnotation{
89+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationFailure, Title: "T", Message: "m", File: "pkg/a b.go", StartLine: 4, EndLine: 6, Step: 2}},
90+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "w", File: "x.go", StartLine: 1, EndLine: 1, Step: 3}},
91+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "n", Step: 40}},
92+ {JobID: "gone", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "x", Step: 1}},
93+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "p", File: "../../etc/passwd", StartLine: 2, Step: 2}},
94+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "a", File: "/etc/passwd", Step: 2}},
95+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "b", File: `dir\file.go`, Step: 2}},
96+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "c", File: "a/./b.go", Step: 2}},
97+ }}
98+ v := annotationViews("proj", sha, list, jobs)
99+ require.Len(t, v, 7, "an annotation of a job the page does not know is skipped")
100+
101+ assert.Equal(t, "failure", v[0].Kind)
102+ assert.Equal(t, "error", v[0].Level)
103+ assert.Equal(t, "Run vet", v[0].Step)
104+ assert.Equal(t, "/proj/actions/runs/1/job/1-0-0#L4", v[0].StepHref)
105+ assert.Equal(t, "pkg/a b.go:4-6", v[0].Where)
106+ assert.Equal(t, "/proj/blob/"+sha+"/pkg/a%20b.go#L4-L6", v[0].WhereHref)
107+
108+ assert.Equal(t, "x.go:1", v[1].Where, "an end line equal to the start is not a range")
109+ assert.Equal(t, "/proj/blob/"+sha+"/x.go#L1", v[1].WhereHref)
110+ assert.Empty(t, v[1].StepHref, "a step without lines has no anchor")
111+
112+ assert.Equal(t, "step 40", v[2].Step, "a step the job does not list")
113+ assert.Equal(t, "notice", v[2].Level)
114+ assert.Empty(t, v[2].Where)
115+
116+ for i, want := range []string{"../../etc/passwd:2", "/etc/passwd", `dir\file.go`, "a/./b.go"} {
117+ assert.Equal(t, want, v[3+i].Where)
118+ assert.Empty(t, v[3+i].WhereHref, "%q is not a path of the repository", want)
119+ }
120+}
121+
122+func TestRepoPath(t *testing.T) {
123+ t.Parallel()
124+ for _, ok := range []string{"a", "a/b.go", "dir/with space/x", ".github/workflows/ci.yml", "a..b", "é/ü.go"} {
125+ assert.True(t, repoPath(ok), ok)
126+ }
127+ for _, bad := range []string{"", ".", "..", "../a", "a/..", "a/../b", "/a", "a//b", "a/./b", "a/", `a\b`, "a\x00b", "a\nb", "a\tb"} {
128+ assert.False(t, repoPath(bad), "%q", bad)
129+ }
130+}
M internal/adapters/web/actions_templ.go +1014 -400 generated file: collapsed
M internal/adapters/web/actions_test.go +1 -0
@@ -38,6 +38,7 @@ func TestLogPagesPassTheClientKey(t *testing.T) {
3838 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
3939 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1,
4040 }, nil).Maybe()
41+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{}, nil).Maybe()
4142 var seen []string
4243 runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(
4344 func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error {
M internal/adapters/web/fragments.go +13 -1
@@ -496,5 +496,17 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
496496 if err != nil {
497497 return nil, err
498498 }
499- return []fragment{f, steps}, nil
499+ run, err := l.s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, view.RunID, 0)
500+ if err != nil {
501+ return nil, err
502+ }
503+ anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, run.Run.Sha, view, 0)
504+ if err != nil {
505+ return nil, err
506+ }
507+ notes, err := renderFragment(ctx, "ci-job", JobAnnotations(anns, more))
508+ if err != nil {
509+ return nil, err
510+ }
511+ return []fragment{f, steps, notes}, nil
500512 }
M internal/adapters/web/markdown.go +17 -5
@@ -55,9 +55,16 @@ func newMarkdown() *markdown {
5555 // are where relative links point; dir is the directory of the Markdown file
5656 // in the repository, "" for the root.
5757 func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
58- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()}))
58+ return m.renderWithIDs(source, repo, ref, dir, headingIDPrefix)
59+}
60+
61+// renderWithIDs is render with the prefix of the heading ids and of the
62+// #fragment links chosen, so that several documents on one page (the summaries
63+// of a run) do not share ids.
64+func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
65+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
5966 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
60- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source}
67+ pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
6168 pol.apply(doc)
6269
6370 var buf bytes.Buffer
@@ -70,10 +77,13 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
7077 }
7178
7279 // prefixedIDs prefixes the heading ids that goldmark generates.
73-type prefixedIDs struct{ inner parser.IDs }
80+type prefixedIDs struct {
81+ prefix string
82+ inner parser.IDs
83+}
7484
7585 func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
76- return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...)
86+ return append([]byte(p.prefix), p.inner.Generate(value, kind)...)
7787 }
7888
7989 func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
@@ -119,6 +129,8 @@ func plainText(n ast.Node, source []byte) string {
119129 type linkPolicy struct {
120130 repo, ref, dir string
121131 source []byte
132+ // idPrefix is the prefix of the heading ids that #fragment links point at.
133+ idPrefix string
122134 }
123135
124136 func (p linkPolicy) apply(doc ast.Node) {
@@ -202,7 +214,7 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
202214 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
203215 return "", dropped
204216 }
205- return "#" + headingIDPrefix + frag, internal
217+ return "#" + p.idPrefix + frag, internal
206218 }
207219 if strings.HasPrefix(dest, "//") {
208220 return "", dropped
M internal/adapters/web/static/css/65-ci.css +29 -0
@@ -37,6 +37,35 @@
3737 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
3838 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
3939
40+ /* annotations and summaries of a run */
41+ .annots { margin: 0 0 var(--s4); }
42+ .annots h3, .summaries h3 { display: flex; align-items: baseline; gap: var(--s2); margin: 0 0 var(--s2); font-size: var(--fs-s); }
43+ .annot-list { display: grid; gap: var(--s2); padding: 0; margin: 0; list-style: none; }
44+ .annot {
45+ display: flex; gap: var(--s2); align-items: flex-start; padding: var(--s2) var(--s3);
46+ background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--border-strong); border-radius: var(--r-s);
47+ font-size: var(--fs-s);
48+ }
49+ .annot-failure { border-left-color: var(--red); }
50+ .annot-warning { border-left-style: dashed; border-left-color: var(--ink-dim); }
51+ .annot-notice { border-left-color: var(--teal); }
52+ .annot p { margin: 0; }
53+ .annot-step { flex-basis: 100%; font-weight: 700; }
54+ .annot:has(.annot-step) { flex-wrap: wrap; }
55+ .annot-body { min-width: 0; }
56+ .annot-head { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: baseline; }
57+ .annot-level { font-weight: 700; }
58+ .annot-msg { white-space: pre-wrap; overflow-wrap: anywhere; }
59+ .annot-where { color: var(--ink-dim); font-family: var(--mono); overflow-wrap: anywhere; }
60+ .an { display: inline-block; flex: none; width: 14px; height: 14px; margin-top: 2px; border: 2px solid var(--ink-dim); box-sizing: border-box; }
61+ .an-failure { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); }
62+ .an-warning { border-radius: 50% 50% 0 0; border-style: dashed; }
63+ .an-notice { border-radius: 50%; border-color: var(--teal); }
64+ .summaries { margin: var(--s5) 0 0; }
65+ .summary { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
66+ .summary + .summary { margin-top: var(--s3); }
67+ .summary-step { margin: 0 0 var(--s2); font-size: var(--fs-s); color: var(--ink-dim); }
68+
4069 .joblog { margin-top: var(--s4); }
4170 .log {
4271 margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto;
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
A internal/app/ci_results_test.go +553 -0
@@ -0,0 +1,553 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// CI-2 results (phase E2): annotations and step summaries, from the runner's
20+// calls to the API, the pages and the CLI.
21+
22+const resultsWorkflow = `name: ci
23+on:
24+ push:
25+ branches: [main]
26+jobs:
27+ build:
28+ runs-on: ubuntu-latest
29+ steps:
30+ - run: echo hello
31+ lint:
32+ runs-on: ubuntu-latest
33+ steps:
34+ - run: echo lint
35+`
36+
37+func ann(level pb.AnnotationLevel, step int32, msg string) *pb.Annotation {
38+ return &pb.Annotation{Level: level, Message: msg, StepNumber: step}
39+}
40+
41+func (r *fakeRunner) update(j *pb.Job, steps []*pb.StepUpdate, anns ...*pb.Annotation) error {
42+ _, err := r.jobAPI(j).runner.UpdateJob(r.c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: steps, Annotations: anns}))
43+ return err
44+}
45+
46+func (r *fakeRunner) summary(j *pb.Job, step int32, md string) error {
47+ _, err := r.jobAPI(j).runner.UploadSummary(r.c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: j.GetJobId(), StepNumber: step, Markdown: md}))
48+ return err
49+}
50+
51+func (r *fakeRunner) heartbeat(j *pb.Job) {
52+ r.c.t.Helper()
53+ _, err := r.jobAPI(j).runner.Heartbeat(r.c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
54+ require.NoError(r.c.t, err)
55+}
56+
57+func (c *ciHarness) annotations(runID int64, jobID string) *pb.ListAnnotationsResponse {
58+ c.t.Helper()
59+ res, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: jobID}))
60+ require.NoError(c.t, err)
61+ return res.Msg
62+}
63+
64+func (c *ciHarness) summaries(jobID string) *pb.GetStepSummariesResponse {
65+ c.t.Helper()
66+ res, err := c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: jobID}))
67+ require.NoError(c.t, err)
68+ return res.Msg
69+}
70+
71+func code(err error) connect.Code { return connect.CodeOf(err) }
72+
73+func TestCIAnnotationsAndSummariesOverTheAPI(t *testing.T) {
74+ c := newCIHarness(t, nil)
75+ runner := c.registerRunner("alpha", "ubuntu-latest")
76+ c.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
77+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
78+ runID := c.runs()[0].GetId()
79+
80+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
81+ require.NoError(t, err)
82+ assert.Contains(t, ver.Msg.GetFeatures(), "ci.annotations")
83+ assert.Contains(t, ver.Msg.GetFeatures(), "ci.summaries")
84+
85+ a, b := runner.await(), runner.await()
86+ runner.heartbeat(a)
87+ runner.heartbeat(b)
88+ start := now()
89+ running := []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start}}
90+ require.NoError(t, runner.update(a, running))
91+ done := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()}
92+
93+ notice, warning, failure := pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE
94+ t.Run("an annotation of a step that UpdateJob never named is stored", func(t *testing.T) {
95+ full := &pb.Annotation{Level: failure, Title: "Vet", Message: "unused variable", File: "main.go", StartLine: 3, EndLine: 4, StartColumn: 2, EndColumn: 8, StepNumber: 7}
96+ require.NoError(t, runner.update(a, nil, full, ann(notice, 7, "second")))
97+ got := c.annotations(runID, a.GetJobId())
98+ require.Len(t, got.GetAnnotations(), 2)
99+ assert.Equal(t, a.GetJobId(), got.GetAnnotations()[0].GetJobId())
100+ assert.EqualValues(t, full.GetStartLine(), got.GetAnnotations()[0].GetAnnotation().GetStartLine())
101+ assert.Equal(t, "Vet", got.GetAnnotations()[0].GetAnnotation().GetTitle())
102+ assert.Equal(t, "second", got.GetAnnotations()[1].GetAnnotation().GetMessage(), "in the order the step produced them")
103+ assert.False(t, got.GetTruncated())
104+ })
105+
106+ t.Run("replacement per step, a retry changes nothing, a step absent is kept", func(t *testing.T) {
107+ require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
108+ require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
109+ require.NoError(t, runner.update(a, nil, ann(notice, 7, "only one now")))
110+ got := c.annotations(runID, a.GetJobId()).GetAnnotations()
111+ require.Len(t, got, 2, "step 3 is kept, step 7 holds one annotation")
112+ assert.EqualValues(t, 3, got[0].GetAnnotation().GetStepNumber(), "ordered by step")
113+ assert.Equal(t, "only one now", got[1].GetAnnotation().GetMessage())
114+ })
115+
116+ t.Run("a COMPLETED step can still have its annotations replaced", func(t *testing.T) {
117+ require.NoError(t, runner.update(a, []*pb.StepUpdate{done}, ann(notice, 1, "before")))
118+ require.NoError(t, runner.update(a, nil, ann(failure, 1, "after")))
119+ require.NoError(t, runner.update(a, []*pb.StepUpdate{done}), "the same step again changes nothing")
120+ got := c.annotations(runID, a.GetJobId()).GetAnnotations()
121+ assert.Equal(t, "after", got[0].GetAnnotation().GetMessage())
122+ assert.Equal(t, failure, got[0].GetAnnotation().GetLevel())
123+ // The rule that a completed step cannot change still holds for the step.
124+ changed := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: done.GetCompletedAt()}
125+ assert.Equal(t, connect.CodeFailedPrecondition, code(runner.update(a, []*pb.StepUpdate{changed})))
126+ })
127+
128+ t.Run("an empty list for a step is not a request: only a step present is replaced", func(t *testing.T) {
129+ before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
130+ require.NoError(t, runner.update(a, nil))
131+ assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
132+ })
133+
134+ t.Run("the limits, and a call applied whole or not at all", func(t *testing.T) {
135+ before := c.annotations(runID, a.GetJobId()).GetAnnotations()
136+ many := func(level pb.AnnotationLevel, n int) []*pb.Annotation {
137+ var out []*pb.Annotation
138+ for range n {
139+ out = append(out, ann(level, 9, "m"))
140+ }
141+ return out
142+ }
143+ require.NoError(t, runner.update(a, nil, many(warning, 10)...), "ten of a level is allowed")
144+ require.NoError(t, runner.update(a, nil, append(many(warning, 10), many(failure, 10)...)...), "the limit is per level")
145+ bad := map[string][]*pb.Annotation{
146+ "eleven of a level": many(notice, 11),
147+ "no level": {{Message: "m", StepNumber: 1}},
148+ "no message": {ann(notice, 1, "")},
149+ "message too long": {ann(notice, 1, strings.Repeat("é", 4097))},
150+ "title too long": {{Level: notice, Message: "m", Title: strings.Repeat("t", 256), StepNumber: 1}},
151+ "file too long": {{Level: notice, Message: "m", File: strings.Repeat("f", 1025), StepNumber: 1}},
152+ "step zero": {ann(notice, 0, "m")},
153+ "step beyond 1000": {ann(notice, 1001, "m")},
154+ "end before start": {{Level: notice, Message: "m", StartLine: 5, EndLine: 4, StepNumber: 1}},
155+ "a good one and bad": {ann(notice, 1, "good"), ann(notice, 1, "")},
156+ }
157+ for name, anns := range bad {
158+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, nil, anns...)), name)
159+ }
160+ // Characters, not bytes: 4096 two-byte characters pass.
161+ require.NoError(t, runner.update(a, nil, ann(notice, 11, strings.Repeat("é", 4096))))
162+ require.NoError(t, runner.update(a, nil, &pb.Annotation{Level: notice, Message: "m", Title: strings.Repeat("t", 255), File: strings.Repeat("f", 1024), StepNumber: 11}))
163+ // An invalid call stored nothing: steps 1, 3 and 7 are as they were.
164+ after := c.annotations(runID, a.GetJobId()).GetAnnotations()
165+ require.GreaterOrEqual(t, len(after), len(before))
166+ var step1 []string
167+ for _, x := range after {
168+ if x.GetAnnotation().GetStepNumber() == 1 {
169+ step1 = append(step1, x.GetAnnotation().GetMessage())
170+ }
171+ }
172+ assert.Equal(t, []string{"after"}, step1)
173+ // A step of a bad call that is valid is not applied either.
174+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, []*pb.StepUpdate{{Number: 5, State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}}, ann(notice, 1, ""))))
175+ for _, s := range c.getRun(runID).GetJobs()[0].GetSteps() {
176+ assert.NotEqual(t, int32(5), s.GetNumber())
177+ }
178+ })
179+
180+ t.Run("summaries: stored, replaced, removed, bounded", func(t *testing.T) {
181+ require.NoError(t, runner.summary(a, 2, "# one"))
182+ require.NoError(t, runner.summary(a, 2, "# one"))
183+ require.NoError(t, runner.summary(a, 2, "# two"))
184+ require.NoError(t, runner.summary(a, 12, "later"))
185+ got := c.summaries(a.GetJobId())
186+ require.Len(t, got.GetSummaries(), 2)
187+ assert.EqualValues(t, 2, got.GetSummaries()[0].GetStepNumber())
188+ assert.Equal(t, "# two", got.GetSummaries()[0].GetMarkdown())
189+ assert.EqualValues(t, 12, got.GetSummaries()[1].GetStepNumber(), "numeric order")
190+ require.NoError(t, runner.summary(a, 12, ""))
191+ assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 1, "an empty markdown removes it")
192+
193+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 0, "x")))
194+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 1001, "x")))
195+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 2, strings.Repeat("x", 1<<20+1))))
196+ require.NoError(t, runner.summary(a, 3, strings.Repeat("x", 1<<20)), "exactly 1 MiB is accepted")
197+ assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 2)
198+ assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "an invalid call changes nothing")
199+ })
200+
201+ t.Run("at most 20 summaries per job, with truncated", func(t *testing.T) {
202+ for i := int32(1); i <= 22; i++ {
203+ require.NoError(t, runner.summary(b, i, "s"))
204+ }
205+ got := c.summaries(b.GetJobId())
206+ assert.Len(t, got.GetSummaries(), 20)
207+ assert.True(t, got.GetTruncated())
208+ assert.EqualValues(t, 20, got.GetSummaries()[19].GetStepNumber())
209+ })
210+
211+ t.Run("the token of another job is refused", func(t *testing.T) {
212+ other := runner.jobAPI(b).runner
213+ _, err := other.UpdateJob(c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: a.GetJobId(), Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
214+ assert.Equal(t, connect.CodePermissionDenied, code(err))
215+ _, err = other.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
216+ assert.Equal(t, connect.CodePermissionDenied, code(err))
217+ _, err = other.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
218+ assert.Equal(t, connect.CodePermissionDenied, code(err))
219+ // No credential, a runner token and a stored token are not job tokens either.
220+ for _, secret := range []string{"", runner.secret, adminToken} {
221+ cl := c.api(secret).runner
222+ _, err = cl.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
223+ assert.Equal(t, connect.CodeUnauthenticated, code(err), secret)
224+ }
225+ assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown())
226+ })
227+
228+ t.Run("CompleteJob applies annotations with the steps and the outcome", func(t *testing.T) {
229+ steps := []*pb.StepUpdate{
230+ done,
231+ {
232+ Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
233+ Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now(),
234+ },
235+ }
236+ _, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
237+ JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
238+ Annotations: []*pb.Annotation{ann(warning, 2, "from complete"), ann(notice, 1, "step one, replaced")},
239+ }))
240+ require.NoError(t, err)
241+ got := c.annotations(runID, a.GetJobId()).GetAnnotations()
242+ var msgs []string
243+ for _, x := range got {
244+ if s := x.GetAnnotation().GetStepNumber(); s == 1 || s == 2 {
245+ msgs = append(msgs, x.GetAnnotation().GetMessage())
246+ }
247+ }
248+ assert.Equal(t, []string{"step one, replaced", "from complete"}, msgs)
249+
250+ job := c.getRun(runID).GetJobs()[0]
251+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[1].GetConclusion())
252+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, job.GetSteps()[1].GetOutcome(), "continue-on-error: failed, then succeeded")
253+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[0].GetOutcome(), "a runner that sent none: read as the conclusion")
254+ })
255+
256+ t.Run("a job that ended rejects annotations and summaries; a retry of CompleteJob changes nothing", func(t *testing.T) {
257+ before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
258+ err := runner.update(a, nil, ann(notice, 1, "too late"))
259+ require.Equal(t, connect.CodeFailedPrecondition, code(err))
260+ assert.NotEmpty(t, errDetails(err), "with a JobEnded detail")
261+ err = runner.summary(a, 1, "too late")
262+ assert.Equal(t, connect.CodeFailedPrecondition, code(err))
263+ assert.NotEmpty(t, errDetails(err))
264+ res, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
265+ JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "ignored")},
266+ }))
267+ require.NoError(t, err)
268+ assert.False(t, res.Msg.GetApplied())
269+ assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
270+ for _, x := range c.annotations(runID, a.GetJobId()).GetAnnotations() {
271+ assert.NotEqual(t, "too late", x.GetAnnotation().GetMessage())
272+ assert.NotEqual(t, "ignored", x.GetAnnotation().GetMessage())
273+ }
274+ assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "still readable after the end")
275+ })
276+
277+ t.Run("reading: the run, one job, a job that is not of the run, access", func(t *testing.T) {
278+ all := c.annotations(runID, "")
279+ jobs := map[string]int{}
280+ for _, x := range all.GetAnnotations() {
281+ jobs[x.GetJobId()]++
282+ }
283+ assert.Len(t, jobs, 1, "job b has none")
284+ assert.Empty(t, c.annotations(runID, b.GetJobId()).GetAnnotations())
285+
286+ _, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: "99-0-0"}))
287+ assert.Equal(t, connect.CodeNotFound, code(err))
288+ _, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, Attempt: 2}))
289+ assert.Equal(t, connect.CodeNotFound, code(err))
290+ _, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: 99}))
291+ assert.Equal(t, connect.CodeNotFound, code(err))
292+ _, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: "99-0-0"}))
293+ assert.Equal(t, connect.CodeNotFound, code(err))
294+ _, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId(), Attempt: 3}))
295+ assert.Equal(t, connect.CodeNotFound, code(err))
296+
297+ // The repository is private: an anonymous caller sees nothing of it, a
298+ // reader sees everything, as for GetRun.
299+ anon := c.api("").run
300+ _, err = anon.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
301+ assert.Equal(t, connect.CodeNotFound, code(err))
302+ _, err = anon.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
303+ assert.Equal(t, connect.CodeNotFound, code(err))
304+ reader := c.api(c.token(scopes(domain.ScopeRepoRead))).run
305+ _, err = reader.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
306+ assert.NoError(t, err)
307+ _, err = reader.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
308+ assert.NoError(t, err)
309+ })
310+
311+ t.Run("the summaries of an ended job stay readable", func(t *testing.T) {
312+ _, err := runner.jobAPI(b).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE}))
313+ require.NoError(t, err)
314+ assert.Len(t, c.summaries(b.GetJobId()).GetSummaries(), 20)
315+ })
316+}
317+
318+// errDetails returns the Connect error details of err.
319+func errDetails(err error) []*connect.ErrorDetail {
320+ var ce *connect.Error
321+ if connect.CodeOf(err) == connect.CodeUnknown {
322+ return nil
323+ }
324+ if e, ok := err.(*connect.Error); ok {
325+ ce = e
326+ }
327+ if ce == nil {
328+ return nil
329+ }
330+ return ce.Details()
331+}
332+
333+// The pages: the Annotations box of the run, the annotations at their step on
334+// the job page, the summaries rendered under the run, with nothing of the
335+// workflow's markup reaching the page as markup.
336+func TestWebAnnotationsAndSummaries(t *testing.T) {
337+ ci := newCIHarness(t, nil)
338+ b := browser{ci.harness}
339+ runner := ci.registerRunner("alpha", "ubuntu-latest")
340+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
341+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
342+ }))
343+ require.NoError(t, err)
344+ sha := ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0], "src/main.go": "package main\n"}, "")
345+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
346+
347+ job := runner.await()
348+ runner.heartbeat(job)
349+ start := now()
350+ steps := []*pb.StepUpdate{
351+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
352+ {Number: 2, Name: "Run <b>vet</b>", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
353+ }
354+ require.NoError(t, runner.update(job, steps))
355+ _, err = runner.jobAPI(job).runner.AppendLog(ci.ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: job.GetJobId(), Step: 2, Lines: []*pb.LogLine{{Time: now(), Text: "vet"}}}))
356+ require.NoError(t, err)
357+ evil := `<img src=x onerror=alert(1)>`
358+ require.NoError(t, runner.update(job, nil,
359+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet <i>failed</i>", Message: "unused " + evil, File: "src/main.go", StartLine: 1, EndLine: 3, StepNumber: 2},
360+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "outside", File: "../../etc/passwd", StartLine: 1, StepNumber: 2},
361+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "plain notice", StepNumber: 1},
362+ ))
363+ require.NoError(t, runner.summary(job, 2, strings.Join([]string{
364+ "# Results",
365+ "",
366+ "<script>alert('summary')</script>",
367+ "",
368+ "[click](javascript:alert(1)) and [ok](https://example.org/x)",
369+ "",
370+ "![pixel](https://evil.example/p.png)",
371+ "",
372+ "<img src=x onerror=alert(2)>",
373+ "",
374+ "| a | b |", "|---|---|", "| 1 | 2 |",
375+ }, "\n")))
376+ _, err = runner.jobAPI(job).runner.CompleteJob(ci.ctx, connect.NewRequest(&pb.CompleteJobRequest{
377+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
378+ }))
379+ require.NoError(t, err)
380+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
381+
382+ run := b.get("/proj/actions/runs/1", nil)
383+ require.Equal(t, 200, run.status, run.body)
384+ assert.Contains(t, run.body, "Annotations")
385+ assert.Contains(t, run.body, `aria-label="error"`, "the level has a symbol and a word")
386+ assert.Contains(t, run.body, `aria-label="warning"`)
387+ assert.Contains(t, run.body, "Vet &lt;i&gt;failed&lt;/i&gt;", "a title is text")
388+ assert.Contains(t, run.body, "unused &lt;img src=x onerror=alert(1)&gt;")
389+ assert.NotContains(t, run.body, evil)
390+ assert.Contains(t, run.body, "Run &lt;b&gt;vet&lt;/b&gt;", "the step name is text")
391+ assert.Contains(t, run.body, `href="/proj/blob/`+sha+`/src/main.go#L1-L3"`, "file:line links to the file at the commit")
392+ assert.Contains(t, run.body, "src/main.go:1-3")
393+ assert.Contains(t, run.body, "../../etc/passwd:1")
394+ assert.NotContains(t, run.body, `href="/proj/blob/`+sha+`/../`, "a path that leaves the repository is not a link")
395+ assert.NotContains(t, run.body, `etc/passwd:1</a>`)
396+ assert.Contains(t, run.body, `href="/proj/actions/runs/1/job/`+job.GetJobId()+`"`, "the job of the annotation")
397+
398+ // The summary is rendered by the README renderer: markup dropped.
399+ assert.Contains(t, run.body, "<h1", "Markdown is rendered")
400+ assert.Contains(t, run.body, "Results")
401+ assert.Contains(t, run.body, "<table>")
402+ assert.Contains(t, run.body, `href="https://example.org/x"`)
403+ assert.Contains(t, run.body, `rel="nofollow noopener noreferrer"`)
404+ assert.NotContains(t, run.body, "alert('summary')")
405+ assert.NotContains(t, run.body, "<script>alert")
406+ assert.NotContains(t, run.body, "javascript:")
407+ assert.NotContains(t, run.body, "onerror=alert(2)")
408+ assert.NotContains(t, run.body, `<img src=x`)
409+ assert.NotContains(t, run.body, "evil.example/p.png", "images are never loaded")
410+ assert.Contains(t, run.body, `<span class="alt">[pixel]</span>`)
411+ assert.Contains(t, run.body, `click`, "the text of a dropped link stays")
412+
413+ // The job page: each annotation at its step, the steps linking to the log.
414+ jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
415+ require.Equal(t, 200, jobPage.status, jobPage.body)
416+ assert.Contains(t, jobPage.body, `id="job-annotations"`)
417+ assert.Contains(t, jobPage.body, "plain notice")
418+ assert.Contains(t, jobPage.body, "unused &lt;img src=x onerror=alert(1)&gt;")
419+ assert.NotContains(t, jobPage.body, evil)
420+ assert.Less(t, strings.Index(jobPage.body, "plain notice"), strings.Index(jobPage.body, "unused &lt;img"), "by step")
421+
422+}
423+
424+// The job page follows the annotations live: a report of the runner replaces the
425+// annotations element, and the stream still never carries the log.
426+func TestWebJobAnnotationsAreLive(t *testing.T) {
427+ ci := newCIHarness(t, nil)
428+ runner := ci.registerRunner("alpha", "ubuntu-latest")
429+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
430+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
431+ }))
432+ require.NoError(t, err)
433+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0]}, "")
434+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
435+ job := runner.await()
436+ stream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
437+ runner.heartbeat(job)
438+ require.NoError(t, runner.update(job, []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}},
439+ ann(pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, 1, "live <b>warning</b>")))
440+ deadline := time.After(10 * time.Second)
441+ for {
442+ select {
443+ case ev := <-stream.events:
444+ if ev.name == "ci-job" && strings.Contains(ev.data, `id="job-annotations"`) && strings.Contains(ev.data, "live &lt;b&gt;warning&lt;/b&gt;") {
445+ assert.NotContains(t, ev.data, "<b>warning</b>")
446+ return
447+ }
448+ case <-deadline:
449+ t.Fatal("the annotation did not reach the stream of the job")
450+ }
451+ }
452+}
453+
454+// A run that is not over has no summary block yet, and a run without result has
455+// neither box.
456+func TestWebRunWithoutAnnotationsOrSummaries(t *testing.T) {
457+ ci := newCIHarness(t, nil)
458+ b := browser{ci.harness}
459+ runner := ci.registerRunner("alpha", "ubuntu-latest")
460+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
461+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
462+ }))
463+ require.NoError(t, err)
464+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0]}, "")
465+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
466+ queued := b.get("/proj/actions/runs/1", nil)
467+ require.Equal(t, 200, queued.status)
468+ assert.NotContains(t, queued.body, "Annotations")
469+
470+ job := runner.await()
471+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello")
472+ done := b.get("/proj/actions/runs/1", nil)
473+ require.Equal(t, 200, done.status)
474+ assert.NotContains(t, done.body, "Annotations")
475+ assert.NotContains(t, done.body, `class="summaries"`)
476+ page := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
477+ require.Equal(t, 200, page.status)
478+ assert.Contains(t, page.body, `id="job-annotations"`, "the slot the live updates fill")
479+ assert.NotContains(t, page.body, "Annotations <")
480+}
481+
482+// `run view` prints the ANNOTATIONS section and `--summary` the Markdown.
483+func TestCLIRunViewAnnotationsAndSummary(t *testing.T) {
484+ ci := newCIHarness(t, nil)
485+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
486+ runner := ci.registerRunner("alpha", "ubuntu-latest")
487+ ci.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
488+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
489+
490+ // A run without annotation prints none.
491+ a, b := runner.await(), runner.await()
492+ runner.heartbeat(a)
493+ runner.heartbeat(b)
494+ plain := h.cli(t, adminToken, "run", "view", "proj", "1")
495+ require.NoError(t, plain.err, plain.stderr)
496+ assert.NotContains(t, plain.stdout, "ANNOTATIONS")
497+ none := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
498+ require.NoError(t, none.err, none.stderr)
499+ assert.Contains(t, none.stderr, "no job summary")
500+
501+ start := now()
502+ steps := []*pb.StepUpdate{
503+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
504+ {Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now()},
505+ }
506+ require.NoError(t, runner.update(a, steps,
507+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet", Message: "unused\x1b[31m variable", File: "main.go", StartLine: 3, EndLine: 5, StepNumber: 2},
508+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "careful", StepNumber: 2},
509+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "fyi", File: "README.md", StepNumber: 1},
510+ ))
511+ require.NoError(t, runner.summary(a, 2, "# Hello\n\nfrom a\x1b[2J step\n"))
512+ require.NoError(t, runner.summary(b, 2, "from b"))
513+
514+ view := h.cli(t, adminToken, "run", "view", "proj", "1")
515+ require.NoError(t, view.err, view.stderr)
516+ assert.Contains(t, view.stdout, "\nANNOTATIONS\n")
517+ assert.Contains(t, view.stdout, "LEVEL")
518+ for _, want := range []string{"error", "Vet: unused", "main.go:3-5", "warning", "careful", "notice", "fyi", "README.md", "build"} {
519+ assert.Contains(t, view.stdout, want)
520+ }
521+ assert.NotContains(t, view.stdout, "\x1b", "control characters of the workflow never reach the terminal")
522+ assert.Less(t, strings.Index(view.stdout, "Set up job"), strings.Index(view.stdout, "ANNOTATIONS"), "after the jobs")
523+ // Ordered by job, step, production order: notice (step 1) before the two of step 2.
524+ assert.Less(t, strings.Index(view.stdout, "fyi"), strings.Index(view.stdout, "Vet: unused"))
525+ assert.Less(t, strings.Index(view.stdout, "Vet: unused"), strings.Index(view.stdout, "careful"))
526+
527+ js := h.cli(t, adminToken, "run", "view", "proj", "1", "--json")
528+ require.NoError(t, js.err, js.stderr)
529+ d := decode[jsonRunDetail](t, js.stdout)
530+ require.Len(t, d.Annotations, 3)
531+ assert.Equal(t, jsonAnnotation{
532+ Job: "build", JobID: a.GetJobId(), Step: 1, Level: "notice", Message: "fyi", File: "README.md",
533+ }, d.Annotations[0])
534+ assert.Equal(t, "failure", d.Annotations[1].Level, "the API's name in JSON")
535+ assert.EqualValues(t, 5, d.Annotations[1].EndLine)
536+ assert.False(t, d.AnnotationsTruncated)
537+ assert.Equal(t, "failure", d.Jobs[0].Steps[1].Outcome)
538+ assert.Equal(t, "success", d.Jobs[0].Steps[1].Conclusion)
539+ assert.Equal(t, "success", d.Jobs[0].Steps[0].Outcome, "none stored: the conclusion")
540+
541+ // --summary: raw Markdown, a header per job and step when there are several.
542+ sum := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
543+ require.NoError(t, sum.err, sum.stderr)
544+ assert.Contains(t, sum.stdout, "== build: Run make ==\n# Hello\n")
545+ assert.Contains(t, sum.stdout, "== lint: step 2 ==\nfrom b\n")
546+ assert.NotContains(t, sum.stdout, "\x1b")
547+ one := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "lint")
548+ require.NoError(t, one.err, one.stderr)
549+ assert.Equal(t, "from b\n", one.stdout, "a single summary is printed as it is")
550+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--log").err)
551+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--json").err)
552+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "nope").err)
553+}
M internal/app/cli_e2e_test.go +1 -1
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, ver.Server.Features)
329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}, ver.Server.Features)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
M internal/app/cli_run.go +238 -15
@@ -40,10 +40,13 @@ type jsonRun struct {
4040 }
4141
4242 type jsonStep struct {
43- Number int32 `json:"number"`
44- Name string `json:"name"`
45- State string `json:"state"`
46- Conclusion string `json:"conclusion"`
43+ Number int32 `json:"number"`
44+ Name string `json:"name"`
45+ State string `json:"state"`
46+ Conclusion string `json:"conclusion"`
47+ // Outcome is the result before continue-on-error; for a step stored without
48+ // one it is the conclusion.
49+ Outcome string `json:"outcome"`
4750 StartedAt *string `json:"started_at"`
4851 CompletedAt *string `json:"completed_at"`
4952 LogStart int64 `json:"log_start"`
@@ -67,10 +70,28 @@ type jsonRunJob struct {
6770 CompletedAt *string `json:"completed_at"`
6871 }
6972
73+// jsonAnnotation is an annotation of a run, with the job and the step it belongs to.
74+type jsonAnnotation struct {
75+ Job string `json:"job"`
76+ JobID string `json:"job_id"`
77+ Step int32 `json:"step"`
78+ Level string `json:"level"`
79+ Title string `json:"title"`
80+ Message string `json:"message"`
81+ File string `json:"file"`
82+ StartLine int32 `json:"start_line"`
83+ EndLine int32 `json:"end_line"`
84+ StartColumn int32 `json:"start_column"`
85+ EndColumn int32 `json:"end_column"`
86+}
87+
7088 type jsonRunDetail struct {
7189 jsonRun
72- LatestAttempt int32 `json:"latest_attempt"`
73- Jobs []jsonRunJob `json:"jobs"`
90+ LatestAttempt int32 `json:"latest_attempt"`
91+ Jobs []jsonRunJob `json:"jobs"`
92+ Annotations []jsonAnnotation `json:"annotations"`
93+ // AnnotationsTruncated is true when the server holds more annotations than it returned.
94+ AnnotationsTruncated bool `json:"annotations_truncated"`
7495 }
7596
7697 const jsonRunHelp = `
@@ -79,7 +100,9 @@ ref, sha, actor, status (queued, in_progress, completed), conclusion (success, f
79100 skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
80101 started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
81102 jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
82-with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
103+with number, name, state, conclusion, outcome (the result before continue-on-error), log_start and log_end,
104+started_at, completed_at), annotations (job, job_id, step, level notice|warning|failure, title, message,
105+file, start_line, end_line, start_column, end_column) and annotations_truncated.`
83106
84107 func statusText(s ermite.CiStatus) string {
85108 return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
@@ -111,6 +134,15 @@ func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
111134 return statusText(status)
112135 }
113136
137+// stepOutcomeText is the outcome of a step; a completed step stored without one
138+// has the outcome of its conclusion (Step.outcome).
139+func stepOutcomeText(s *ermite.Step) string {
140+ if s.GetOutcome() == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED && s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
141+ return conclusionText(s.GetConclusion())
142+ }
143+ return conclusionText(s.GetOutcome())
144+}
145+
114146 func toJSONRun(r *ermite.Run) jsonRun {
115147 return jsonRun{
116148 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
@@ -125,7 +157,7 @@ func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
125157 for _, s := range j.GetSteps() {
126158 steps = append(steps, jsonStep{
127159 Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
128- StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
160+ Outcome: stepOutcomeText(s), StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
129161 })
130162 }
131163 labels, needs := j.GetLabels(), j.GetNeeds()
@@ -254,21 +286,27 @@ func parseStatusFlag(s string) (ermite.CiStatus, error) {
254286
255287 func newRunViewCmd(env *cliEnv) *cobra.Command {
256288 var attempt int32
257- var logFlag bool
289+ var logFlag, summaryFlag bool
258290 var jobFlag string
259291 cmd := &cobra.Command{
260292 Use: "view <repo> <run id>",
261- Short: "Show a run with its jobs and steps, or the log of a job",
262- Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
293+ Short: "Show a run with its jobs, steps and annotations, or the log or summaries of its jobs",
294+ Long: "Show a run: its state, its jobs and their steps, then its ANNOTATIONS (the notices, warnings and\n" +
295+ "errors that the steps produced, with their job and file:line). --attempt reads an earlier attempt.\n" +
263296 "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
264- "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
297+ "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing.\n" +
298+ "With --summary, print the job summaries (the Markdown the steps wrote to GITHUB_STEP_SUMMARY) of\n" +
299+ "the run's jobs, or of the --job, as Markdown, not rendered." + jsonRunHelp,
265300 Args: cobra.ExactArgs(2),
266301 ValidArgsFunction: completeRepoNames(env),
267302 }
268303 cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
269304 cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
270- cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
305+ cmd.Flags().BoolVar(&summaryFlag, "summary", false, "print the job summaries (Markdown) instead of the summary of the run")
306+ cmd.Flags().StringVar(&jobFlag, "job", "", "with --log or --summary, only this job (its id or its name)")
271307 asJSON := addJSONFlag(cmd)
308+ cmd.MarkFlagsMutuallyExclusive("log", "summary")
309+ cmd.MarkFlagsMutuallyExclusive("json", "summary")
272310 cmd.RunE = func(cmd *cobra.Command, args []string) error {
273311 id, err := parseRunID(args[1])
274312 if err != nil {
@@ -286,14 +324,30 @@ func newRunViewCmd(env *cliEnv) *cobra.Command {
286324 if logFlag {
287325 return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
288326 }
327+ if summaryFlag {
328+ return printRunSummaries(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), c, args[0], res.Msg, jobFlag)
329+ }
330+ anns, err := runAnnotations(ctx, c, args[0], res.Msg)
331+ if err != nil {
332+ return err
333+ }
289334 if *asJSON {
290- d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
335+ d := jsonRunDetail{
336+ jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{},
337+ Annotations: []jsonAnnotation{}, AnnotationsTruncated: anns.truncated,
338+ }
291339 for _, j := range res.Msg.GetJobs() {
292340 d.Jobs = append(d.Jobs, toJSONRunJob(j))
293341 }
342+ for _, a := range anns.items {
343+ d.Annotations = append(d.Annotations, toJSONAnnotation(a, anns.jobs))
344+ }
294345 return writeJSON(cmd.OutOrStdout(), d)
295346 }
296- return printRun(cmd.OutOrStdout(), res.Msg)
347+ if err := printRun(cmd.OutOrStdout(), res.Msg); err != nil {
348+ return err
349+ }
350+ return printAnnotations(cmd.OutOrStdout(), anns)
297351 }
298352 return cmd
299353 }
@@ -340,6 +394,175 @@ func printRun(w io.Writer, r *ermite.GetRunResponse) error {
340394 return nil
341395 }
342396
397+// runNotes are the annotations of a run as read from the server.
398+type runNotes struct {
399+ items []*ermite.JobAnnotation
400+ truncated bool
401+ // jobs maps a job id to the job.
402+ jobs map[string]*ermite.RunJob
403+}
404+
405+// runAnnotations reads the annotations of the attempt of a run that was read.
406+// A server without the feature has none: it answers unimplemented, which is
407+// not an error here.
408+func runAnnotations(ctx context.Context, c *client.Client, repo string, r *ermite.GetRunResponse) (runNotes, error) {
409+ out := runNotes{jobs: map[string]*ermite.RunJob{}}
410+ for _, j := range r.GetJobs() {
411+ out.jobs[j.GetId()] = j
412+ }
413+ res, err := c.Runs().ListAnnotations(ctx, connect.NewRequest(&ermite.ListAnnotationsRequest{
414+ Repo: repo, RunId: r.GetRun().GetId(), Attempt: r.GetRun().GetAttempt(),
415+ }))
416+ if connect.CodeOf(err) == connect.CodeUnimplemented {
417+ return out, nil
418+ }
419+ if err != nil {
420+ return runNotes{}, err
421+ }
422+ out.items, out.truncated = res.Msg.GetAnnotations(), res.Msg.GetTruncated()
423+ return out, nil
424+}
425+
426+func annotationLevelText(l ermite.AnnotationLevel) string {
427+ switch l {
428+ case ermite.AnnotationLevel_ANNOTATION_LEVEL_NOTICE:
429+ return "notice"
430+ case ermite.AnnotationLevel_ANNOTATION_LEVEL_WARNING:
431+ return "warning"
432+ case ermite.AnnotationLevel_ANNOTATION_LEVEL_FAILURE:
433+ return "error"
434+ }
435+ return ""
436+}
437+
438+// annotationPlace is "file:line" (or "file:line-endline"), "" without a file.
439+func annotationPlace(a *ermite.Annotation) string {
440+ if a.GetFile() == "" {
441+ return ""
442+ }
443+ p := a.GetFile()
444+ if a.GetStartLine() > 0 {
445+ p += ":" + strconv.Itoa(int(a.GetStartLine()))
446+ if a.GetEndLine() > a.GetStartLine() {
447+ p += "-" + strconv.Itoa(int(a.GetEndLine()))
448+ }
449+ }
450+ return p
451+}
452+
453+func toJSONAnnotation(ja *ermite.JobAnnotation, jobs map[string]*ermite.RunJob) jsonAnnotation {
454+ a := ja.GetAnnotation()
455+ level := annotationLevelText(a.GetLevel())
456+ if level == "error" {
457+ level = "failure" // the API's name, as GitHub's annotation_level
458+ }
459+ return jsonAnnotation{
460+ Job: jobs[ja.GetJobId()].GetName(), JobID: ja.GetJobId(), Step: a.GetStepNumber(), Level: level, Title: a.GetTitle(),
461+ Message: a.GetMessage(), File: a.GetFile(), StartLine: a.GetStartLine(), EndLine: a.GetEndLine(),
462+ StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
463+ }
464+}
465+
466+// printAnnotations prints the ANNOTATIONS section of `run view`, like `gh run
467+// view` does: one line per annotation with its level, its job, its place and
468+// its message. Nothing is printed for a run without annotation.
469+func printAnnotations(w io.Writer, n runNotes) error {
470+ if len(n.items) == 0 {
471+ return nil
472+ }
473+ _, _ = fmt.Fprint(w, "\nANNOTATIONS\n")
474+ rows := make([][]string, 0, len(n.items))
475+ for _, ja := range n.items {
476+ a := ja.GetAnnotation()
477+ msg := a.GetMessage()
478+ if a.GetTitle() != "" {
479+ msg = a.GetTitle() + ": " + msg
480+ }
481+ rows = append(rows, []string{annotationLevelText(a.GetLevel()), n.jobs[ja.GetJobId()].GetName(), annotationPlace(a), msg})
482+ }
483+ if err := table(w, []string{"LEVEL", "JOB", "FILE", "MESSAGE"}, rows); err != nil {
484+ return err
485+ }
486+ if n.truncated {
487+ _, _ = fmt.Fprintf(w, "(more annotations exist than the %d shown)\n", len(n.items))
488+ }
489+ return nil
490+}
491+
492+// printRunSummaries prints the job summaries of the chosen jobs as the Markdown
493+// the steps wrote to w; notes go to errw. With several of them, each is preceded
494+// by a line naming its job and step. Control characters are replaced, as everywhere the CLI prints
495+// text it did not write (a tab becomes a space); line breaks are kept.
496+func printRunSummaries(ctx context.Context, w, errw io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string) error {
497+ jobs, err := pickJobs(r.GetJobs(), sel)
498+ if err != nil {
499+ return err
500+ }
501+ type found struct {
502+ job *ermite.RunJob
503+ sum *ermite.StepSummary
504+ }
505+ var all []found
506+ truncated := false
507+ for _, j := range jobs {
508+ if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED {
509+ continue
510+ }
511+ res, err := c.Runs().GetStepSummaries(ctx, connect.NewRequest(&ermite.GetStepSummariesRequest{Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt()}))
512+ if connect.CodeOf(err) == connect.CodeUnimplemented {
513+ return fmt.Errorf("the server does not keep job summaries (it lacks the ci.summaries feature)")
514+ }
515+ if err != nil {
516+ return err
517+ }
518+ for _, s := range res.Msg.GetSummaries() {
519+ all = append(all, found{j, s})
520+ }
521+ truncated = truncated || res.Msg.GetTruncated()
522+ }
523+ if len(all) == 0 {
524+ noticef2(errw, "the run has no job summary")
525+ return nil
526+ }
527+ for i, f := range all {
528+ if len(all) > 1 {
529+ if i > 0 {
530+ _, _ = fmt.Fprintln(w)
531+ }
532+ _, _ = fmt.Fprintf(w, "== %s: %s ==\n", clean(f.job.GetName()), clean(stepName(f.job, f.sum.GetStepNumber())))
533+ }
534+ text := rawText(f.sum.GetMarkdown())
535+ _, _ = fmt.Fprint(w, text)
536+ if !strings.HasSuffix(text, "\n") {
537+ _, _ = fmt.Fprintln(w)
538+ }
539+ }
540+ if truncated {
541+ noticef2(errw, "some jobs have more summaries than the 20 the server returns")
542+ }
543+ return nil
544+}
545+
546+// stepName is the name of a step of a job, or "step N" when it is not known.
547+func stepName(j *ermite.RunJob, n int32) string {
548+ for _, s := range j.GetSteps() {
549+ if s.GetNumber() == n && s.GetName() != "" {
550+ return s.GetName()
551+ }
552+ }
553+ return "step " + strconv.Itoa(int(n))
554+}
555+
556+// rawText replaces the control characters of a text that may come from the
557+// workflow (clean), line by line, so that its line breaks stay.
558+func rawText(s string) string {
559+ lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
560+ for i, l := range lines {
561+ lines[i] = clean(l)
562+ }
563+ return strings.Join(lines, "\n")
564+}
565+
343566 // pickJobs selects the jobs --job names (an id or a name), or all of them.
344567 func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
345568 if sel == "" {
M internal/app/forge.go +2 -1
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/zerowrap"
1717
1818 "bnema.dev/ermite/internal/adapters/cilogs"
19+ "bnema.dev/ermite/internal/adapters/cisummaries"
1920 "bnema.dev/ermite/internal/adapters/ermitegit"
2021 "bnema.dev/ermite/internal/adapters/eventbus"
2122 "bnema.dev/ermite/internal/adapters/gitcli"
@@ -212,7 +213,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
212213 // CI (ADR 0025): the engine reads workflows through the reader, and plans
213214 // them in the background; a push only queues its refs.
214215 ciCfg := usecase.CIConfig{
215- Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
216+ Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Summaries: cisummaries.New(cisummaries.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
216217 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
217218 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
218219 PollTimeout: cfg.CIPollTimeout, Archive: git,
M internal/domain/ci.go +8 -4
@@ -198,10 +198,14 @@ type CIRunSource struct {
198198
199199 // CIStep is a step of a job with where its lines are in the job log.
200200 type CIStep struct {
201- Number int32
202- Name string
203- State CIStepState
204- Conclusion CIConclusion
201+ Number int32
202+ Name string
203+ State CIStepState
204+ Conclusion CIConclusion
205+ // Outcome is how the step ended before continue-on-error was applied
206+ // ("steps.<id>.outcome"). Empty for a step that was not completed by a
207+ // runner that reports it: read it as Conclusion.
208+ Outcome CIConclusion
205209 StartedAt time.Time
206210 CompletedAt time.Time
207211 LogStart int64
A internal/domain/ci_annotation.go +112 -0
@@ -0,0 +1,112 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "slices"
8+ "unicode/utf8"
9+)
10+
11+// AnnotationLevel is the severity of an annotation, with the names of GitHub's
12+// check run annotation levels.
13+type AnnotationLevel string
14+
15+// Annotation levels.
16+const (
17+ AnnotationNotice AnnotationLevel = "notice"
18+ AnnotationWarning AnnotationLevel = "warning"
19+ AnnotationFailure AnnotationLevel = "failure"
20+)
21+
22+// Valid reports whether the level is one of the three.
23+func (l AnnotationLevel) Valid() bool {
24+ return l == AnnotationNotice || l == AnnotationWarning || l == AnnotationFailure
25+}
26+
27+// Limits of annotations and summaries (runner.proto, run.proto). Lengths count
28+// characters (code points), not bytes, except the summary size.
29+const (
30+ // CIMaxAnnotationsPerLevel bounds the annotations of one level in one step.
31+ CIMaxAnnotationsPerLevel = 10
32+ // CIMaxAnnotationMessage, CIMaxAnnotationTitle and CIMaxAnnotationFile bound
33+ // the text fields of an annotation, in characters.
34+ CIMaxAnnotationMessage = 4096
35+ CIMaxAnnotationTitle = 255
36+ CIMaxAnnotationFile = 1024
37+ // CIMaxListedAnnotations is the most annotations ListAnnotations returns.
38+ CIMaxListedAnnotations = 1000
39+ // CIMaxSummaryBytes bounds the summary of one step (1 MiB).
40+ CIMaxSummaryBytes = 1 << 20
41+ // CIMaxListedSummaries is the most summaries GetStepSummaries returns for a
42+ // job, as GitHub does.
43+ CIMaxListedSummaries = 20
44+)
45+
46+// CIAnnotation is a message attached to a step, and optionally to a place in a
47+// file, like an annotation of a GitHub check run.
48+type CIAnnotation struct {
49+ Level AnnotationLevel
50+ Title, Message, File string
51+ StartLine, EndLine, StartColumn, EndColumn int32
52+ // Step is the number of the step that produced the annotation.
53+ Step int32
54+}
55+
56+// CIStepSummary is the job summary (Markdown) of one step.
57+type CIStepSummary struct {
58+ Step int32
59+ Markdown string
60+}
61+
62+// CheckAnnotations validates the annotations of a request: the limits of the
63+// contract, whole or not at all.
64+func CheckAnnotations(anns []CIAnnotation) error {
65+ type key struct {
66+ step int32
67+ level AnnotationLevel
68+ }
69+ count := map[key]int{}
70+ for i, a := range anns {
71+ switch {
72+ case !a.Level.Valid():
73+ return fmt.Errorf("%w: annotation %d has no level", ErrInvalid, i)
74+ case a.Step < 1 || a.Step > CIMaxSteps:
75+ return fmt.Errorf("%w: annotation %d: step number %d is not between 1 and %d", ErrInvalid, i, a.Step, CIMaxSteps)
76+ case a.Message == "":
77+ return fmt.Errorf("%w: annotation %d has no message", ErrInvalid, i)
78+ case utf8.RuneCountInString(a.Message) > CIMaxAnnotationMessage:
79+ return fmt.Errorf("%w: annotation %d: the message is longer than %d characters", ErrInvalid, i, CIMaxAnnotationMessage)
80+ case utf8.RuneCountInString(a.Title) > CIMaxAnnotationTitle:
81+ return fmt.Errorf("%w: annotation %d: the title is longer than %d characters", ErrInvalid, i, CIMaxAnnotationTitle)
82+ case utf8.RuneCountInString(a.File) > CIMaxAnnotationFile:
83+ return fmt.Errorf("%w: annotation %d: the file is longer than %d characters", ErrInvalid, i, CIMaxAnnotationFile)
84+ case !utf8.ValidString(a.Message) || !utf8.ValidString(a.Title) || !utf8.ValidString(a.File):
85+ return fmt.Errorf("%w: annotation %d is not valid UTF-8", ErrInvalid, i)
86+ case a.StartLine < 0 || a.EndLine < 0 || a.StartColumn < 0 || a.EndColumn < 0:
87+ return fmt.Errorf("%w: annotation %d: lines and columns are not negative", ErrInvalid, i)
88+ case a.EndLine != 0 && a.EndLine < a.StartLine:
89+ return fmt.Errorf("%w: annotation %d: end_line is before start_line", ErrInvalid, i)
90+ }
91+ k := key{a.Step, a.Level}
92+ if count[k]++; count[k] > CIMaxAnnotationsPerLevel {
93+ return fmt.Errorf("%w: step %d has more than %d annotations of level %s", ErrInvalid, a.Step, CIMaxAnnotationsPerLevel, a.Level)
94+ }
95+ }
96+ return nil
97+}
98+
99+// GroupAnnotations groups annotations by step number, each group in the order
100+// of the input, and returns the step numbers in ascending order.
101+func GroupAnnotations(anns []CIAnnotation) (map[int32][]CIAnnotation, []int32) {
102+ by := map[int32][]CIAnnotation{}
103+ var steps []int32
104+ for _, a := range anns {
105+ if _, ok := by[a.Step]; !ok {
106+ steps = append(steps, a.Step)
107+ }
108+ by[a.Step] = append(by[a.Step], a)
109+ }
110+ slices.Sort(steps)
111+ return by, steps
112+}
A internal/domain/ci_annotation_test.go +74 -0
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func an(level domain.AnnotationLevel, step int32, msg string) domain.CIAnnotation {
16+ return domain.CIAnnotation{Level: level, Step: step, Message: msg}
17+}
18+
19+func TestCheckAnnotationsLimits(t *testing.T) {
20+ t.Parallel()
21+ n, w, f := domain.AnnotationNotice, domain.AnnotationWarning, domain.AnnotationFailure
22+ same := func(level domain.AnnotationLevel, step int32, count int) []domain.CIAnnotation {
23+ var out []domain.CIAnnotation
24+ for range count {
25+ out = append(out, an(level, step, "m"))
26+ }
27+ return out
28+ }
29+ valid := map[string][]domain.CIAnnotation{
30+ "none": nil,
31+ "ten of a level": same(n, 1, 10),
32+ "ten of each level": append(append(same(n, 1, 10), same(w, 1, 10)...), same(f, 1, 10)...),
33+ "ten per level, two steps": append(same(n, 1, 10), same(n, 2, 10)...),
34+ "4096 characters, 8 KiB": {an(n, 1, strings.Repeat("é", 4096))},
35+ "title 255, file 1024": {{Level: n, Step: 1, Message: "m", Title: strings.Repeat("é", 255), File: strings.Repeat("é", 1024)}},
36+ "step 1000": {an(n, 1000, "m")},
37+ "end line equal to start": {{Level: n, Step: 1, Message: "m", StartLine: 3, EndLine: 3}},
38+ "end line without start": {{Level: n, Step: 1, Message: "m", EndLine: 3}},
39+ }
40+ for name, anns := range valid {
41+ assert.NoError(t, domain.CheckAnnotations(anns), name)
42+ }
43+ invalid := map[string][]domain.CIAnnotation{
44+ "eleven of a level": same(w, 4, 11),
45+ "no level": {{Step: 1, Message: "m"}},
46+ "unknown level": {{Level: "debug", Step: 1, Message: "m"}},
47+ "no message": {an(n, 1, "")},
48+ "message 4097": {an(n, 1, strings.Repeat("é", 4097))},
49+ "title 256": {{Level: n, Step: 1, Message: "m", Title: strings.Repeat("t", 256)}},
50+ "file 1025": {{Level: n, Step: 1, Message: "m", File: strings.Repeat("f", 1025)}},
51+ "step 0": {an(n, 0, "m")},
52+ "step 1001": {an(n, 1001, "m")},
53+ "negative line": {{Level: n, Step: 1, Message: "m", StartLine: -1}},
54+ "negative column": {{Level: n, Step: 1, Message: "m", EndColumn: -1}},
55+ "end before start": {{Level: n, Step: 1, Message: "m", StartLine: 4, EndLine: 3}},
56+ "invalid utf-8": {an(n, 1, "a\xffb")},
57+ "one bad among good": append(same(n, 1, 3), an(n, 1, "")),
58+ }
59+ for name, anns := range invalid {
60+ err := domain.CheckAnnotations(anns)
61+ require.Error(t, err, name)
62+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
63+ }
64+}
65+
66+func TestGroupAnnotationsKeepsTheOrderOfAStep(t *testing.T) {
67+ t.Parallel()
68+ by, steps := domain.GroupAnnotations([]domain.CIAnnotation{
69+ an(domain.AnnotationNotice, 10, "ten-a"), an(domain.AnnotationNotice, 2, "two-a"), an(domain.AnnotationFailure, 10, "ten-b"),
70+ })
71+ assert.Equal(t, []int32{2, 10}, steps)
72+ assert.Equal(t, "ten-a", by[10][0].Message)
73+ assert.Equal(t, "ten-b", by[10][1].Message)
74+}
M internal/ports/ci.go +93 -14
@@ -30,13 +30,30 @@ type CIRunDraft struct {
3030 // exist yet), RevokeJobs the jobs whose repository tokens are deleted by it,
3131 // and Resolutions the action resolutions of a run attempt to keep. They are
3232 // written under the lock of the run that owns them.
33+//
34+// Annotations replace, for each (execution of a job, step) they name, all the
35+// annotations stored for it (none deletes them); ClearAnnotations deletes all
36+// the annotations of the executions of jobs they name, steps included. Both
37+// are written with the batch, so a call is applied whole or not at all.
3338 type CIWrite struct {
34- Runs []domain.CIRun
35- Jobs []domain.CIJob
36- Checks []domain.CICheck
37- RepoTokens []domain.CIRepoToken
38- RevokeJobs []CIJobRef
39- Resolutions []domain.CIActionResolution
39+ Runs []domain.CIRun
40+ Jobs []domain.CIJob
41+ Checks []domain.CICheck
42+ RepoTokens []domain.CIRepoToken
43+ RevokeJobs []CIJobRef
44+ Resolutions []domain.CIActionResolution
45+ Annotations []CIStepAnnotations
46+ ClearAnnotations []CILogRef
47+}
48+
49+// CIStepAnnotations are all the annotations of one step of one execution of a
50+// job, in the order the step produced them.
51+type CIStepAnnotations struct {
52+ Repo domain.RepoName
53+ Exec CILogRef
54+ Step int32
55+ // Items are the annotations; none removes those of the step.
56+ Items []domain.CIAnnotation
4057 }
4158
4259 // CIJobRef names one execution of a job.
@@ -84,6 +101,10 @@ type CIStore interface {
84101 RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
85102 // ListOpenRuns returns the runs that have not completed.
86103 ListOpenRuns(ctx context.Context) ([]domain.CIRun, error)
104+ // JobAnnotations returns the annotations of one execution of a job
105+ // (CIJob.Attempt), ordered by step then by the order the step produced
106+ // them. A job without annotation gives an empty list.
107+ JobAnnotations(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error)
87108 // GetJob returns a job of a run at an attempt of the run, or
88109 // domain.ErrNotFound.
89110 GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error)
@@ -122,8 +143,8 @@ type CIStore interface {
122143 }
123144
124145 // CIRunCleaner is what repository deletion needs from CI: the runs of the
125-// repository before its records go, and the removal of their logs after.
126-// *usecase.CI implements it.
146+// repository before its records go, and the removal of their logs and
147+// summaries after. *usecase.CI implements it.
127148 type CIRunCleaner interface {
128149 RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
129150 RemoveLogs(ctx context.Context, runIDs []int64) error
@@ -138,7 +159,8 @@ type CIChecksReader interface {
138159 CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)
139160 }
140161
141-// CILogRef names the log of one execution of a job.
162+// CILogRef names one execution of a job: its log, its summaries and its
163+// annotations are kept under it.
142164 type CILogRef struct {
143165 RunID int64
144166 JobID string
@@ -188,6 +210,25 @@ type CILogStore interface {
188210 RunIDs(ctx context.Context) ([]int64, error)
189211 }
190212
213+// CISummaryStore keeps the job summaries (the Markdown a step wrote to
214+// GITHUB_STEP_SUMMARY): one file per step of an execution of a job, never in
215+// the metadata store.
216+type CISummaryStore interface {
217+ // Put stores the summary of a step, replacing the one it has; the file
218+ // appears whole or not at all. An empty markdown removes the summary.
219+ Put(ctx context.Context, ref CILogRef, step int32, markdown string) error
220+ // List returns the summaries of the steps that have one, by step number,
221+ // at most limit; more is true when there are others.
222+ List(ctx context.Context, ref CILogRef, limit int) (summaries []domain.CIStepSummary, more bool, err error)
223+ // RemoveJob deletes the summaries of one execution of a job.
224+ RemoveJob(ctx context.Context, ref CILogRef) error
225+ // RemoveRuns deletes the summaries of the runs.
226+ RemoveRuns(ctx context.Context, runIDs []int64) error
227+ // RunIDs lists the runs that have a summary directory, for the orphan
228+ // sweep.
229+ RunIDs(ctx context.Context) ([]int64, error)
230+}
231+
191232 // WorkflowTrigger starts the workflows that match pushed refs. OnPush only
192233 // queues the work and returns: planning happens in the background and its
193234 // errors never reach the push.
@@ -258,10 +299,13 @@ type FetchJobResult struct {
258299
259300 // StepUpdate is the state of one step as a runner reports it.
260301 type StepUpdate struct {
261- Number int32
262- Name string
263- State domain.CIStepState
264- Conclusion domain.CIConclusion
302+ Number int32
303+ Name string
304+ State domain.CIStepState
305+ Conclusion domain.CIConclusion
306+ // Outcome is how the step ended before continue-on-error; none when the
307+ // runner does not report it.
308+ Outcome domain.CIConclusion
265309 StartedAt time.Time
266310 CompletedAt time.Time
267311 }
@@ -273,6 +317,8 @@ type CompleteJobParams struct {
273317 Outputs map[string]string
274318 RunnerShutdown bool
275319 Steps []StepUpdate
320+ // Annotations replace those of each step they name, as in UpdateJob.
321+ Annotations []domain.CIAnnotation
276322 }
277323
278324 // CompleteJobResult is the stored result of a job.
@@ -337,7 +383,10 @@ type JobCancel struct {
337383 type RunnerService interface {
338384 Register(ctx context.Context, p domain.Principal, params RegisterRunnerParams) (RegisteredRunner, error)
339385 FetchJob(ctx context.Context, secret string, params FetchJobParams) (FetchJobResult, error)
340- UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error
386+ // UpdateJob applies the steps and the annotations whole or not at all: for
387+ // each step number present in annotations, the annotations of that step are
388+ // replaced (also for a step already completed, or not in steps).
389+ UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate, annotations []domain.CIAnnotation) error
341390 AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error)
342391 Heartbeat(ctx context.Context, secret, jobID string) error
343392 // WatchJob calls ready once the job token is valid and the stream is
@@ -350,6 +399,10 @@ type RunnerService interface {
350399 // an error: its answer carries Error. A local ("./") or Docker
351400 // ("docker://") reference, or more than 100, is domain.ErrInvalid.
352401 ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error)
402+ // UploadSummary stores the summary of a step, replacing the one it has; an
403+ // empty markdown removes it. More than 1 MiB, invalid UTF-8 or a step
404+ // number outside 1 to 1,000 is domain.ErrInvalid.
405+ UploadSummary(ctx context.Context, secret, jobID string, step int32, markdown string) error
353406 }
354407
355408 // JobView is a job as owners see it.
@@ -445,6 +498,25 @@ type RunnerView struct {
445498 Busy bool
446499 }
447500
501+// JobAnnotation is an annotation with the job it belongs to.
502+type JobAnnotation struct {
503+ JobID string
504+ Annotation domain.CIAnnotation
505+}
506+
507+// AnnotationList are the annotations of a run, at most
508+// domain.CIMaxListedAnnotations.
509+type AnnotationList struct {
510+ Annotations []JobAnnotation
511+ Truncated bool
512+}
513+
514+// SummaryList are the summaries of a job, at most domain.CIMaxListedSummaries.
515+type SummaryList struct {
516+ Summaries []domain.CIStepSummary
517+ Truncated bool
518+}
519+
448520 // RunService is the owner side of CI. *usecase.CI implements it.
449521 type RunService interface {
450522 ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRunsParams) (RunPage, error)
@@ -454,6 +526,13 @@ type RunService interface {
454526 CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error)
455527 RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error)
456528 ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)
529+ // ListAnnotations returns the annotations of a run attempt (0 for the
530+ // latest), of one job when jobID is not empty. Each job gives those of the
531+ // attempt in which it executed.
532+ ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error)
533+ // GetStepSummaries returns the summaries of a job at an attempt (0 for the
534+ // latest), by step number.
535+ GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (SummaryList, error)
457536 ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)
458537 RemoveRunner(ctx context.Context, p domain.Principal, id string) error
459538 }
M internal/ports/mocks/CIStore.go +80 -0
@@ -765,6 +765,86 @@ func (_c *CIStore_GetRunner_Call) RunAndReturn(run func(ctx context.Context, id
765765 return _c
766766 }
767767
768+// JobAnnotations provides a mock function for the type CIStore
769+func (_mock *CIStore) JobAnnotations(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error) {
770+ ret := _mock.Called(ctx, runID, attempt, jobID)
771+
772+ if len(ret) == 0 {
773+ panic("no return value specified for JobAnnotations")
774+ }
775+
776+ var r0 []domain.CIAnnotation
777+ var r1 error
778+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) ([]domain.CIAnnotation, error)); ok {
779+ return returnFunc(ctx, runID, attempt, jobID)
780+ }
781+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) []domain.CIAnnotation); ok {
782+ r0 = returnFunc(ctx, runID, attempt, jobID)
783+ } else {
784+ if ret.Get(0) != nil {
785+ r0 = ret.Get(0).([]domain.CIAnnotation)
786+ }
787+ }
788+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok {
789+ r1 = returnFunc(ctx, runID, attempt, jobID)
790+ } else {
791+ r1 = ret.Error(1)
792+ }
793+ return r0, r1
794+}
795+
796+// CIStore_JobAnnotations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobAnnotations'
797+type CIStore_JobAnnotations_Call struct {
798+ *mock.Call
799+}
800+
801+// JobAnnotations is a helper method to define mock.On call
802+// - ctx context.Context
803+// - runID int64
804+// - attempt int32
805+// - jobID string
806+func (_e *CIStore_Expecter) JobAnnotations(ctx any, runID any, attempt any, jobID any) *CIStore_JobAnnotations_Call {
807+ return &CIStore_JobAnnotations_Call{Call: _e.mock.On("JobAnnotations", ctx, runID, attempt, jobID)}
808+}
809+
810+func (_c *CIStore_JobAnnotations_Call) Run(run func(ctx context.Context, runID int64, attempt int32, jobID string)) *CIStore_JobAnnotations_Call {
811+ _c.Call.Run(func(args mock.Arguments) {
812+ var arg0 context.Context
813+ if args[0] != nil {
814+ arg0 = args[0].(context.Context)
815+ }
816+ var arg1 int64
817+ if args[1] != nil {
818+ arg1 = args[1].(int64)
819+ }
820+ var arg2 int32
821+ if args[2] != nil {
822+ arg2 = args[2].(int32)
823+ }
824+ var arg3 string
825+ if args[3] != nil {
826+ arg3 = args[3].(string)
827+ }
828+ run(
829+ arg0,
830+ arg1,
831+ arg2,
832+ arg3,
833+ )
834+ })
835+ return _c
836+}
837+
838+func (_c *CIStore_JobAnnotations_Call) Return(cIAnnotations []domain.CIAnnotation, err error) *CIStore_JobAnnotations_Call {
839+ _c.Call.Return(cIAnnotations, err)
840+ return _c
841+}
842+
843+func (_c *CIStore_JobAnnotations_Call) RunAndReturn(run func(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error)) *CIStore_JobAnnotations_Call {
844+ _c.Call.Return(run)
845+ return _c
846+}
847+
768848 // ListActiveJobs provides a mock function for the type CIStore
769849 func (_mock *CIStore) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
770850 ret := _mock.Called(ctx)
A internal/ports/mocks/CISummaryStore.go +374 -0
@@ -0,0 +1,374 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewCISummaryStore creates a new instance of CISummaryStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewCISummaryStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *CISummaryStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &CISummaryStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// CISummaryStore is an autogenerated mock type for the CISummaryStore type
39+type CISummaryStore struct {
40+ mock.Mock
41+}
42+
43+type CISummaryStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *CISummaryStore) EXPECT() *CISummaryStore_Expecter {
48+ return &CISummaryStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// List provides a mock function for the type CISummaryStore
52+func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error) {
53+ ret := _mock.Called(ctx, ref, limit)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for List")
57+ }
58+
59+ var r0 []domain.CIStepSummary
60+ var r1 bool
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int) ([]domain.CIStepSummary, bool, error)); ok {
63+ return returnFunc(ctx, ref, limit)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int) []domain.CIStepSummary); ok {
66+ r0 = returnFunc(ctx, ref, limit)
67+ } else {
68+ if ret.Get(0) != nil {
69+ r0 = ret.Get(0).([]domain.CIStepSummary)
70+ }
71+ }
72+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int) bool); ok {
73+ r1 = returnFunc(ctx, ref, limit)
74+ } else {
75+ r1 = ret.Get(1).(bool)
76+ }
77+ if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int) error); ok {
78+ r2 = returnFunc(ctx, ref, limit)
79+ } else {
80+ r2 = ret.Error(2)
81+ }
82+ return r0, r1, r2
83+}
84+
85+// CISummaryStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
86+type CISummaryStore_List_Call struct {
87+ *mock.Call
88+}
89+
90+// List is a helper method to define mock.On call
91+// - ctx context.Context
92+// - ref ports.CILogRef
93+// - limit int
94+func (_e *CISummaryStore_Expecter) List(ctx any, ref any, limit any) *CISummaryStore_List_Call {
95+ return &CISummaryStore_List_Call{Call: _e.mock.On("List", ctx, ref, limit)}
96+}
97+
98+func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int)) *CISummaryStore_List_Call {
99+ _c.Call.Run(func(args mock.Arguments) {
100+ var arg0 context.Context
101+ if args[0] != nil {
102+ arg0 = args[0].(context.Context)
103+ }
104+ var arg1 ports.CILogRef
105+ if args[1] != nil {
106+ arg1 = args[1].(ports.CILogRef)
107+ }
108+ var arg2 int
109+ if args[2] != nil {
110+ arg2 = args[2].(int)
111+ }
112+ run(
113+ arg0,
114+ arg1,
115+ arg2,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, more bool, err error) *CISummaryStore_List_Call {
122+ _c.Call.Return(summaries, more, err)
123+ return _c
124+}
125+
126+func (_c *CISummaryStore_List_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error)) *CISummaryStore_List_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
131+// Put provides a mock function for the type CISummaryStore
132+func (_mock *CISummaryStore) Put(ctx context.Context, ref ports.CILogRef, step int32, markdown string) error {
133+ ret := _mock.Called(ctx, ref, step, markdown)
134+
135+ if len(ret) == 0 {
136+ panic("no return value specified for Put")
137+ }
138+
139+ var r0 error
140+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int32, string) error); ok {
141+ r0 = returnFunc(ctx, ref, step, markdown)
142+ } else {
143+ r0 = ret.Error(0)
144+ }
145+ return r0
146+}
147+
148+// CISummaryStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
149+type CISummaryStore_Put_Call struct {
150+ *mock.Call
151+}
152+
153+// Put is a helper method to define mock.On call
154+// - ctx context.Context
155+// - ref ports.CILogRef
156+// - step int32
157+// - markdown string
158+func (_e *CISummaryStore_Expecter) Put(ctx any, ref any, step any, markdown any) *CISummaryStore_Put_Call {
159+ return &CISummaryStore_Put_Call{Call: _e.mock.On("Put", ctx, ref, step, markdown)}
160+}
161+
162+func (_c *CISummaryStore_Put_Call) Run(run func(ctx context.Context, ref ports.CILogRef, step int32, markdown string)) *CISummaryStore_Put_Call {
163+ _c.Call.Run(func(args mock.Arguments) {
164+ var arg0 context.Context
165+ if args[0] != nil {
166+ arg0 = args[0].(context.Context)
167+ }
168+ var arg1 ports.CILogRef
169+ if args[1] != nil {
170+ arg1 = args[1].(ports.CILogRef)
171+ }
172+ var arg2 int32
173+ if args[2] != nil {
174+ arg2 = args[2].(int32)
175+ }
176+ var arg3 string
177+ if args[3] != nil {
178+ arg3 = args[3].(string)
179+ }
180+ run(
181+ arg0,
182+ arg1,
183+ arg2,
184+ arg3,
185+ )
186+ })
187+ return _c
188+}
189+
190+func (_c *CISummaryStore_Put_Call) Return(err error) *CISummaryStore_Put_Call {
191+ _c.Call.Return(err)
192+ return _c
193+}
194+
195+func (_c *CISummaryStore_Put_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, step int32, markdown string) error) *CISummaryStore_Put_Call {
196+ _c.Call.Return(run)
197+ return _c
198+}
199+
200+// RemoveJob provides a mock function for the type CISummaryStore
201+func (_mock *CISummaryStore) RemoveJob(ctx context.Context, ref ports.CILogRef) error {
202+ ret := _mock.Called(ctx, ref)
203+
204+ if len(ret) == 0 {
205+ panic("no return value specified for RemoveJob")
206+ }
207+
208+ var r0 error
209+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) error); ok {
210+ r0 = returnFunc(ctx, ref)
211+ } else {
212+ r0 = ret.Error(0)
213+ }
214+ return r0
215+}
216+
217+// CISummaryStore_RemoveJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveJob'
218+type CISummaryStore_RemoveJob_Call struct {
219+ *mock.Call
220+}
221+
222+// RemoveJob is a helper method to define mock.On call
223+// - ctx context.Context
224+// - ref ports.CILogRef
225+func (_e *CISummaryStore_Expecter) RemoveJob(ctx any, ref any) *CISummaryStore_RemoveJob_Call {
226+ return &CISummaryStore_RemoveJob_Call{Call: _e.mock.On("RemoveJob", ctx, ref)}
227+}
228+
229+func (_c *CISummaryStore_RemoveJob_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CISummaryStore_RemoveJob_Call {
230+ _c.Call.Run(func(args mock.Arguments) {
231+ var arg0 context.Context
232+ if args[0] != nil {
233+ arg0 = args[0].(context.Context)
234+ }
235+ var arg1 ports.CILogRef
236+ if args[1] != nil {
237+ arg1 = args[1].(ports.CILogRef)
238+ }
239+ run(
240+ arg0,
241+ arg1,
242+ )
243+ })
244+ return _c
245+}
246+
247+func (_c *CISummaryStore_RemoveJob_Call) Return(err error) *CISummaryStore_RemoveJob_Call {
248+ _c.Call.Return(err)
249+ return _c
250+}
251+
252+func (_c *CISummaryStore_RemoveJob_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) error) *CISummaryStore_RemoveJob_Call {
253+ _c.Call.Return(run)
254+ return _c
255+}
256+
257+// RemoveRuns provides a mock function for the type CISummaryStore
258+func (_mock *CISummaryStore) RemoveRuns(ctx context.Context, runIDs []int64) error {
259+ ret := _mock.Called(ctx, runIDs)
260+
261+ if len(ret) == 0 {
262+ panic("no return value specified for RemoveRuns")
263+ }
264+
265+ var r0 error
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok {
267+ r0 = returnFunc(ctx, runIDs)
268+ } else {
269+ r0 = ret.Error(0)
270+ }
271+ return r0
272+}
273+
274+// CISummaryStore_RemoveRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRuns'
275+type CISummaryStore_RemoveRuns_Call struct {
276+ *mock.Call
277+}
278+
279+// RemoveRuns is a helper method to define mock.On call
280+// - ctx context.Context
281+// - runIDs []int64
282+func (_e *CISummaryStore_Expecter) RemoveRuns(ctx any, runIDs any) *CISummaryStore_RemoveRuns_Call {
283+ return &CISummaryStore_RemoveRuns_Call{Call: _e.mock.On("RemoveRuns", ctx, runIDs)}
284+}
285+
286+func (_c *CISummaryStore_RemoveRuns_Call) Run(run func(ctx context.Context, runIDs []int64)) *CISummaryStore_RemoveRuns_Call {
287+ _c.Call.Run(func(args mock.Arguments) {
288+ var arg0 context.Context
289+ if args[0] != nil {
290+ arg0 = args[0].(context.Context)
291+ }
292+ var arg1 []int64
293+ if args[1] != nil {
294+ arg1 = args[1].([]int64)
295+ }
296+ run(
297+ arg0,
298+ arg1,
299+ )
300+ })
301+ return _c
302+}
303+
304+func (_c *CISummaryStore_RemoveRuns_Call) Return(err error) *CISummaryStore_RemoveRuns_Call {
305+ _c.Call.Return(err)
306+ return _c
307+}
308+
309+func (_c *CISummaryStore_RemoveRuns_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CISummaryStore_RemoveRuns_Call {
310+ _c.Call.Return(run)
311+ return _c
312+}
313+
314+// RunIDs provides a mock function for the type CISummaryStore
315+func (_mock *CISummaryStore) RunIDs(ctx context.Context) ([]int64, error) {
316+ ret := _mock.Called(ctx)
317+
318+ if len(ret) == 0 {
319+ panic("no return value specified for RunIDs")
320+ }
321+
322+ var r0 []int64
323+ var r1 error
324+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]int64, error)); ok {
325+ return returnFunc(ctx)
326+ }
327+ if returnFunc, ok := ret.Get(0).(func(context.Context) []int64); ok {
328+ r0 = returnFunc(ctx)
329+ } else {
330+ if ret.Get(0) != nil {
331+ r0 = ret.Get(0).([]int64)
332+ }
333+ }
334+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
335+ r1 = returnFunc(ctx)
336+ } else {
337+ r1 = ret.Error(1)
338+ }
339+ return r0, r1
340+}
341+
342+// CISummaryStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs'
343+type CISummaryStore_RunIDs_Call struct {
344+ *mock.Call
345+}
346+
347+// RunIDs is a helper method to define mock.On call
348+// - ctx context.Context
349+func (_e *CISummaryStore_Expecter) RunIDs(ctx any) *CISummaryStore_RunIDs_Call {
350+ return &CISummaryStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx)}
351+}
352+
353+func (_c *CISummaryStore_RunIDs_Call) Run(run func(ctx context.Context)) *CISummaryStore_RunIDs_Call {
354+ _c.Call.Run(func(args mock.Arguments) {
355+ var arg0 context.Context
356+ if args[0] != nil {
357+ arg0 = args[0].(context.Context)
358+ }
359+ run(
360+ arg0,
361+ )
362+ })
363+ return _c
364+}
365+
366+func (_c *CISummaryStore_RunIDs_Call) Return(int64s []int64, err error) *CISummaryStore_RunIDs_Call {
367+ _c.Call.Return(int64s, err)
368+ return _c
369+}
370+
371+func (_c *CISummaryStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context) ([]int64, error)) *CISummaryStore_RunIDs_Call {
372+ _c.Call.Return(run)
373+ return _c
374+}
M internal/ports/mocks/RunService.go +174 -0
@@ -369,6 +369,180 @@ func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p d
369369 return _c
370370 }
371371
372+// GetStepSummaries provides a mock function for the type RunService
373+func (_mock *RunService) GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error) {
374+ ret := _mock.Called(ctx, p, repo, jobID, attempt)
375+
376+ if len(ret) == 0 {
377+ panic("no return value specified for GetStepSummaries")
378+ }
379+
380+ var r0 ports.SummaryList
381+ var r1 error
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) (ports.SummaryList, error)); ok {
383+ return returnFunc(ctx, p, repo, jobID, attempt)
384+ }
385+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) ports.SummaryList); ok {
386+ r0 = returnFunc(ctx, p, repo, jobID, attempt)
387+ } else {
388+ r0 = ret.Get(0).(ports.SummaryList)
389+ }
390+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string, int32) error); ok {
391+ r1 = returnFunc(ctx, p, repo, jobID, attempt)
392+ } else {
393+ r1 = ret.Error(1)
394+ }
395+ return r0, r1
396+}
397+
398+// RunService_GetStepSummaries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStepSummaries'
399+type RunService_GetStepSummaries_Call struct {
400+ *mock.Call
401+}
402+
403+// GetStepSummaries is a helper method to define mock.On call
404+// - ctx context.Context
405+// - p domain.Principal
406+// - repo domain.RepoName
407+// - jobID string
408+// - attempt int32
409+func (_e *RunService_Expecter) GetStepSummaries(ctx any, p any, repo any, jobID any, attempt any) *RunService_GetStepSummaries_Call {
410+ return &RunService_GetStepSummaries_Call{Call: _e.mock.On("GetStepSummaries", ctx, p, repo, jobID, attempt)}
411+}
412+
413+func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32)) *RunService_GetStepSummaries_Call {
414+ _c.Call.Run(func(args mock.Arguments) {
415+ var arg0 context.Context
416+ if args[0] != nil {
417+ arg0 = args[0].(context.Context)
418+ }
419+ var arg1 domain.Principal
420+ if args[1] != nil {
421+ arg1 = args[1].(domain.Principal)
422+ }
423+ var arg2 domain.RepoName
424+ if args[2] != nil {
425+ arg2 = args[2].(domain.RepoName)
426+ }
427+ var arg3 string
428+ if args[3] != nil {
429+ arg3 = args[3].(string)
430+ }
431+ var arg4 int32
432+ if args[4] != nil {
433+ arg4 = args[4].(int32)
434+ }
435+ run(
436+ arg0,
437+ arg1,
438+ arg2,
439+ arg3,
440+ arg4,
441+ )
442+ })
443+ return _c
444+}
445+
446+func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList, err error) *RunService_GetStepSummaries_Call {
447+ _c.Call.Return(summaryList, err)
448+ return _c
449+}
450+
451+func (_c *RunService_GetStepSummaries_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error)) *RunService_GetStepSummaries_Call {
452+ _c.Call.Return(run)
453+ return _c
454+}
455+
456+// ListAnnotations provides a mock function for the type RunService
457+func (_mock *RunService) ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error) {
458+ ret := _mock.Called(ctx, p, repo, runID, attempt, jobID)
459+
460+ if len(ret) == 0 {
461+ panic("no return value specified for ListAnnotations")
462+ }
463+
464+ var r0 ports.AnnotationList
465+ var r1 error
466+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) (ports.AnnotationList, error)); ok {
467+ return returnFunc(ctx, p, repo, runID, attempt, jobID)
468+ }
469+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) ports.AnnotationList); ok {
470+ r0 = returnFunc(ctx, p, repo, runID, attempt, jobID)
471+ } else {
472+ r0 = ret.Get(0).(ports.AnnotationList)
473+ }
474+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) error); ok {
475+ r1 = returnFunc(ctx, p, repo, runID, attempt, jobID)
476+ } else {
477+ r1 = ret.Error(1)
478+ }
479+ return r0, r1
480+}
481+
482+// RunService_ListAnnotations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAnnotations'
483+type RunService_ListAnnotations_Call struct {
484+ *mock.Call
485+}
486+
487+// ListAnnotations is a helper method to define mock.On call
488+// - ctx context.Context
489+// - p domain.Principal
490+// - repo domain.RepoName
491+// - runID int64
492+// - attempt int32
493+// - jobID string
494+func (_e *RunService_Expecter) ListAnnotations(ctx any, p any, repo any, runID any, attempt any, jobID any) *RunService_ListAnnotations_Call {
495+ return &RunService_ListAnnotations_Call{Call: _e.mock.On("ListAnnotations", ctx, p, repo, runID, attempt, jobID)}
496+}
497+
498+func (_c *RunService_ListAnnotations_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string)) *RunService_ListAnnotations_Call {
499+ _c.Call.Run(func(args mock.Arguments) {
500+ var arg0 context.Context
501+ if args[0] != nil {
502+ arg0 = args[0].(context.Context)
503+ }
504+ var arg1 domain.Principal
505+ if args[1] != nil {
506+ arg1 = args[1].(domain.Principal)
507+ }
508+ var arg2 domain.RepoName
509+ if args[2] != nil {
510+ arg2 = args[2].(domain.RepoName)
511+ }
512+ var arg3 int64
513+ if args[3] != nil {
514+ arg3 = args[3].(int64)
515+ }
516+ var arg4 int32
517+ if args[4] != nil {
518+ arg4 = args[4].(int32)
519+ }
520+ var arg5 string
521+ if args[5] != nil {
522+ arg5 = args[5].(string)
523+ }
524+ run(
525+ arg0,
526+ arg1,
527+ arg2,
528+ arg3,
529+ arg4,
530+ arg5,
531+ )
532+ })
533+ return _c
534+}
535+
536+func (_c *RunService_ListAnnotations_Call) Return(annotationList ports.AnnotationList, err error) *RunService_ListAnnotations_Call {
537+ _c.Call.Return(annotationList, err)
538+ return _c
539+}
540+
541+func (_c *RunService_ListAnnotations_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error)) *RunService_ListAnnotations_Call {
542+ _c.Call.Return(run)
543+ return _c
544+}
545+
372546 // ListChecks provides a mock function for the type RunService
373547 func (_mock *RunService) ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error) {
374548 ret := _mock.Called(ctx, p, repo, sha)
M internal/ports/mocks/RunnerService.go +89 -8
@@ -498,16 +498,16 @@ func (_c *RunnerService_ResolveActions_Call) RunAndReturn(run func(ctx context.C
498498 }
499499
500500 // UpdateJob provides a mock function for the type RunnerService
501-func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {
502- ret := _mock.Called(ctx, secret, jobID, steps)
501+func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error {
502+ ret := _mock.Called(ctx, secret, jobID, steps, annotations)
503503
504504 if len(ret) == 0 {
505505 panic("no return value specified for UpdateJob")
506506 }
507507
508508 var r0 error
509- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []ports.StepUpdate) error); ok {
510- r0 = returnFunc(ctx, secret, jobID, steps)
509+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []ports.StepUpdate, []domain.CIAnnotation) error); ok {
510+ r0 = returnFunc(ctx, secret, jobID, steps, annotations)
511511 } else {
512512 r0 = ret.Error(0)
513513 }
@@ -524,11 +524,12 @@ type RunnerService_UpdateJob_Call struct {
524524 // - secret string
525525 // - jobID string
526526 // - steps []ports.StepUpdate
527-func (_e *RunnerService_Expecter) UpdateJob(ctx any, secret any, jobID any, steps any) *RunnerService_UpdateJob_Call {
528- return &RunnerService_UpdateJob_Call{Call: _e.mock.On("UpdateJob", ctx, secret, jobID, steps)}
527+// - annotations []domain.CIAnnotation
528+func (_e *RunnerService_Expecter) UpdateJob(ctx any, secret any, jobID any, steps any, annotations any) *RunnerService_UpdateJob_Call {
529+ return &RunnerService_UpdateJob_Call{Call: _e.mock.On("UpdateJob", ctx, secret, jobID, steps, annotations)}
529530 }
530531
531-func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate)) *RunnerService_UpdateJob_Call {
532+func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation)) *RunnerService_UpdateJob_Call {
532533 _c.Call.Run(func(args mock.Arguments) {
533534 var arg0 context.Context
534535 if args[0] != nil {
@@ -546,11 +547,16 @@ func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret
546547 if args[3] != nil {
547548 arg3 = args[3].([]ports.StepUpdate)
548549 }
550+ var arg4 []domain.CIAnnotation
551+ if args[4] != nil {
552+ arg4 = args[4].([]domain.CIAnnotation)
553+ }
549554 run(
550555 arg0,
551556 arg1,
552557 arg2,
553558 arg3,
559+ arg4,
554560 )
555561 })
556562 return _c
@@ -561,7 +567,82 @@ func (_c *RunnerService_UpdateJob_Call) Return(err error) *RunnerService_UpdateJ
561567 return _c
562568 }
563569
564-func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error) *RunnerService_UpdateJob_Call {
570+func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error) *RunnerService_UpdateJob_Call {
571+ _c.Call.Return(run)
572+ return _c
573+}
574+
575+// UploadSummary provides a mock function for the type RunnerService
576+func (_mock *RunnerService) UploadSummary(ctx context.Context, secret string, jobID string, step int32, markdown string) error {
577+ ret := _mock.Called(ctx, secret, jobID, step, markdown)
578+
579+ if len(ret) == 0 {
580+ panic("no return value specified for UploadSummary")
581+ }
582+
583+ var r0 error
584+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int32, string) error); ok {
585+ r0 = returnFunc(ctx, secret, jobID, step, markdown)
586+ } else {
587+ r0 = ret.Error(0)
588+ }
589+ return r0
590+}
591+
592+// RunnerService_UploadSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadSummary'
593+type RunnerService_UploadSummary_Call struct {
594+ *mock.Call
595+}
596+
597+// UploadSummary is a helper method to define mock.On call
598+// - ctx context.Context
599+// - secret string
600+// - jobID string
601+// - step int32
602+// - markdown string
603+func (_e *RunnerService_Expecter) UploadSummary(ctx any, secret any, jobID any, step any, markdown any) *RunnerService_UploadSummary_Call {
604+ return &RunnerService_UploadSummary_Call{Call: _e.mock.On("UploadSummary", ctx, secret, jobID, step, markdown)}
605+}
606+
607+func (_c *RunnerService_UploadSummary_Call) Run(run func(ctx context.Context, secret string, jobID string, step int32, markdown string)) *RunnerService_UploadSummary_Call {
608+ _c.Call.Run(func(args mock.Arguments) {
609+ var arg0 context.Context
610+ if args[0] != nil {
611+ arg0 = args[0].(context.Context)
612+ }
613+ var arg1 string
614+ if args[1] != nil {
615+ arg1 = args[1].(string)
616+ }
617+ var arg2 string
618+ if args[2] != nil {
619+ arg2 = args[2].(string)
620+ }
621+ var arg3 int32
622+ if args[3] != nil {
623+ arg3 = args[3].(int32)
624+ }
625+ var arg4 string
626+ if args[4] != nil {
627+ arg4 = args[4].(string)
628+ }
629+ run(
630+ arg0,
631+ arg1,
632+ arg2,
633+ arg3,
634+ arg4,
635+ )
636+ })
637+ return _c
638+}
639+
640+func (_c *RunnerService_UploadSummary_Call) Return(err error) *RunnerService_UploadSummary_Call {
641+ _c.Call.Return(err)
642+ return _c
643+}
644+
645+func (_c *RunnerService_UploadSummary_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, step int32, markdown string) error) *RunnerService_UploadSummary_Call {
565646 _c.Call.Return(run)
566647 return _c
567648 }
M internal/usecase/ci.go +20 -11
@@ -46,19 +46,21 @@ const (
4646 maxCIWorkflowFiles = 256
4747 )
4848
49-// CIConfig holds what the CI use cases need. Store, Logs, Meta, Repos, Reader,
49+// CIConfig holds what the CI use cases need. Store, Logs, Summaries, Meta, Repos, Reader,
5050 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
5151 // the defaults of Owner and Retention).
5252 type CIConfig struct {
53- Store ports.CIStore
54- Logs ports.CILogStore
55- Meta ports.RepoMetaStore
56- Repos ports.GitRepos
57- Reader ports.RepoReader
58- Git ports.MergeGit
59- Diff ports.CIGit
60- Bus ports.EventBus
61- Clock ports.Clock
53+ Store ports.CIStore
54+ Logs ports.CILogStore
55+ // Summaries keeps the job summaries, deleted with the logs of their run.
56+ Summaries ports.CISummaryStore
57+ Meta ports.RepoMetaStore
58+ Repos ports.GitRepos
59+ Reader ports.RepoReader
60+ Git ports.MergeGit
61+ Diff ports.CIGit
62+ Bus ports.EventBus
63+ Clock ports.Clock
6264 // Auth re-validates the token of a log stream; nil skips it.
6365 Auth ports.Revalidator
6466 // Remote lists the refs of github.com repositories for ResolveActions; nil
@@ -295,6 +297,10 @@ type runState struct {
295297 revoke []ports.CIJobRef
296298 // resolutions are the action resolutions to keep with the next flush.
297299 resolutions []domain.CIActionResolution
300+ // annotations replace the annotations of steps, clear drops all those of
301+ // executions of jobs, with the next flush.
302+ annotations []ports.CIStepAnnotations
303+ clear []ports.CILogRef
298304 }
299305
300306 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)
@@ -379,7 +385,9 @@ func (rs *runState) flush(ctx context.Context) error {
379385 w.Runs = []domain.CIRun{rs.run}
380386 }
381387 w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions
382- if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 && len(w.Resolutions) == 0 {
388+ w.Annotations, w.ClearAnnotations = rs.annotations, rs.clear
389+ if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 &&
390+ len(w.Resolutions) == 0 && len(w.Annotations) == 0 && len(w.ClearAnnotations) == 0 {
383391 return nil
384392 }
385393 v, err := c.cfg.Store.Apply(ctx, w)
@@ -395,6 +403,7 @@ func (rs *runState) flush(ctx context.Context) error {
395403 changedRun := rs.runDirty
396404 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
397405 rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil
406+ rs.annotations, rs.clear = nil, nil
398407
399408 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
400409 if err != nil {
M internal/usecase/ci_plan.go +1 -0
@@ -231,6 +231,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str
231231 if s.State == domain.CIStepPending && !cancelish {
232232 s.Conclusion = domain.CISkipped
233233 }
234+ s.Outcome = s.Conclusion
234235 s.State, s.CompletedAt = domain.CIStepCompleted, now
235236 }
236237 j.Phase, j.Conclusion, j.CompletedAt = domain.CIPhaseEnded, conclusion, now
A internal/usecase/ci_results.go +128 -0
@@ -0,0 +1,128 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "unicode/utf8"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// The results of a job beyond its steps and its log: annotations and step
16+// summaries (runner.proto, run.proto).
17+
18+// annotationWrites returns the replacements an UpdateJob or CompleteJob makes:
19+// one per step the request names whose stored annotations differ, so that
20+// repeating a call changes nothing. The request was validated.
21+func (c *CI) annotationWrites(ctx context.Context, j domain.CIJob, anns []domain.CIAnnotation) ([]ports.CIStepAnnotations, error) {
22+ if len(anns) == 0 {
23+ return nil, nil
24+ }
25+ stored, err := c.cfg.Store.JobAnnotations(ctx, j.RunID, j.Attempt, j.ID)
26+ if err != nil {
27+ return nil, fmt.Errorf("read the annotations of job %s: %w", j.ID, err)
28+ }
29+ have, _ := domain.GroupAnnotations(stored)
30+ want, steps := domain.GroupAnnotations(anns)
31+ var out []ports.CIStepAnnotations
32+ for _, step := range steps {
33+ if slices.Equal(have[step], want[step]) {
34+ continue
35+ }
36+ out = append(out, ports.CIStepAnnotations{Repo: j.Repo, Exec: logRef(j), Step: step, Items: want[step]})
37+ }
38+ return out, nil
39+}
40+
41+// UploadSummary stores the summary of a step, replacing the one it has. An
42+// empty Markdown removes it.
43+func (c *CI) UploadSummary(ctx context.Context, secret, jobID string, step int32, markdown string) error {
44+ j, err := c.jobFor(ctx, secret, jobID)
45+ if err != nil {
46+ return err
47+ }
48+ if j.Ended() {
49+ return endedError(j)
50+ }
51+ switch {
52+ case step < 1 || step > domain.CIMaxSteps:
53+ return fmt.Errorf("%w: step %d is not between 1 and %d", domain.ErrInvalid, step, domain.CIMaxSteps)
54+ case len(markdown) > domain.CIMaxSummaryBytes:
55+ return fmt.Errorf("%w: the summary is larger than 1 MiB", domain.ErrInvalid)
56+ case !utf8.ValidString(markdown):
57+ return fmt.Errorf("%w: the summary is not valid UTF-8", domain.ErrInvalid)
58+ }
59+ // Under the lock of the run, so that the end of the job and the summary do
60+ // not cross: after the end nothing is stored.
61+ return c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
62+ if cur.Ended() {
63+ return endedError(*cur)
64+ }
65+ return c.cfg.Summaries.Put(ctx, logRef(*cur), step, markdown)
66+ })
67+}
68+
69+// ListAnnotations returns the annotations of a run attempt (0 for the latest),
70+// of one job when jobID is set, ordered by job, step and production order.
71+// Each job gives those of the attempt in which it executed.
72+func (c *CI) ListAnnotations(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error) {
73+ if _, err := c.readableRepo(ctx, p, name); err != nil {
74+ return ports.AnnotationList{}, err
75+ }
76+ run, err := c.runOf(ctx, name, runID)
77+ if err != nil {
78+ return ports.AnnotationList{}, err
79+ }
80+ if attempt < 0 || attempt > run.Attempt {
81+ return ports.AnnotationList{}, domain.ErrNotFound
82+ }
83+ if attempt == 0 {
84+ attempt = run.Attempt
85+ }
86+ jobs, err := c.cfg.Store.ListJobs(ctx, runID, attempt)
87+ if err != nil {
88+ return ports.AnnotationList{}, err
89+ }
90+ if jobID != "" {
91+ jobs = slices.DeleteFunc(jobs, func(j domain.CIJob) bool { return j.ID != jobID })
92+ if len(jobs) == 0 {
93+ return ports.AnnotationList{}, domain.ErrNotFound
94+ }
95+ }
96+ out := ports.AnnotationList{Annotations: []ports.JobAnnotation{}}
97+ for _, j := range jobs {
98+ anns, err := c.cfg.Store.JobAnnotations(ctx, j.RunID, j.Attempt, j.ID)
99+ if err != nil {
100+ return ports.AnnotationList{}, err
101+ }
102+ for _, a := range anns {
103+ if len(out.Annotations) >= domain.CIMaxListedAnnotations {
104+ out.Truncated = true
105+ return out, nil
106+ }
107+ out.Annotations = append(out.Annotations, ports.JobAnnotation{JobID: j.ID, Annotation: a})
108+ }
109+ }
110+ return out, nil
111+}
112+
113+// GetStepSummaries returns the summaries of a job at an attempt (0 for the
114+// latest): the first 20 steps that have one, by step number.
115+func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error) {
116+ if _, err := c.readableRepo(ctx, p, name); err != nil {
117+ return ports.SummaryList{}, err
118+ }
119+ _, j, err := c.jobOf(ctx, name, jobID, attempt)
120+ if err != nil {
121+ return ports.SummaryList{}, err
122+ }
123+ list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries)
124+ if err != nil {
125+ return ports.SummaryList{}, err
126+ }
127+ return ports.SummaryList{Summaries: list, Truncated: more}, nil
128+}
A internal/usecase/ci_results_unit_test.go +231 -0
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+const otherJobSecret = "ermitejob_ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkj"
19+
20+func warn(step int32, msg string) domain.CIAnnotation {
21+ return domain.CIAnnotation{Level: domain.AnnotationWarning, Step: step, Message: msg}
22+}
23+
24+// UpdateJob replaces the annotations of the steps it names, in the batch of the
25+// job, also for a completed step and a step that is not in the call; a retry
26+// writes nothing.
27+func TestUpdateJobReplacesAnnotationsPerStep(t *testing.T) {
28+ t.Parallel()
29+ u := newCIUnit(t, nil)
30+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
31+ j.Steps = []domain.CIStep{{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: now, CompletedAt: now}}
32+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(jobSecret), mock.Anything).Return(j, nil)
33+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
34+ u.store.EXPECT().JobAnnotations(mock.Anything, j.RunID, j.Attempt, j.ID).Return([]domain.CIAnnotation{warn(1, "old"), warn(5, "kept")}, nil).Once()
35+ var written ports.CIWrite
36+ u.store.EXPECT().Apply(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, w ports.CIWrite) (uint64, error) {
37+ written = w
38+ return 9, nil
39+ }).Once()
40+ u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo}, nil)
41+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil)
42+ u.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
43+
44+ // Step 1 is completed: its annotations are replaced, the step is not touched.
45+ completed := ports.StepUpdate{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: now, CompletedAt: now}
46+ require.NoError(t, u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{completed},
47+ []domain.CIAnnotation{warn(1, "new"), warn(1, "new too"), warn(9, "a step not in the call")}))
48+
49+ require.Len(t, written.Annotations, 2, "step 5 is not named: it is kept")
50+ assert.EqualValues(t, 1, written.Annotations[0].Step)
51+ assert.Equal(t, []string{"new", "new too"}, []string{written.Annotations[0].Items[0].Message, written.Annotations[0].Items[1].Message})
52+ assert.EqualValues(t, 9, written.Annotations[1].Step)
53+ assert.Equal(t, ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}, written.Annotations[0].Exec, "keyed by the attempt the job executes in")
54+ assert.Equal(t, j.Repo, written.Annotations[0].Repo)
55+ require.Len(t, written.Jobs, 1, "annotations travel with the job record")
56+}
57+
58+func TestUpdateJobRetryWritesNothing(t *testing.T) {
59+ t.Parallel()
60+ u := newCIUnit(t, nil)
61+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
62+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
63+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
64+ u.store.EXPECT().JobAnnotations(mock.Anything, j.RunID, j.Attempt, j.ID).Return([]domain.CIAnnotation{warn(2, "same")}, nil)
65+ // No Apply is expected: the mock fails the test on one.
66+ require.NoError(t, u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(2, "same")}))
67+}
68+
69+// A call that breaks a limit is refused before anything is read or written.
70+func TestUpdateJobAnnotationLimitsStopBeforeTheStore(t *testing.T) {
71+ t.Parallel()
72+ u := newCIUnit(t, nil)
73+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
74+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
75+ eleven := make([]domain.CIAnnotation, 11)
76+ for i := range eleven {
77+ eleven[i] = warn(3, "m")
78+ }
79+ for name, anns := range map[string][]domain.CIAnnotation{
80+ "eleven": eleven,
81+ "too long": {warn(1, strings.Repeat("x", 4097))},
82+ "no message": {warn(1, "")},
83+ } {
84+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, nil, anns)
85+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
86+ }
87+}
88+
89+func TestResultsAreRefusedWithAnotherJobsToken(t *testing.T) {
90+ t.Parallel()
91+ u := newCIUnit(t, nil)
92+ other := ciJob(t, domain.CIPhaseRunning, otherJobSecret)
93+ other.ID = "7-1-0"
94+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(otherJobSecret), mock.Anything).Return(other, nil)
95+ ctx := context.Background()
96+
97+ err := u.ci.UpdateJob(ctx, otherJobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(1, "x")})
98+ assert.ErrorIs(t, err, domain.ErrForbidden)
99+ err = u.ci.UploadSummary(ctx, otherJobSecret, jobIDOne, 1, "x")
100+ assert.ErrorIs(t, err, domain.ErrForbidden)
101+ _, err = u.ci.CompleteJob(ctx, otherJobSecret, ports.CompleteJobParams{JobID: jobIDOne, Conclusion: domain.CISuccess, Annotations: []domain.CIAnnotation{warn(1, "x")}})
102+ assert.ErrorIs(t, err, domain.ErrForbidden)
103+
104+ // Not a job token at all.
105+ for _, secret := range []string{"", "ermiterun_" + strings.Repeat("a", 43), "ermite_" + strings.Repeat("a", 43)} {
106+ assert.ErrorIs(t, u.ci.UploadSummary(ctx, secret, jobIDOne, 1, "x"), domain.ErrUnauthenticated, secret)
107+ }
108+}
109+
110+func TestEndedJobsRejectAnnotationsAndSummaries(t *testing.T) {
111+ t.Parallel()
112+ u := newCIUnit(t, nil)
113+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
114+ j.Conclusion, j.Error = domain.CICancelled, "stopped"
115+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
116+ ctx := context.Background()
117+
118+ for name, err := range map[string]error{
119+ "UpdateJob": u.ci.UpdateJob(ctx, jobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(1, "x")}),
120+ "UploadSummary": u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1, "x"),
121+ } {
122+ ended, ok := domain.IsJobEnded(err)
123+ require.True(t, ok, name)
124+ assert.Equal(t, domain.CICancelled, ended.Conclusion, name)
125+ assert.Equal(t, "stopped", ended.Message, name)
126+ }
127+
128+ // CompleteJob on an ended job returns the stored result and stores nothing,
129+ // whatever the annotations: the mocks expect no read and no write.
130+ res, err := u.ci.CompleteJob(ctx, jobSecret, ports.CompleteJobParams{
131+ JobID: jobIDOne, Conclusion: domain.CISuccess, Annotations: []domain.CIAnnotation{warn(1, "ignored")},
132+ })
133+ require.NoError(t, err)
134+ assert.False(t, res.Applied)
135+ assert.Equal(t, domain.CICancelled, res.Conclusion)
136+}
137+
138+func TestUploadSummaryRules(t *testing.T) {
139+ t.Parallel()
140+ u := newCIUnit(t, nil)
141+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
142+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
143+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
144+ ctx := context.Background()
145+ ref := ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
146+
147+ for name, tc := range map[string]struct {
148+ step int32
149+ md string
150+ }{
151+ "step 0": {0, "x"},
152+ "step 1001": {1001, "x"},
153+ "1 MiB and a byte": {1, strings.Repeat("x", 1<<20+1)},
154+ "invalid UTF-8": {1, "a\xffb"},
155+ "negative": {-1, "x"},
156+ } {
157+ assert.ErrorIs(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, tc.step, tc.md), domain.ErrInvalid, name)
158+ }
159+
160+ max := strings.Repeat("x", 1<<20)
161+ u.sums.EXPECT().Put(mock.Anything, ref, int32(1), max).Return(nil).Once()
162+ u.sums.EXPECT().Put(mock.Anything, ref, int32(1000), "").Return(nil).Once()
163+ require.NoError(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1, max), "exactly 1 MiB")
164+ require.NoError(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1000, ""), "empty removes")
165+}
166+
167+// The summaries are read through the same access as GetJob, per attempt of the
168+// job, at most 20.
169+func TestGetStepSummariesAndListAnnotations(t *testing.T) {
170+ t.Parallel()
171+ u := newCIUnit(t, nil)
172+ repo := name(t, "proj")
173+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
174+ j.Attempt, j.RunAttempt = 1, 2 // kept from attempt 1 by a re-run
175+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
176+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 2}, nil)
177+ u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(2), jobIDOne).Return(j, nil)
178+ u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(2)).Return([]domain.CIJob{j}, nil)
179+ u.store.EXPECT().JobAnnotations(mock.Anything, int64(7), int32(1), jobIDOne).Return([]domain.CIAnnotation{warn(1, "from attempt 1")}, nil)
180+ u.sums.EXPECT().List(mock.Anything, ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1}, domain.CIMaxListedSummaries).
181+ Return([]domain.CIStepSummary{{Step: 3, Markdown: "s"}}, true, nil)
182+ ctx := context.Background()
183+
184+ anns, err := u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 0, "")
185+ require.NoError(t, err)
186+ require.Len(t, anns.Annotations, 1)
187+ assert.Equal(t, jobIDOne, anns.Annotations[0].JobID)
188+ assert.False(t, anns.Truncated)
189+
190+ sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0)
191+ require.NoError(t, err)
192+ assert.True(t, sums.Truncated)
193+ assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries)
194+
195+ _, err = u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 3, "")
196+ assert.ErrorIs(t, err, domain.ErrNotFound, "an attempt that does not exist")
197+ _, err = u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 0, "7-9-0")
198+ assert.ErrorIs(t, err, domain.ErrNotFound, "a job that is not of the run")
199+}
200+
201+func TestListAnnotationsIsBoundedAndTruncated(t *testing.T) {
202+ t.Parallel()
203+ u := newCIUnit(t, nil)
204+ repo := name(t, "proj")
205+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
206+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
207+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 1}, nil)
208+ u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(1)).Return([]domain.CIJob{j}, nil)
209+ many := make([]domain.CIAnnotation, 1001)
210+ for i := range many {
211+ many[i] = warn(int32(i%1000)+1, "m")
212+ }
213+ u.store.EXPECT().JobAnnotations(mock.Anything, int64(7), int32(1), jobIDOne).Return(many, nil)
214+ got, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
215+ require.NoError(t, err)
216+ assert.Len(t, got.Annotations, 1000)
217+ assert.True(t, got.Truncated)
218+}
219+
220+// A private repository is not readable by an anonymous caller: not found, as
221+// for GetRun.
222+func TestResultsReadsFollowTheAccessOfTheRepository(t *testing.T) {
223+ t.Parallel()
224+ u := newCIUnit(t, nil)
225+ repo := name(t, "proj")
226+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}, nil)
227+ _, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
228+ assert.ErrorIs(t, err, domain.ErrNotFound)
229+ _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0)
230+ assert.ErrorIs(t, err, domain.ErrNotFound)
231+}
M internal/usecase/ci_runner.go +52 -16
@@ -321,10 +321,11 @@ func sameAssignment(was, cur domain.CIJob) error {
321321 return nil
322322 }
323323
324-// saveJob writes one job (its steps changed) and publishes JobUpdated. The
325-// status of the run does not depend on steps.
326-func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error {
327- v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}})
324+// saveJob writes one job (its steps changed) with the annotations that change
325+// with it, and publishes JobUpdated. The status of the run does not depend on
326+// steps.
327+func (c *CI) saveJob(ctx context.Context, j *domain.CIJob, anns ...ports.CIStepAnnotations) error {
328+ v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}, Annotations: anns})
328329 if err != nil {
329330 return fmt.Errorf("write job %s: %w", j.ID, err)
330331 }
@@ -341,8 +342,10 @@ func logRef(j domain.CIJob) ports.CILogRef {
341342 return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
342343 }
343344
344-// UpdateJob applies the steps of the request whole or not at all.
345-func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate) error {
345+// UpdateJob applies the steps and the annotations of the request whole or not
346+// at all. The annotations of each step they name replace those stored, whether
347+// the step is completed or not.
348+func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error {
346349 j, err := c.jobFor(ctx, secret, jobID)
347350 if err != nil {
348351 return err
@@ -353,16 +356,26 @@ func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.
353356 if err := checkStepUpdates(steps); err != nil {
354357 return err
355358 }
359+ if err := domain.CheckAnnotations(annotations); err != nil {
360+ return err
361+ }
356362 return c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
357363 if cur.Ended() {
358364 return endedError(*cur)
359365 }
360366 next, changed, err := applySteps(cur.Steps, steps)
361- if err != nil || !changed {
367+ if err != nil {
362368 return err
363369 }
370+ writes, err := c.annotationWrites(ctx, *cur, annotations)
371+ if err != nil {
372+ return err
373+ }
374+ if !changed && len(writes) == 0 {
375+ return nil
376+ }
364377 cur.Steps = next
365- return c.saveJob(ctx, cur)
378+ return c.saveJob(ctx, cur, writes...)
366379 })
367380 }
368381
@@ -377,12 +390,12 @@ func checkStepUpdates(steps []ports.StepUpdate) error {
377390 }
378391 switch s.State {
379392 case domain.CIStepPending:
380- if s.Conclusion != domain.CINoConclusion {
381- return fmt.Errorf("%w: step %d is not completed: it has no conclusion", domain.ErrInvalid, s.Number)
393+ if s.Conclusion != domain.CINoConclusion || s.Outcome != domain.CINoConclusion {
394+ return fmt.Errorf("%w: step %d is not completed: it has no conclusion or outcome", domain.ErrInvalid, s.Number)
382395 }
383396 case domain.CIStepInProgress:
384- if s.Conclusion != domain.CINoConclusion || s.StartedAt.IsZero() {
385- return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion is not allowed", domain.ErrInvalid, s.Number)
397+ if s.Conclusion != domain.CINoConclusion || s.Outcome != domain.CINoConclusion || s.StartedAt.IsZero() {
398+ return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion and outcome are not allowed", domain.ErrInvalid, s.Number)
386399 }
387400 case domain.CIStepCompleted:
388401 switch s.Conclusion {
@@ -390,6 +403,11 @@ func checkStepUpdates(steps []ports.StepUpdate) error {
390403 default:
391404 return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
392405 }
406+ switch s.Outcome {
407+ case domain.CINoConclusion, domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CISkipped:
408+ default:
409+ return fmt.Errorf("%w: step %d is completed: its outcome must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
410+ }
393411 if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped {
394412 return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number)
395413 }
@@ -413,21 +431,22 @@ func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep,
413431 i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number })
414432 if i < 0 {
415433 out = append(out, domain.CIStep{
416- Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, StartedAt: u.StartedAt, CompletedAt: u.CompletedAt,
434+ Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, Outcome: u.Outcome,
435+ StartedAt: u.StartedAt, CompletedAt: u.CompletedAt,
417436 })
418437 changed = true
419438 continue
420439 }
421440 cur := out[i]
422441 next := cur
423- next.Name, next.State, next.Conclusion = name, u.State, u.Conclusion
442+ next.Name, next.State, next.Conclusion, next.Outcome = name, u.State, u.Conclusion, u.Outcome
424443 next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt
425444 if cur.Name != "" && name == "" {
426445 next.Name = cur.Name // an empty name never erases one
427446 }
428447 switch {
429448 case cur.State == domain.CIStepCompleted:
430- same := next.State == cur.State && next.Conclusion == cur.Conclusion && next.Name == cur.Name &&
449+ same := next.State == cur.State && next.Conclusion == cur.Conclusion && effectiveOutcome(next) == effectiveOutcome(cur) && next.Name == cur.Name &&
431450 next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt)
432451 if !same {
433452 return nil, false, fmt.Errorf("%w: step %d is completed: it cannot change", domain.ErrPreconditionFailed, u.Number)
@@ -444,8 +463,17 @@ func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep,
444463 return out, changed, nil
445464 }
446465
466+// effectiveOutcome is the outcome of a step, read as its conclusion when the
467+// runner did not report one.
468+func effectiveOutcome(s domain.CIStep) domain.CIConclusion {
469+ if s.Outcome == domain.CINoConclusion {
470+ return s.Conclusion
471+ }
472+ return s.Outcome
473+}
474+
447475 func stepsEqual(a, b domain.CIStep) bool {
448- return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion &&
476+ return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion && a.Outcome == b.Outcome &&
449477 a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines
450478 }
451479
@@ -731,6 +759,9 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple
731759 if err := checkStepUpdates(params.Steps); err != nil {
732760 return ports.CompleteJobResult{}, err
733761 }
762+ if err := domain.CheckAnnotations(params.Annotations); err != nil {
763+ return ports.CompleteJobResult{}, err
764+ }
734765 var res ports.CompleteJobResult
735766 err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
736767 cur := rs.find(j.ID)
@@ -748,6 +779,11 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple
748779 if err != nil {
749780 return err
750781 }
782+ writes, err := c.annotationWrites(ctx, *cur, params.Annotations)
783+ if err != nil {
784+ return err
785+ }
786+ rs.annotations = append(rs.annotations, writes...)
751787 cur.Steps = steps
752788 cur.Outputs = params.Outputs
753789 msg := ""
M internal/usecase/ci_runner_unit_test.go +13 -4
@@ -24,6 +24,7 @@ import (
2424 type ciUnit struct {
2525 store *mocks.CIStore
2626 logs *mocks.CILogStore
27+ sums *mocks.CISummaryStore
2728 meta *mocks.RepoMetaStore
2829 bus *mocks.EventBus
2930 clock *fakeClock
@@ -37,11 +38,11 @@ func (c *fakeClock) Now() time.Time { return c.t }
3738 func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit {
3839 t.Helper()
3940 u := &ciUnit{
40- store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), meta: mocks.NewRepoMetaStore(t),
41+ store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), sums: mocks.NewCISummaryStore(t), meta: mocks.NewRepoMetaStore(t),
4142 bus: mocks.NewEventBus(t), clock: &fakeClock{t: now},
4243 }
4344 cfg := usecase.CIConfig{
44- Store: u.store, Logs: u.logs, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
45+ Store: u.store, Logs: u.logs, Summaries: u.sums, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
4546 Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test",
4647 }
4748 if tweak != nil {
@@ -83,7 +84,7 @@ func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
8384 "UpdateJob": func(u *ciUnit) error {
8485 return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{
8586 Number: 1, State: domain.CIStepInProgress, StartedAt: now,
86- }})
87+ }}, nil)
8788 },
8889 "AppendLog": func(u *ciUnit) error {
8990 _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
@@ -122,7 +123,7 @@ func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
122123 ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled
123124 u.expectToken(jobSecret, j)
124125 u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(ended, nil)
125- err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}})
126+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}}, nil)
126127 got, ok := domain.IsJobEnded(err)
127128 require.True(t, ok, "%v", err)
128129 assert.Equal(t, domain.CICancelled, got.Conclusion)
@@ -311,6 +312,10 @@ func TestSweep(t *testing.T) {
311312 ctx, out := logCapture(t)
312313 u.store.EXPECT().Sweep(mock.Anything, u.clock.t.Add(-retention)).Return([]int64{3, 4}, nil)
313314 u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3, 4}).Return(nil).Once()
315+ u.sums.EXPECT().RemoveRuns(mock.Anything, []int64{3, 4}).Return(nil).Once()
316+ u.sums.EXPECT().RunIDs(mock.Anything).Return([]int64{6, 8}, nil)
317+ u.sums.EXPECT().RemoveRuns(mock.Anything, []int64{6, 8}).Return(nil).Once()
318+ u.store.EXPECT().GetRunSource(mock.Anything, int64(8)).Return(domain.CIRunSource{}, domain.ErrNotFound)
314319 u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{5, 6, 7}, nil)
315320 u.store.EXPECT().GetRunSource(mock.Anything, int64(5)).Return(domain.CIRunSource{}, nil)
316321 u.store.EXPECT().GetRunSource(mock.Anything, int64(6)).Return(domain.CIRunSource{}, domain.ErrNotFound)
@@ -337,6 +342,8 @@ func TestSweep(t *testing.T) {
337342 ctx, out := logCapture(t)
338343 u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return([]int64{3}, nil)
339344 u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3}).Return(errors.New("permission denied")).Once()
345+ u.sums.EXPECT().RemoveRuns(mock.Anything, []int64{3}).Return(nil).Once()
346+ u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, nil)
340347 u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil)
341348 u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, domain.ErrNotFound)
342349 u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{9}).Return(errors.New("disk on fire")).Once()
@@ -353,6 +360,7 @@ func TestSweep(t *testing.T) {
353360 ctx, out := logCapture(t)
354361 u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil)
355362 u.logs.EXPECT().RunIDs(mock.Anything).Return(nil, errors.New("io error"))
363+ u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, errors.New("io error"))
356364 u.ci.Sweep(ctx)
357365 assert.Contains(t, out.String(), "list the log directories")
358366 })
@@ -363,6 +371,7 @@ func TestSweep(t *testing.T) {
363371 ctx, out := logCapture(t)
364372 u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil)
365373 u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil)
374+ u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, nil)
366375 u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, errors.New("kv closed"))
367376 u.ci.Sweep(ctx) // RemoveRuns is not expected: an unknown state removes nothing
368377 assert.Contains(t, out.String(), "look up a run")
M internal/usecase/ci_watch.go +21 -7
@@ -185,6 +185,8 @@ func itoa(n int64) string { return strconv.FormatInt(n, 10) }
185185 func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob) {
186186 ctxLog(ctx).Warn().Str("job_id", j.ID).Str("runner", j.RunnerName).Msg("job assignment not acknowledged: cancelled")
187187 _ = c.cfg.Logs.Reset(ctx, logRef(*j))
188+ _ = c.cfg.Summaries.RemoveJob(ctx, logRef(*j))
189+ rs.clear = append(rs.clear, logRef(*j))
188190 j.AssignCancels++
189191 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
190192 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
@@ -252,14 +254,25 @@ func (c *CI) sweep(ctx context.Context) {
252254 if err := c.cfg.Logs.RemoveRuns(ctx, ids); err != nil {
253255 log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the logs of the old runs")
254256 }
257+ if err := c.cfg.Summaries.RemoveRuns(ctx, ids); err != nil {
258+ log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the summaries of the old runs")
259+ }
255260 for _, id := range ids {
256261 c.locks.Delete(id)
257262 }
258263 log.Info().Int("runs", len(ids)).Msg("ci: old runs removed")
259264 }
260- onDisk, err := c.cfg.Logs.RunIDs(ctx)
265+ c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns)
266+ c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns)
267+}
268+
269+// removeOrphans deletes what a file store (logs, summaries) holds for runs that
270+// no longer exist, for example a repository deleted while the server was down.
271+func (c *CI) removeOrphans(ctx context.Context, what, dirs string, list func(context.Context) ([]int64, error), remove func(context.Context, []int64) error) {
272+ log := ctxLog(ctx)
273+ onDisk, err := list(ctx)
261274 if err != nil {
262- log.Error().Err(err).Msg("ci: list the log directories")
275+ log.Error().Err(err).Msg("ci: list the " + dirs)
263276 return
264277 }
265278 var orphans []int64
@@ -268,12 +281,12 @@ func (c *CI) sweep(ctx context.Context) {
268281 case errors.Is(err, domain.ErrNotFound):
269282 orphans = append(orphans, id)
270283 case err != nil:
271- log.Error().Err(err).Int64("run_id", id).Msg("ci: look up a run with a log directory")
284+ log.Error().Err(err).Int64("run_id", id).Msg("ci: look up a run with a " + what + " directory")
272285 }
273286 }
274287 if len(orphans) > 0 {
275- if err := c.cfg.Logs.RemoveRuns(ctx, orphans); err != nil {
276- log.Error().Err(err).Int("runs", len(orphans)).Msg("ci: remove the logs of runs that no longer exist")
288+ if err := remove(ctx, orphans); err != nil {
289+ log.Error().Err(err).Int("runs", len(orphans)).Msg("ci: remove the " + what + " of runs that no longer exist")
277290 }
278291 }
279292 }
@@ -284,10 +297,11 @@ func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
284297 return c.cfg.Store.RunIDs(ctx, repo)
285298 }
286299
287-// RemoveLogs deletes the logs of runs of a repository that was deleted.
300+// RemoveLogs deletes the logs and the summaries of runs of a repository that
301+// was deleted.
288302 func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error {
289303 for _, id := range runIDs {
290304 c.locks.Delete(id)
291305 }
292- return c.cfg.Logs.RemoveRuns(ctx, runIDs)
306+ return errors.Join(c.cfg.Logs.RemoveRuns(ctx, runIDs), c.cfg.Summaries.RemoveRuns(ctx, runIDs))
293307 }
M proto/ermite/storage/ci.proto +31 -0
@@ -135,6 +135,9 @@ message CIStep {
135135 // count of the job: it is never stored.
136136 int64 log_start = 7;
137137 bool has_lines = 9;
138+ // How the step ended before continue-on-error was applied; unspecified for
139+ // a step stored before the field existed (read it as conclusion).
140+ CIConclusion outcome = 11;
138141 }
139142
140143 // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".
@@ -260,3 +263,31 @@ message CIActionResolution {
260263 // 1 = github.com, 2 = this server (the numbers of ActionSource).
261264 int32 source = 4;
262265 }
266+
267+// CIAnnotationLevel is the severity of an annotation.
268+enum CIAnnotationLevel {
269+ CI_ANNOTATION_LEVEL_UNSPECIFIED = 0;
270+ CI_ANNOTATION_LEVEL_NOTICE = 1;
271+ CI_ANNOTATION_LEVEL_WARNING = 2;
272+ CI_ANNOTATION_LEVEL_FAILURE = 3;
273+}
274+
275+// CIAnnotation is a message attached to a step of a job.
276+message CIAnnotation {
277+ CIAnnotationLevel level = 1;
278+ string title = 2;
279+ string message = 3;
280+ string file = 4;
281+ int32 start_line = 5;
282+ int32 end_line = 6;
283+ int32 start_column = 7;
284+ int32 end_column = 8;
285+}
286+
287+// CIStepAnnotations holds all the annotations of one step of one execution of
288+// a job, in the order the step produced them. It is stored under
289+// "annot/<run:020d>/<job attempt:010d>/<job id>/<step:010d>"; a step without
290+// annotation has no key. The runner replaces the whole value at each report.
291+message CIStepAnnotations {
292+ repeated CIAnnotation annotations = 1;
293+}

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
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close