Merge pull request 'feat(ci): store and show annotations, step summaries and step outcomes' (#23) from feat/ci2-results into main
43 files · +5369 -566
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 | |||
| 126 | 126 | ||
| 127 | 127 | 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. | |
| 128 | 128 | ||
| 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. | |
| 130 | 130 | ||
| 131 | 131 | ## Web sign-in | |
| 132 | 132 | ||
M gen/ermite/storage/ci.pb.go +291 -54
| @@ -369,6 +369,59 @@ func (CICancelReason) EnumDescriptor() ([]byte, []int) { | |||
| 369 | 369 | return file_ermite_storage_ci_proto_rawDescGZIP(), []int{5} | |
| 370 | 370 | } | |
| 371 | 371 | ||
| 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 | + | ||
| 372 | 425 | // CIRun is stored under "run/<repo>/<id:020d>". | |
| 373 | 426 | type CIRun struct { | |
| 374 | 427 | state protoimpl.MessageState `protogen:"open.v1"` | |
| @@ -674,8 +727,11 @@ type CIStep struct { | |||
| 674 | 727 | // Number of the first log line of the step; meaningful with has_lines. The | |
| 675 | 728 | // end of a step is the start of the next step that has lines, or the line | |
| 676 | 729 | // 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"` | |
| 679 | 735 | unknownFields protoimpl.UnknownFields | |
| 680 | 736 | sizeCache protoimpl.SizeCache | |
| 681 | 737 | } | |
| @@ -766,6 +822,13 @@ func (x *CIStep) GetHasLines() bool { | |||
| 766 | 822 | return false | |
| 767 | 823 | } | |
| 768 | 824 | ||
| 825 | + | func (x *CIStep) GetOutcome() CIConclusion { | |
| 826 | + | if x != nil { | |
| 827 | + | return x.Outcome | |
| 828 | + | } | |
| 829 | + | return CIConclusion_CI_CONCLUSION_UNSPECIFIED | |
| 830 | + | } | |
| 831 | + | ||
| 769 | 832 | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | |
| 770 | 833 | type CIJob struct { | |
| 771 | 834 | state protoimpl.MessageState `protogen:"open.v1"` | |
| @@ -1578,6 +1641,155 @@ func (x *CIActionResolution) GetSource() int32 { | |||
| 1578 | 1641 | return 0 | |
| 1579 | 1642 | } | |
| 1580 | 1643 | ||
| 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 | + | ||
| 1581 | 1793 | var File_ermite_storage_ci_proto protoreflect.FileDescriptor | |
| 1582 | 1794 | ||
| 1583 | 1795 | const file_ermite_storage_ci_proto_rawDesc = "" + | |
| @@ -1617,7 +1829,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1617 | 1829 | "\x04repo\x18\x03 \x01(\tR\x04repo\x12\x1f\n" + | |
| 1618 | 1830 | "\vpusher_kind\x18\x05 \x01(\tR\n" + | |
| 1619 | 1831 | "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" + | |
| 1621 | 1833 | "\x06CIStep\x12\x16\n" + | |
| 1622 | 1834 | "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" + | |
| 1623 | 1835 | "\x04name\x18\x02 \x01(\tR\x04name\x121\n" + | |
| @@ -1629,7 +1841,8 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1629 | 1841 | "started_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12=\n" + | |
| 1630 | 1842 | "\fcompleted_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12\x1b\n" + | |
| 1631 | 1843 | "\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" + | |
| 1633 | 1846 | "\x10\v\"\xe6\f\n" + | |
| 1634 | 1847 | "\x05CIJob\x12\x0e\n" + | |
| 1635 | 1848 | "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + | |
| @@ -1734,7 +1947,20 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1734 | 1947 | "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" + | |
| 1735 | 1948 | "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" + | |
| 1736 | 1949 | "\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" + | |
| 1738 | 1964 | "\bCIStatus\x12\x19\n" + | |
| 1739 | 1965 | "\x15CI_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" + | |
| 1740 | 1966 | "\x10CI_STATUS_QUEUED\x10\x01\x12\x19\n" + | |
| @@ -1771,7 +1997,12 @@ const file_ermite_storage_ci_proto_rawDesc = "" + | |||
| 1771 | 1997 | "\x1cCI_CANCEL_REASON_UNSPECIFIED\x10\x00\x12\x19\n" + | |
| 1772 | 1998 | "\x15CI_CANCEL_REASON_USER\x10\x01\x12\x1e\n" + | |
| 1773 | 1999 | "\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" | |
| 1775 | 2006 | ||
| 1776 | 2007 | var ( | |
| 1777 | 2008 | file_ermite_storage_ci_proto_rawDescOnce sync.Once | |
| @@ -1785,8 +2016,8 @@ func file_ermite_storage_ci_proto_rawDescGZIP() []byte { | |||
| 1785 | 2016 | return file_ermite_storage_ci_proto_rawDescData | |
| 1786 | 2017 | } | |
| 1787 | 2018 | ||
| 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) | |
| 1790 | 2021 | var file_ermite_storage_ci_proto_goTypes = []any{ | |
| 1791 | 2022 | (CIStatus)(0), // 0: ermite.storage.CIStatus | |
| 1792 | 2023 | (CIConclusion)(0), // 1: ermite.storage.CIConclusion | |
| @@ -1794,56 +2025,62 @@ var file_ermite_storage_ci_proto_goTypes = []any{ | |||
| 1794 | 2025 | (CIJobPhase)(0), // 3: ermite.storage.CIJobPhase | |
| 1795 | 2026 | (CIIsolation)(0), // 4: ermite.storage.CIIsolation | |
| 1796 | 2027 | (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 | |
| 1807 | 2041 | } | |
| 1808 | 2042 | var file_ermite_storage_ci_proto_depIdxs = []int32{ | |
| 1809 | 2043 | 0, // 0: ermite.storage.CIRun.status:type_name -> ermite.storage.CIStatus | |
| 1810 | 2044 | 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 | |
| 1815 | 2049 | 2, // 6: ermite.storage.CIStep.state:type_name -> ermite.storage.CIStepState | |
| 1816 | 2050 | 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 | |
| 1847 | 2084 | } | |
| 1848 | 2085 | ||
| 1849 | 2086 | func init() { file_ermite_storage_ci_proto_init() } | |
| @@ -1856,8 +2093,8 @@ func file_ermite_storage_ci_proto_init() { | |||
| 1856 | 2093 | File: protoimpl.DescBuilder{ | |
| 1857 | 2094 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 1858 | 2095 | 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, | |
| 1861 | 2098 | NumExtensions: 0, | |
| 1862 | 2099 | NumServices: 0, | |
| 1863 | 2100 | }, | |
A internal/adapters/cisummaries/cisummaries.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 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" | |
| 17 | + | "io/fs" | |
| 18 | + | "os" | |
| 19 | + | "path/filepath" | |
| 20 | + | "slices" | |
| 21 | + | "strconv" | |
| 22 | + | "strings" | |
| 23 | + | ||
| 24 | + | "bnema.dev/ermite/internal/domain" | |
| 25 | + | "bnema.dev/ermite/internal/ports" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // Dir is the directory of the summaries under the data directory. | |
| 29 | + | func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "summaries") } | |
| 30 | + | ||
| 31 | + | const ext = ".md" | |
| 32 | + | ||
| 33 | + | // Store is the file-backed summary store. | |
| 34 | + | type Store struct{ root string } | |
| 35 | + | ||
| 36 | + | var _ ports.CISummaryStore = (*Store)(nil) | |
| 37 | + | ||
| 38 | + | // New returns a store rooted at dir, which need not exist yet. | |
| 39 | + | func New(dir string) *Store { return &Store{root: dir} } | |
| 40 | + | ||
| 41 | + | // validRef rejects a reference that could escape the directory. | |
| 42 | + | func validRef(ref ports.CILogRef) bool { | |
| 43 | + | if ref.RunID < 1 || ref.Attempt < 1 { | |
| 44 | + | return false | |
| 45 | + | } | |
| 46 | + | _, _, _, ok := domain.ParseJobID(ref.JobID) | |
| 47 | + | return ok | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (s *Store) jobDir(ref ports.CILogRef) string { | |
| 51 | + | return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt))) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (s *Store) path(ref ports.CILogRef, step int32) string { | |
| 55 | + | return filepath.Join(s.jobDir(ref), strconv.Itoa(int(step))+ext) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Put stores the summary of a step, or removes it when markdown is empty. | |
| 59 | + | func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error { | |
| 60 | + | if !validRef(ref) || step < 1 || step > domain.CIMaxSteps { | |
| 61 | + | return domain.ErrNotFound | |
| 62 | + | } | |
| 63 | + | if markdown == "" { | |
| 64 | + | if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) { | |
| 65 | + | return fmt.Errorf("remove summary: %w", err) | |
| 66 | + | } | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | dir := s.jobDir(ref) | |
| 70 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 71 | + | return fmt.Errorf("create summary directory: %w", err) | |
| 72 | + | } | |
| 73 | + | f, err := os.CreateTemp(dir, ".tmp-*") | |
| 74 | + | if err != nil { | |
| 75 | + | return fmt.Errorf("create summary: %w", err) | |
| 76 | + | } | |
| 77 | + | tmp := f.Name() | |
| 78 | + | _, werr := f.WriteString(markdown) | |
| 79 | + | serr := f.Sync() | |
| 80 | + | cerr := f.Close() | |
| 81 | + | if err := errors.Join(werr, serr, cerr); err != nil { | |
| 82 | + | _ = os.Remove(tmp) | |
| 83 | + | return fmt.Errorf("write summary: %w", err) | |
| 84 | + | } | |
| 85 | + | if err := os.Rename(tmp, s.path(ref, step)); err != nil { | |
| 86 | + | _ = os.Remove(tmp) | |
| 87 | + | return fmt.Errorf("store summary: %w", err) | |
| 88 | + | } | |
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // List returns the summaries of the steps that have one, by step number. | |
| 93 | + | func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) { | |
| 94 | + | if !validRef(ref) { | |
| 95 | + | return nil, false, nil | |
| 96 | + | } | |
| 97 | + | entries, err := os.ReadDir(s.jobDir(ref)) | |
| 98 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 99 | + | return nil, false, nil | |
| 100 | + | } | |
| 101 | + | if err != nil { | |
| 102 | + | return nil, false, fmt.Errorf("list summaries: %w", err) | |
| 103 | + | } | |
| 104 | + | var steps []int32 | |
| 105 | + | for _, e := range entries { | |
| 106 | + | name, ok := strings.CutSuffix(e.Name(), ext) | |
| 107 | + | if !ok || e.IsDir() { | |
| 108 | + | continue // a temporary file, or something else | |
| 109 | + | } | |
| 110 | + | n, err := strconv.ParseInt(name, 10, 32) | |
| 111 | + | if err != nil || n < 1 || strconv.FormatInt(n, 10) != name { | |
| 112 | + | continue | |
| 113 | + | } | |
| 114 | + | steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size | |
| 115 | + | } | |
| 116 | + | slices.Sort(steps) | |
| 117 | + | more := limit > 0 && len(steps) > limit | |
| 118 | + | if more { | |
| 119 | + | steps = steps[:limit] | |
| 120 | + | } | |
| 121 | + | out := make([]domain.CIStepSummary, 0, len(steps)) | |
| 122 | + | var read int64 | |
| 123 | + | exhausted := false | |
| 124 | + | for _, n := range steps { | |
| 125 | + | if exhausted { | |
| 126 | + | out = append(out, domain.CIStepSummary{Step: n, Omitted: true}) | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | if maxBytes > 0 { | |
| 130 | + | // Size the file first: a summary that does not fit is not read. | |
| 131 | + | info, err := os.Stat(s.path(ref, n)) | |
| 132 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 133 | + | continue // removed meanwhile | |
| 134 | + | } | |
| 135 | + | if err != nil { | |
| 136 | + | return nil, false, fmt.Errorf("stat summary: %w", err) | |
| 137 | + | } | |
| 138 | + | if read+info.Size() > maxBytes { | |
| 139 | + | exhausted = true | |
| 140 | + | out = append(out, domain.CIStepSummary{Step: n, Omitted: true}) | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | b, err := readCapped(s.path(ref, n), maxBytes, read) | |
| 145 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 146 | + | continue // removed meanwhile | |
| 147 | + | } | |
| 148 | + | if errors.Is(err, errOverBudget) { | |
| 149 | + | exhausted = true // replaced by a larger file since the stat | |
| 150 | + | out = append(out, domain.CIStepSummary{Step: n, Omitted: true}) | |
| 151 | + | continue | |
| 152 | + | } | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, false, fmt.Errorf("read summary: %w", err) | |
| 155 | + | } | |
| 156 | + | read += int64(len(b)) | |
| 157 | + | out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)}) | |
| 158 | + | } | |
| 159 | + | return out, more, nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | var errOverBudget = errors.New("over budget") | |
| 163 | + | ||
| 164 | + | // readCapped reads a file, at most what is left of maxBytes after read bytes | |
| 165 | + | // (no cap when maxBytes is 0): a file that is longer than that, because it grew | |
| 166 | + | // since it was sized, is errOverBudget and its content is dropped. | |
| 167 | + | func readCapped(path string, maxBytes, read int64) ([]byte, error) { | |
| 168 | + | f, err := os.Open(path) | |
| 169 | + | if err != nil { | |
| 170 | + | return nil, err | |
| 171 | + | } | |
| 172 | + | defer func() { _ = f.Close() }() | |
| 173 | + | if maxBytes <= 0 { | |
| 174 | + | return io.ReadAll(f) | |
| 175 | + | } | |
| 176 | + | left := maxBytes - read | |
| 177 | + | b, err := io.ReadAll(io.LimitReader(f, left+1)) | |
| 178 | + | if err != nil { | |
| 179 | + | return nil, err | |
| 180 | + | } | |
| 181 | + | if int64(len(b)) > left { | |
| 182 | + | return nil, errOverBudget | |
| 183 | + | } | |
| 184 | + | return b, nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // RemoveJob deletes the summaries of one execution of a job. | |
| 188 | + | func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error { | |
| 189 | + | if !validRef(ref) { | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | if err := os.RemoveAll(s.jobDir(ref)); err != nil { | |
| 193 | + | return fmt.Errorf("remove summaries: %w", err) | |
| 194 | + | } | |
| 195 | + | return nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // RemoveRuns deletes the summaries of the runs. | |
| 199 | + | func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error { | |
| 200 | + | var errs []error | |
| 201 | + | for _, id := range runIDs { | |
| 202 | + | if id < 1 { | |
| 203 | + | continue | |
| 204 | + | } | |
| 205 | + | if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil { | |
| 206 | + | errs = append(errs, err) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | return errors.Join(errs...) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // RunIDs lists the runs that have a summary directory. | |
| 213 | + | func (s *Store) RunIDs(_ context.Context) ([]int64, error) { | |
| 214 | + | entries, err := os.ReadDir(s.root) | |
| 215 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 216 | + | return nil, nil | |
| 217 | + | } | |
| 218 | + | if err != nil { | |
| 219 | + | return nil, fmt.Errorf("list summaries: %w", err) | |
| 220 | + | } | |
| 221 | + | var ids []int64 | |
| 222 | + | for _, e := range entries { | |
| 223 | + | if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() { | |
| 224 | + | ids = append(ids, id) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | return ids, nil | |
| 228 | + | } | |
A internal/adapters/cisummaries/cisummaries_test.go +211 -0
| @@ -0,0 +1,211 @@ | |||
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 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, 0) | |
| 114 | + | assert.Empty(t, got) | |
| 115 | + | got, _, _ = s.List(ctx, second, 20, 0) | |
| 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, 0) | |
| 120 | + | assert.Empty(t, got) | |
| 121 | + | got, _, _ = s.List(ctx, third, 20, 0) | |
| 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, 0) | |
| 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, 0) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // With a byte budget the files are sized before they are read: a summary that | |
| 160 | + | // does not fit, and every one after it, comes back Omitted and is never opened. | |
| 161 | + | func TestListReadsNoMoreThanTheBudget(t *testing.T) { | |
| 162 | + | ctx := t.Context() | |
| 163 | + | root := t.TempDir() | |
| 164 | + | s := cisummaries.New(root) | |
| 165 | + | require.NoError(t, s.Put(ctx, ref, 1, strings.Repeat("a", 100))) | |
| 166 | + | require.NoError(t, s.Put(ctx, ref, 2, strings.Repeat("b", 100))) | |
| 167 | + | require.NoError(t, s.Put(ctx, ref, 3, strings.Repeat("c", 10))) | |
| 168 | + | require.NoError(t, s.Put(ctx, ref, 4, "d")) | |
| 169 | + | dir := filepath.Join(root, "7", "7-0-0", "1") | |
| 170 | + | // A file that cannot be opened proves it was sized, not read. | |
| 171 | + | if os.Geteuid() != 0 { | |
| 172 | + | for _, n := range []string{"2.md", "3.md", "4.md"} { | |
| 173 | + | require.NoError(t, os.Chmod(filepath.Join(dir, n), 0)) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | got, more, err := s.List(ctx, ref, 20, 150) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | assert.False(t, more) | |
| 180 | + | require.Len(t, got, 4) | |
| 181 | + | assert.Equal(t, strings.Repeat("a", 100), got[0].Markdown) | |
| 182 | + | assert.False(t, got[0].Omitted) | |
| 183 | + | for _, o := range got[1:] { | |
| 184 | + | assert.True(t, o.Omitted, "step %d", o.Step) | |
| 185 | + | assert.Empty(t, o.Markdown) | |
| 186 | + | } | |
| 187 | + | assert.EqualValues(t, []int32{1, 2, 3, 4}, []int32{got[0].Step, got[1].Step, got[2].Step, got[3].Step}, "the steps are still listed") | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestListBudgetBoundaries(t *testing.T) { | |
| 191 | + | ctx := t.Context() | |
| 192 | + | s := cisummaries.New(t.TempDir()) | |
| 193 | + | require.NoError(t, s.Put(ctx, ref, 1, "12345")) | |
| 194 | + | require.NoError(t, s.Put(ctx, ref, 2, "678")) | |
| 195 | + | ||
| 196 | + | got, _, _ := s.List(ctx, ref, 20, 8) | |
| 197 | + | assert.False(t, got[0].Omitted || got[1].Omitted, "exactly the budget fits") | |
| 198 | + | got, _, _ = s.List(ctx, ref, 20, 7) | |
| 199 | + | assert.False(t, got[0].Omitted) | |
| 200 | + | assert.True(t, got[1].Omitted, "one byte over") | |
| 201 | + | got, _, _ = s.List(ctx, ref, 20, 4) | |
| 202 | + | assert.True(t, got[0].Omitted && got[1].Omitted, "the first does not fit") | |
| 203 | + | got, _, _ = s.List(ctx, ref, 20, 0) | |
| 204 | + | assert.False(t, got[0].Omitted || got[1].Omitted, "no budget, no bound") | |
| 205 | + | ||
| 206 | + | // A smaller file after one that does not fit is still omitted: the order | |
| 207 | + | // of steps is kept and nothing is read past the first omission. | |
| 208 | + | require.NoError(t, s.Put(ctx, ref, 3, "x")) | |
| 209 | + | got, _, _ = s.List(ctx, ref, 20, 7) | |
| 210 | + | assert.True(t, got[2].Omitted) | |
| 211 | + | } | |
M internal/adapters/kvstore/ci.go +6 -3
| @@ -38,7 +38,8 @@ import ( | |||
| 38 | 38 | // ciactive/<job key> job assigned or running | |
| 39 | 39 | // jobtoken/<hash> job token (value: job key) | |
| 40 | 40 | // | |
| 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. | |
| 42 | 43 | const ( | |
| 43 | 44 | runPrefix = "run/" | |
| 44 | 45 | runSrcPrefix = "runsrc/" | |
| @@ -315,7 +316,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 315 | 316 | steps = append(steps, &storagepb.CIStep{ | |
| 316 | 317 | Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion), | |
| 317 | 318 | 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), | |
| 319 | 320 | }) | |
| 320 | 321 | } | |
| 321 | 322 | pb := &storagepb.CIJob{ | |
| @@ -367,7 +368,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||
| 367 | 368 | j.Steps = append(j.Steps, domain.CIStep{ | |
| 368 | 369 | Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()), | |
| 369 | 370 | 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()), | |
| 371 | 372 | }) | |
| 372 | 373 | } | |
| 373 | 374 | return j, nil | |
| @@ -510,6 +511,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||
| 510 | 511 | } | |
| 511 | 512 | } | |
| 512 | 513 | c.stageRepoTokens(ctx, b, w, guarded) | |
| 514 | + | c.stageAnnotations(ctx, b, w, guarded) | |
| 513 | 515 | // Checks are derived from the jobs, written in the same batch and under | |
| 514 | 516 | // the lock of their run: they are upserted without a precondition. | |
| 515 | 517 | for _, ck := range w.Checks { | |
| @@ -836,6 +838,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l | |||
| 836 | 838 | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | |
| 837 | 839 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | |
| 838 | 840 | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | |
| 841 | + | keys = append(keys, annotationKeys(ctx, snap, runID)...) | |
| 839 | 842 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | |
| 840 | 843 | keys = append(keys, slices.Clone(key)) | |
| 841 | 844 | 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 ( | |||
| 25 | 25 | // bounded: a diff has its own limits and blobs are streamed. | |
| 26 | 26 | const MaxRequestBytes = 1 << 20 | |
| 27 | 27 | ||
| 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 | + | ||
| 28 | 33 | // Features are the optional capabilities of the contract that this server | |
| 29 | 34 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 30 | 35 | // 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"} | |
| 32 | 37 | ||
| 33 | 38 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 34 | 39 | const FeaturePasskeys = "passkeys" | |
| @@ -100,7 +105,8 @@ func New(cfg Config) *API { | |||
| 100 | 105 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 101 | 106 | } | |
| 102 | 107 | 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...)) | |
| 104 | 110 | } | |
| 105 | 111 | if cfg.Runs != nil { | |
| 106 | 112 | 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 | |||
| 105 | 105 | if err != nil { | |
| 106 | 106 | return nil, s.failed(ctx, err) | |
| 107 | 107 | } | |
| 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 { | |
| 109 | 113 | return nil, s.failed(ctx, err) | |
| 110 | 114 | } | |
| 111 | 115 | return connect.NewResponse(&pb.UpdateJobResponse{}), nil | |
| @@ -162,9 +166,13 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb | |||
| 162 | 166 | if err != nil { | |
| 163 | 167 | return nil, s.failed(ctx, err) | |
| 164 | 168 | } | |
| 169 | + | anns, err := annotationsFromPB(req.Msg.GetAnnotations()) | |
| 170 | + | if err != nil { | |
| 171 | + | return nil, s.failed(ctx, err) | |
| 172 | + | } | |
| 165 | 173 | res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{ | |
| 166 | 174 | 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, | |
| 168 | 176 | }) | |
| 169 | 177 | if err != nil { | |
| 170 | 178 | return nil, s.failed(ctx, err) | |
| @@ -190,10 +198,12 @@ func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request | |||
| 190 | 198 | return connect.NewResponse(out), nil | |
| 191 | 199 | } | |
| 192 | 200 | ||
| 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 | |
| 197 | 207 | } | |
| 198 | 208 | ||
| 199 | 209 | // runService implements ermiteconnect.RunServiceHandler. | |
| @@ -334,13 +344,36 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R | |||
| 334 | 344 | return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil | |
| 335 | 345 | } | |
| 336 | 346 | ||
| 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 | |
| 342 | 362 | } | |
| 343 | 363 | ||
| 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(), 0) | |
| 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 | |
| 346 | 379 | } | |
M internal/adapters/rpc/ci_convert.go +67 -2
| @@ -149,13 +149,26 @@ func runToPB(r domain.CIRun) *pb.Run { | |||
| 149 | 149 | } | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 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 | + | ||
| 152 | 165 | func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step { | |
| 153 | 166 | ranges := domain.StepRanges(steps, lines) | |
| 154 | 167 | out := make([]*pb.Step, 0, len(steps)) | |
| 155 | 168 | for i, s := range steps { | |
| 156 | 169 | out = append(out, &pb.Step{ | |
| 157 | 170 | 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), | |
| 159 | 172 | LogStart: ranges[i].Start, LogEnd: ranges[i].End, | |
| 160 | 173 | }) | |
| 161 | 174 | } | |
| @@ -231,8 +244,12 @@ func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) { | |||
| 231 | 244 | if err != nil { | |
| 232 | 245 | return nil, err | |
| 233 | 246 | } | |
| 247 | + | outcome, err := conclusionFromPB(s.GetOutcome()) | |
| 248 | + | if err != nil { | |
| 249 | + | return nil, err | |
| 250 | + | } | |
| 234 | 251 | 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, | |
| 236 | 253 | StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()), | |
| 237 | 254 | }) | |
| 238 | 255 | } | |
| @@ -250,3 +267,51 @@ func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated { | |||
| 250 | 267 | func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated { | |
| 251 | 268 | return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}} | |
| 252 | 269 | } | |
| 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 ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 16 | 17 | "bnema.dev/ermite/internal/domain" | |
| 17 | 18 | "bnema.dev/ermite/internal/ports" | |
| 18 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| @@ -99,3 +100,119 @@ func TestRunnerServiceErrors(t *testing.T) { | |||
| 99 | 100 | requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated)) | |
| 100 | 101 | requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail | |
| 101 | 102 | } | |
| 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), int64(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 +106 -0
| @@ -62,6 +62,7 @@ templ RunDetail(l Layout, p RunPage) { | |||
| 62 | 62 | <div id="run-body"> | |
| 63 | 63 | @RunBody(p) | |
| 64 | 64 | </div> | |
| 65 | + | @RunSummaries(p.Summaries, p.SummariesMore) | |
| 65 | 66 | } | |
| 66 | 67 | } | |
| 67 | 68 | ||
| @@ -78,6 +79,12 @@ templ RunBody(p RunPage) { | |||
| 78 | 79 | if p.Error != "" { | |
| 79 | 80 | <pre class="run-err">{ p.Error }</pre> | |
| 80 | 81 | } | |
| 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 | + | } | |
| 81 | 88 | <ul class="jobs"> | |
| 82 | 89 | for _, j := range p.Jobs { | |
| 83 | 90 | <li id={ "job-" + j.ID }> | |
| @@ -107,6 +114,104 @@ templ RunBody(p RunPage) { | |||
| 107 | 114 | </ul> | |
| 108 | 115 | } | |
| 109 | 116 | ||
| 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, more bool) { | |
| 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 | + | if more { | |
| 211 | + | <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p> | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 110 | 215 | templ runRail(p RunPage) { | |
| 111 | 216 | <section aria-labelledby="r-run"> | |
| 112 | 217 | <h2 id="r-run">Run</h2> | |
| @@ -186,6 +291,7 @@ templ Job(l Layout, p JobPage) { | |||
| 186 | 291 | @Shell(l, jobRail(p)) { | |
| 187 | 292 | @LiveSlot("job-notice") | |
| 188 | 293 | @JobHead(p.JobRow, p.Total) | |
| 294 | + | @JobAnnotations(p.Annotations, p.AnnotationsMore) | |
| 189 | 295 | <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))) }> | |
| 190 | 296 | if p.Note != "" { | |
| 191 | 297 | <p class="dim">{ p.Note }</p> | |
M internal/adapters/web/actions_log.go +3 -0
| @@ -51,6 +51,9 @@ type JobPage struct { | |||
| 51 | 51 | // StreamHref is the live feed of the lines (logStream). | |
| 52 | 52 | StreamHref string | |
| 53 | 53 | Note string | |
| 54 | + | // Annotations are those of the job, shown above the log. | |
| 55 | + | Annotations []AnnotationView | |
| 56 | + | AnnotationsMore bool | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | // appendLogLine adds the line n of a job log to the lines shown. The markers | |
M internal/adapters/web/actions_pages.go +25 -2
| @@ -173,7 +173,17 @@ type RunPage struct { | |||
| 173 | 173 | Latest int32 | |
| 174 | 174 | Attempts []AttemptLink | |
| 175 | 175 | 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 | + | // SummariesMore says that some summaries are not shown (the page budget). | |
| 183 | + | SummariesMore bool | |
| 184 | + | Active bool | |
| 185 | + | // detail is what the page was built from. | |
| 186 | + | detail ports.RunDetail | |
| 177 | 187 | // Forms are the owner's actions; nil/empty when the visitor cannot. | |
| 178 | 188 | CancelAction, RerunAction, RerunFailedAction string | |
| 179 | 189 | CanRerunFailed bool | |
| @@ -284,6 +294,11 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int | |||
| 284 | 294 | if err != nil { | |
| 285 | 295 | return nil, 0, err | |
| 286 | 296 | } | |
| 297 | + | // The summaries are part of the page, not of the live body: the page | |
| 298 | + | // fetches itself again when the run completes (runBody), and they show then. | |
| 299 | + | if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil { | |
| 300 | + | return nil, 0, err | |
| 301 | + | } | |
| 287 | 302 | l := s.repoLayout(ctx, d, "actions") | |
| 288 | 303 | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | |
| 289 | 304 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID) | |
| @@ -303,7 +318,7 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int | |||
| 303 | 318 | run := detail.Run | |
| 304 | 319 | p := RunPage{ | |
| 305 | 320 | 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, | |
| 321 | + | Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, detail: detail, | |
| 307 | 322 | } | |
| 308 | 323 | for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- { | |
| 309 | 324 | p.Attempts = append(p.Attempts, AttemptLink{ | |
| @@ -318,6 +333,11 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int | |||
| 318 | 333 | } | |
| 319 | 334 | p.Jobs = append(p.Jobs, row) | |
| 320 | 335 | } | |
| 336 | + | // The annotations of the attempt shown (the jobs kept from an earlier attempt | |
| 337 | + | // by a re-run bring their own). | |
| 338 | + | if p.Annotations, p.AnnotationsMore, err = s.runAnnotations(ctx, d, run, run.Attempt, detail.Jobs, run.Attempt == detail.LatestAttempt); err != nil { | |
| 339 | + | return RunPage{}, err | |
| 340 | + | } | |
| 321 | 341 | if SignedIn(ctx) && run.Attempt == detail.LatestAttempt { | |
| 322 | 342 | base := runHref(d.name(), id) | |
| 323 | 343 | if p.Active { | |
| @@ -382,6 +402,9 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int | |||
| 382 | 402 | p.RawHref += "?attempt=" + strconv.Itoa(int(attempt)) | |
| 383 | 403 | } | |
| 384 | 404 | p.Follow = !view.Ended() | |
| 405 | + | if p.Annotations, p.AnnotationsMore, err = s.jobAnnotations(ctx, d, id, run.Run.Sha, view, attempt); err != nil { | |
| 406 | + | return nil, 0, err | |
| 407 | + | } | |
| 385 | 408 | p.StreamHref = jobHref(d.name(), id, jobID) + "/stream" | |
| 386 | 409 | lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines) | |
| 387 | 410 | if err != nil { | |
A internal/adapters/web/actions_results.go +251 -0
| @@ -0,0 +1,251 @@ | |||
| 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 int64 = 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 budget bytes of Markdown. The budget goes down as | |
| 160 | + | // summaries are read and is handed to the use case, which sizes the files | |
| 161 | + | // before it reads them: a page view reads at most budget bytes. Once it is used | |
| 162 | + | // up no further job is asked, and more is true: some summaries are not shown. | |
| 163 | + | func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun, jobs []ports.JobView, linkJobs bool, budget int64) (out []JobSummaries, more bool, err error) { | |
| 164 | + | for _, j := range jobs { | |
| 165 | + | if j.Phase != domain.CIPhaseRunning && !j.Ended() { | |
| 166 | + | continue // no step has run | |
| 167 | + | } | |
| 168 | + | if budget <= 0 { | |
| 169 | + | return out, true, nil | |
| 170 | + | } | |
| 171 | + | res, err := s.cfg.Runs.GetStepSummaries(ctx, principal(ctx), d.repo.Name, j.ID, j.Attempt, budget) | |
| 172 | + | if err != nil { | |
| 173 | + | return nil, false, err | |
| 174 | + | } | |
| 175 | + | if len(res.Summaries) == 0 { | |
| 176 | + | continue | |
| 177 | + | } | |
| 178 | + | info := jobInfoOf(d.name(), run.ID, j, linkJobs) | |
| 179 | + | js := JobSummaries{Job: info.name, JobHref: info.href, More: res.Truncated} | |
| 180 | + | for _, sm := range res.Summaries { | |
| 181 | + | name := "step " + strconv.Itoa(int(sm.Step)) | |
| 182 | + | if st, ok := info.steps[sm.Step]; ok && st.name != "" { | |
| 183 | + | name = st.name | |
| 184 | + | } | |
| 185 | + | v := SummaryView{Step: name} | |
| 186 | + | if sm.Omitted { | |
| 187 | + | v.Note = "this summary is not shown: the summaries of the page are over " + humanSize(maxSummaryPageBytes) + "." | |
| 188 | + | more = true | |
| 189 | + | budget = 0 // everything after an omitted summary is omitted too | |
| 190 | + | js.Steps = append(js.Steps, v) | |
| 191 | + | continue | |
| 192 | + | } | |
| 193 | + | budget -= int64(len(sm.Markdown)) | |
| 194 | + | prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-" | |
| 195 | + | h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix) | |
| 196 | + | if err != nil { | |
| 197 | + | v.Note = "this summary cannot be shown." | |
| 198 | + | } else { | |
| 199 | + | v.HTML = h | |
| 200 | + | } | |
| 201 | + | js.Steps = append(js.Steps, v) | |
| 202 | + | } | |
| 203 | + | out = append(out, js) | |
| 204 | + | } | |
| 205 | + | return out, more, nil | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // jobRunSha is the commit of the run a job belongs to, for the live fragments of | |
| 209 | + | // its page. A job id that is not one, or a run that cannot be read, is not found. | |
| 210 | + | func (s *Server) jobRunSha(ctx context.Context, repo, jobID string) (string, error) { | |
| 211 | + | name, err := domain.ParseRepoName(repo) | |
| 212 | + | if err != nil { | |
| 213 | + | return "", domain.ErrNotFound | |
| 214 | + | } | |
| 215 | + | runID, _, _, ok := domain.ParseJobID(jobID) | |
| 216 | + | if !ok { | |
| 217 | + | return "", domain.ErrNotFound | |
| 218 | + | } | |
| 219 | + | d, err := s.cfg.Runs.GetRun(ctx, principal(ctx), name, runID, 0) | |
| 220 | + | if err != nil { | |
| 221 | + | return "", err | |
| 222 | + | } | |
| 223 | + | return d.Run.Sha, nil | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // runAnnotations reads the annotations of a run attempt and builds their views. | |
| 227 | + | 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) { | |
| 228 | + | list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, run.ID, attempt, "") | |
| 229 | + | if err != nil { | |
| 230 | + | return nil, false, err | |
| 231 | + | } | |
| 232 | + | infos := make(map[string]jobInfo, len(jobs)) | |
| 233 | + | for _, j := range jobs { | |
| 234 | + | infos[j.ID] = jobInfoOf(d.name(), run.ID, j, linkJobs) | |
| 235 | + | } | |
| 236 | + | return annotationViews(d.name(), run.Sha, list, infos), list.Truncated, nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // jobAnnotations reads the annotations of one job and builds their views, | |
| 240 | + | // grouped by step. | |
| 241 | + | func (s *Server) jobAnnotations(ctx context.Context, d *repoData, runID int64, sha string, view ports.JobView, attempt int32) ([]AnnotationView, bool, error) { | |
| 242 | + | list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, runID, attempt, view.ID) | |
| 243 | + | if err != nil { | |
| 244 | + | return nil, false, err | |
| 245 | + | } | |
| 246 | + | views := annotationViews(d.name(), sha, list, map[string]jobInfo{view.ID: jobInfoOf(d.name(), runID, view, true)}) | |
| 247 | + | for i := range views { | |
| 248 | + | views[i].StepHead = i == 0 || views[i-1].Step != views[i].Step | |
| 249 | + | } | |
| 250 | + | return views, list.Truncated, nil | |
| 251 | + | } | |
A internal/adapters/web/actions_results_test.go +237 -0
| @@ -0,0 +1,237 @@ | |||
| 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), maxSummaryPageBytes).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 <b>two</b>", "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 <script>", "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 | + | } | |
| 131 | + | ||
| 132 | + | func endedJob(t *testing.T, repo domain.RepoName, id string) ports.JobView { | |
| 133 | + | t.Helper() | |
| 134 | + | return ports.JobView{CIJob: domain.CIJob{ | |
| 135 | + | ID: id, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "k", Name: "job " + id, | |
| 136 | + | Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess, | |
| 137 | + | }} | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, runs *mocks.RunService) *httptest.ResponseRecorder { | |
| 141 | + | t.Helper() | |
| 142 | + | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |
| 143 | + | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess}, | |
| 144 | + | Jobs: jobs, LatestAttempt: 1, | |
| 145 | + | }, nil) | |
| 146 | + | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil) | |
| 147 | + | browse := mocks.NewBrowseService(t) | |
| 148 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 149 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: browse, Runs: runs}) | |
| 150 | + | rec := httptest.NewRecorder() | |
| 151 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/proj/actions/runs/1", nil)) | |
| 152 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 153 | + | return rec | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // GO-001: once the render budget is used up no other job is asked for its | |
| 157 | + | // summaries, and the page says that some are not shown. | |
| 158 | + | func TestSummariesStopAtTheBudget(t *testing.T) { | |
| 159 | + | t.Parallel() | |
| 160 | + | repo := repoName(t, "proj") | |
| 161 | + | jobs := []ports.JobView{endedJob(t, repo, "1-0-0"), endedJob(t, repo, "1-1-0"), endedJob(t, repo, "1-2-0")} | |
| 162 | + | runs := mocks.NewRunService(t) | |
| 163 | + | // The first job takes the whole budget; the other two are never asked | |
| 164 | + | // (the mock fails the test on a call it does not expect). | |
| 165 | + | full := strings.Repeat("x", int(maxSummaryPageBytes)) | |
| 166 | + | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{ | |
| 167 | + | Summaries: []domain.CIStepSummary{{Step: 1, Markdown: full}}, | |
| 168 | + | }, nil).Once() | |
| 169 | + | body := runPageServer(t, repo, jobs, runs).Body.String() | |
| 170 | + | assert.Contains(t, body, "more summaries are not shown") | |
| 171 | + | assert.Contains(t, body, `aria-label="Summary of job 1-0-0"`) | |
| 172 | + | assert.NotContains(t, body, `aria-label="Summary of job 1-1-0"`, "no summary of the other jobs") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // The budget handed to the next job is what is left; a summary the store left | |
| 176 | + | // unread is shown as such, and so are the ones after it. | |
| 177 | + | func TestSummariesPassWhatIsLeftOfTheBudget(t *testing.T) { | |
| 178 | + | t.Parallel() | |
| 179 | + | repo := repoName(t, "proj") | |
| 180 | + | jobs := []ports.JobView{endedJob(t, repo, "1-0-0"), endedJob(t, repo, "1-1-0"), endedJob(t, repo, "1-2-0")} | |
| 181 | + | runs := mocks.NewRunService(t) | |
| 182 | + | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{ | |
| 183 | + | Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "hello"}}, | |
| 184 | + | }, nil).Once() | |
| 185 | + | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-1-0", int32(1), maxSummaryPageBytes-5).Return(ports.SummaryList{ | |
| 186 | + | Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "world"}, {Step: 2, Omitted: true}, {Step: 3, Omitted: true}}, | |
| 187 | + | }, nil).Once() | |
| 188 | + | body := runPageServer(t, repo, jobs, runs).Body.String() | |
| 189 | + | assert.Contains(t, body, "hello") | |
| 190 | + | assert.Contains(t, body, "world") | |
| 191 | + | assert.Equal(t, 2, strings.Count(body, "this summary is not shown")) | |
| 192 | + | assert.Contains(t, body, "more summaries are not shown") | |
| 193 | + | assert.NotContains(t, body, `aria-label="Summary of job 1-2-0"`, "the third job is not asked once an omission happened") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestNoNoteWhenEverythingFits(t *testing.T) { | |
| 197 | + | t.Parallel() | |
| 198 | + | repo := repoName(t, "proj") | |
| 199 | + | runs := mocks.NewRunService(t) | |
| 200 | + | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{ | |
| 201 | + | Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "small"}}, | |
| 202 | + | }, nil).Once() | |
| 203 | + | body := runPageServer(t, repo, []ports.JobView{endedJob(t, repo, "1-0-0")}, runs).Body.String() | |
| 204 | + | assert.Contains(t, body, "small") | |
| 205 | + | assert.NotContains(t, body, "not shown") | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // GO-002: a live job update reads the job and its annotations, never the run; | |
| 209 | + | // the commit comes from the stream, and a stream without it has the page fetch | |
| 210 | + | // itself again. | |
| 211 | + | func TestJobStateDoesNotReadTheRun(t *testing.T) { | |
| 212 | + | t.Parallel() | |
| 213 | + | const jobID = "1-0-0" | |
| 214 | + | repo := repoName(t, "proj") | |
| 215 | + | job := endedJob(t, repo, jobID) | |
| 216 | + | job.Steps = []domain.CIStep{{Number: 1, Name: "s", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, HasLines: true}} | |
| 217 | + | job.Lines = 2 | |
| 218 | + | sha := strings.Repeat("c", 40) | |
| 219 | + | ||
| 220 | + | runs := mocks.NewRunService(t) // GetRun is not expected | |
| 221 | + | runs.EXPECT().GetJob(mock.Anything, mock.Anything, repo, jobID, int32(0)).Return(job, nil) | |
| 222 | + | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{ | |
| 223 | + | Annotations: []ports.JobAnnotation{{JobID: jobID, Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 2, Step: 1}}}, | |
| 224 | + | }, nil) | |
| 225 | + | browse := mocks.NewBrowseService(t) | |
| 226 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 227 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs}) | |
| 228 | + | ||
| 229 | + | frags, err := (&liveStream{s: srv, repo: "proj", view: "job", job: jobID, sha: sha}).jobState(context.Background()) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | require.Len(t, frags, 3) | |
| 232 | + | assert.Contains(t, frags[2].html, `href="/proj/blob/`+sha+`/a.go#L2"`, "the annotation links to the commit the stream was set up with") | |
| 233 | + | ||
| 234 | + | frags, err = (&liveStream{s: srv, repo: "proj", view: "job", job: jobID}).jobState(context.Background()) | |
| 235 | + | require.NoError(t, err) | |
| 236 | + | assert.Equal(t, gap, frags) | |
| 237 | + | } | |
M internal/adapters/web/actions_templ.go +1033 -400 generated file: collapsed
M internal/adapters/web/actions_test.go +1 -0
| @@ -38,6 +38,7 @@ func TestLogPagesPassTheClientKey(t *testing.T) { | |||
| 38 | 38 | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |
| 39 | 39 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1, | |
| 40 | 40 | }, nil).Maybe() | |
| 41 | + | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{}, nil).Maybe() | |
| 41 | 42 | var seen []string | |
| 42 | 43 | runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn( | |
| 43 | 44 | func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error { | |
M internal/adapters/web/events.go +17 -1
| @@ -120,6 +120,22 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 120 | 120 | } | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | + | // The commit of the run of a job page is read once here: the annotations of | |
| 124 | + | // the live job fragments link to files at it, and it never changes, so a | |
| 125 | + | // job update does not read the run again. A run that is not there or not | |
| 126 | + | // readable refuses the stream like a missing repository. | |
| 127 | + | var sha string | |
| 128 | + | if repo != "" && q.Get("view") == "job" && s.cfg.Runs != nil { | |
| 129 | + | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | |
| 130 | + | var err error | |
| 131 | + | sha, err = s.jobRunSha(cctx, repo, q.Get("job")) | |
| 132 | + | ccancel() | |
| 133 | + | if err != nil { | |
| 134 | + | s.denyEvents(ctx, w, err) | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 123 | 139 | // The watch runs in a goroutine and hands each event to this one through | |
| 124 | 140 | // an unbuffered channel: a visitor that reads slowly holds the watch in | |
| 125 | 141 | // its send, falls behind the buffer of the use case, and gets a gap. | |
| @@ -202,7 +218,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 202 | 218 | return | |
| 203 | 219 | } | |
| 204 | 220 | ||
| 205 | - | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")} | |
| 221 | + | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha} | |
| 206 | 222 | handle := func(m recvd) bool { | |
| 207 | 223 | if m.err != nil { | |
| 208 | 224 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | |
M internal/adapters/web/fragments.go +15 -1
| @@ -26,6 +26,9 @@ type liveStream struct { | |||
| 26 | 26 | head string // commits: the commit listed first | |
| 27 | 27 | n string // mr: the number of the merge request shown; run: the run id | |
| 28 | 28 | job string // job: the id of the job shown | |
| 29 | + | // sha is the commit of the run of the job shown, read once when the stream | |
| 30 | + | // is set up: the annotations link to files at it, and it never changes. | |
| 31 | + | sha string | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | // renderFragment renders a component to the data of an event. | |
| @@ -478,6 +481,9 @@ func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) { | |||
| 478 | 481 | // jobState tells the job page that its state or its steps changed: the page | |
| 479 | 482 | // reloads the part of the log after the last line it holds (ermite.js). | |
| 480 | 483 | func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |
| 484 | + | if l.sha == "" { | |
| 485 | + | return gap, nil // the stream was set up without the commit of the run | |
| 486 | + | } | |
| 481 | 487 | d, err := l.s.loadRepo(ctx, l.repo) | |
| 482 | 488 | if err != nil { | |
| 483 | 489 | return nil, err | |
| @@ -496,5 +502,13 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |||
| 496 | 502 | if err != nil { | |
| 497 | 503 | return nil, err | |
| 498 | 504 | } | |
| 499 | - | return []fragment{f, steps}, nil | |
| 505 | + | anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, l.sha, view, 0) | |
| 506 | + | if err != nil { | |
| 507 | + | return nil, err | |
| 508 | + | } | |
| 509 | + | notes, err := renderFragment(ctx, "ci-job", JobAnnotations(anns, more)) | |
| 510 | + | if err != nil { | |
| 511 | + | return nil, err | |
| 512 | + | } | |
| 513 | + | return []fragment{f, steps, notes}, nil | |
| 500 | 514 | } | |
M internal/adapters/web/markdown.go +17 -5
| @@ -55,9 +55,16 @@ func newMarkdown() *markdown { | |||
| 55 | 55 | // are where relative links point; dir is the directory of the Markdown file | |
| 56 | 56 | // in the repository, "" for the root. | |
| 57 | 57 | 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()})) | |
| 59 | 66 | 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} | |
| 61 | 68 | pol.apply(doc) | |
| 62 | 69 | ||
| 63 | 70 | var buf bytes.Buffer | |
| @@ -70,10 +77,13 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, | |||
| 70 | 77 | } | |
| 71 | 78 | ||
| 72 | 79 | // 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 | + | } | |
| 74 | 84 | ||
| 75 | 85 | 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)...) | |
| 77 | 87 | } | |
| 78 | 88 | ||
| 79 | 89 | func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) } | |
| @@ -119,6 +129,8 @@ func plainText(n ast.Node, source []byte) string { | |||
| 119 | 129 | type linkPolicy struct { | |
| 120 | 130 | repo, ref, dir string | |
| 121 | 131 | source []byte | |
| 132 | + | // idPrefix is the prefix of the heading ids that #fragment links point at. | |
| 133 | + | idPrefix string | |
| 122 | 134 | } | |
| 123 | 135 | ||
| 124 | 136 | func (p linkPolicy) apply(doc ast.Node) { | |
| @@ -202,7 +214,7 @@ func (p linkPolicy) target(dest string) (string, linkKind) { | |||
| 202 | 214 | if frag == "" || strings.ContainsAny(frag, " \"'<>") { | |
| 203 | 215 | return "", dropped | |
| 204 | 216 | } | |
| 205 | - | return "#" + headingIDPrefix + frag, internal | |
| 217 | + | return "#" + p.idPrefix + frag, internal | |
| 206 | 218 | } | |
| 207 | 219 | if strings.HasPrefix(dest, "//") { | |
| 208 | 220 | return "", dropped | |
M internal/adapters/web/static/css/65-ci.css +29 -0
| @@ -37,6 +37,35 @@ | |||
| 37 | 37 | .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); } | |
| 38 | 38 | .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); } | |
| 39 | 39 | ||
| 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 | + | ||
| 40 | 69 | .joblog { margin-top: var(--s4); } | |
| 41 | 70 | .log { | |
| 42 | 71 | 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) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | 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) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
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 | + | "", | |
| 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 <i>failed</i>", "a title is text") | |
| 388 | + | assert.Contains(t, run.body, "unused <img src=x onerror=alert(1)>") | |
| 389 | + | assert.NotContains(t, run.body, evil) | |
| 390 | + | assert.Contains(t, run.body, "Run <b>vet</b>", "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 <img src=x onerror=alert(1)>") | |
| 419 | + | assert.NotContains(t, jobPage.body, evil) | |
| 420 | + | assert.Less(t, strings.Index(jobPage.body, "plain notice"), strings.Index(jobPage.body, "unused <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 <b>warning</b>") { | |
| 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) { | |||
| 326 | 326 | ver := decode[versionOut](t, res.stdout) | |
| 327 | 327 | assert.Equal(t, c.url, ver.Server.URL) | |
| 328 | 328 | 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) | |
| 330 | 330 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 331 | 331 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 332 | 332 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_run.go +238 -15
| @@ -40,10 +40,13 @@ type jsonRun struct { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | 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"` | |
| 47 | 50 | StartedAt *string `json:"started_at"` | |
| 48 | 51 | CompletedAt *string `json:"completed_at"` | |
| 49 | 52 | LogStart int64 `json:"log_start"` | |
| @@ -67,10 +70,28 @@ type jsonRunJob struct { | |||
| 67 | 70 | CompletedAt *string `json:"completed_at"` | |
| 68 | 71 | } | |
| 69 | 72 | ||
| 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 | + | ||
| 70 | 88 | type jsonRunDetail struct { | |
| 71 | 89 | 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"` | |
| 74 | 95 | } | |
| 75 | 96 | ||
| 76 | 97 | const jsonRunHelp = ` | |
| @@ -79,7 +100,9 @@ ref, sha, actor, status (queued, in_progress, completed), conclusion (success, f | |||
| 79 | 100 | skipped, timed_out, neutral, action_required; empty until completed), error, created_at, | |
| 80 | 101 | started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and | |
| 81 | 102 | 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.` | |
| 83 | 106 | ||
| 84 | 107 | func statusText(s ermite.CiStatus) string { | |
| 85 | 108 | return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_")) | |
| @@ -111,6 +134,15 @@ func runResult(status ermite.CiStatus, c ermite.CiConclusion) string { | |||
| 111 | 134 | return statusText(status) | |
| 112 | 135 | } | |
| 113 | 136 | ||
| 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 | + | ||
| 114 | 146 | func toJSONRun(r *ermite.Run) jsonRun { | |
| 115 | 147 | return jsonRun{ | |
| 116 | 148 | 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 { | |||
| 125 | 157 | for _, s := range j.GetSteps() { | |
| 126 | 158 | steps = append(steps, jsonStep{ | |
| 127 | 159 | 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(), | |
| 129 | 161 | }) | |
| 130 | 162 | } | |
| 131 | 163 | labels, needs := j.GetLabels(), j.GetNeeds() | |
| @@ -254,21 +286,27 @@ func parseStatusFlag(s string) (ermite.CiStatus, error) { | |||
| 254 | 286 | ||
| 255 | 287 | func newRunViewCmd(env *cliEnv) *cobra.Command { | |
| 256 | 288 | var attempt int32 | |
| 257 | - | var logFlag bool | |
| 289 | + | var logFlag, summaryFlag bool | |
| 258 | 290 | var jobFlag string | |
| 259 | 291 | cmd := &cobra.Command{ | |
| 260 | 292 | 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" + | |
| 263 | 296 | "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, | |
| 265 | 300 | Args: cobra.ExactArgs(2), | |
| 266 | 301 | ValidArgsFunction: completeRepoNames(env), | |
| 267 | 302 | } | |
| 268 | 303 | cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)") | |
| 269 | 304 | 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)") | |
| 271 | 307 | asJSON := addJSONFlag(cmd) | |
| 308 | + | cmd.MarkFlagsMutuallyExclusive("log", "summary") | |
| 309 | + | cmd.MarkFlagsMutuallyExclusive("json", "summary") | |
| 272 | 310 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 273 | 311 | id, err := parseRunID(args[1]) | |
| 274 | 312 | if err != nil { | |
| @@ -286,14 +324,30 @@ func newRunViewCmd(env *cliEnv) *cobra.Command { | |||
| 286 | 324 | if logFlag { | |
| 287 | 325 | return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false) | |
| 288 | 326 | } | |
| 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 | + | } | |
| 289 | 334 | 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 | + | } | |
| 291 | 339 | for _, j := range res.Msg.GetJobs() { | |
| 292 | 340 | d.Jobs = append(d.Jobs, toJSONRunJob(j)) | |
| 293 | 341 | } | |
| 342 | + | for _, a := range anns.items { | |
| 343 | + | d.Annotations = append(d.Annotations, toJSONAnnotation(a, anns.jobs)) | |
| 344 | + | } | |
| 294 | 345 | return writeJSON(cmd.OutOrStdout(), d) | |
| 295 | 346 | } | |
| 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) | |
| 297 | 351 | } | |
| 298 | 352 | return cmd | |
| 299 | 353 | } | |
| @@ -340,6 +394,175 @@ func printRun(w io.Writer, r *ermite.GetRunResponse) error { | |||
| 340 | 394 | return nil | |
| 341 | 395 | } | |
| 342 | 396 | ||
| 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 | + | ||
| 343 | 566 | // pickJobs selects the jobs --job names (an id or a name), or all of them. | |
| 344 | 567 | func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) { | |
| 345 | 568 | if sel == "" { | |
M internal/app/forge.go +2 -1
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | 18 | "bnema.dev/ermite/internal/adapters/cilogs" | |
| 19 | + | "bnema.dev/ermite/internal/adapters/cisummaries" | |
| 19 | 20 | "bnema.dev/ermite/internal/adapters/ermitegit" | |
| 20 | 21 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 21 | 22 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| @@ -212,7 +213,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 212 | 213 | // CI (ADR 0025): the engine reads workflows through the reader, and plans | |
| 213 | 214 | // them in the background; a push only queues its refs. | |
| 214 | 215 | 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, | |
| 216 | 217 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 217 | 218 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 218 | 219 | PollTimeout: cfg.CIPollTimeout, Archive: git, | |
M internal/domain/ci.go +8 -4
| @@ -198,10 +198,14 @@ type CIRunSource struct { | |||
| 198 | 198 | ||
| 199 | 199 | // CIStep is a step of a job with where its lines are in the job log. | |
| 200 | 200 | 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 | |
| 205 | 209 | StartedAt time.Time | |
| 206 | 210 | CompletedAt time.Time | |
| 207 | 211 | LogStart int64 | |
A internal/domain/ci_annotation.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 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 | + | // Omitted is set when the summary exists but was not read, because the | |
| 61 | + | // caller's byte budget was used up; Markdown is then empty. | |
| 62 | + | Omitted bool | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // CheckAnnotations validates the annotations of a request: the limits of the | |
| 66 | + | // contract, whole or not at all. | |
| 67 | + | func CheckAnnotations(anns []CIAnnotation) error { | |
| 68 | + | type key struct { | |
| 69 | + | step int32 | |
| 70 | + | level AnnotationLevel | |
| 71 | + | } | |
| 72 | + | count := map[key]int{} | |
| 73 | + | for i, a := range anns { | |
| 74 | + | switch { | |
| 75 | + | case !a.Level.Valid(): | |
| 76 | + | return fmt.Errorf("%w: annotation %d has no level", ErrInvalid, i) | |
| 77 | + | case a.Step < 1 || a.Step > CIMaxSteps: | |
| 78 | + | return fmt.Errorf("%w: annotation %d: step number %d is not between 1 and %d", ErrInvalid, i, a.Step, CIMaxSteps) | |
| 79 | + | case a.Message == "": | |
| 80 | + | return fmt.Errorf("%w: annotation %d has no message", ErrInvalid, i) | |
| 81 | + | case utf8.RuneCountInString(a.Message) > CIMaxAnnotationMessage: | |
| 82 | + | return fmt.Errorf("%w: annotation %d: the message is longer than %d characters", ErrInvalid, i, CIMaxAnnotationMessage) | |
| 83 | + | case utf8.RuneCountInString(a.Title) > CIMaxAnnotationTitle: | |
| 84 | + | return fmt.Errorf("%w: annotation %d: the title is longer than %d characters", ErrInvalid, i, CIMaxAnnotationTitle) | |
| 85 | + | case utf8.RuneCountInString(a.File) > CIMaxAnnotationFile: | |
| 86 | + | return fmt.Errorf("%w: annotation %d: the file is longer than %d characters", ErrInvalid, i, CIMaxAnnotationFile) | |
| 87 | + | case !utf8.ValidString(a.Message) || !utf8.ValidString(a.Title) || !utf8.ValidString(a.File): | |
| 88 | + | return fmt.Errorf("%w: annotation %d is not valid UTF-8", ErrInvalid, i) | |
| 89 | + | case a.StartLine < 0 || a.EndLine < 0 || a.StartColumn < 0 || a.EndColumn < 0: | |
| 90 | + | return fmt.Errorf("%w: annotation %d: lines and columns are not negative", ErrInvalid, i) | |
| 91 | + | case a.EndLine != 0 && a.EndLine < a.StartLine: | |
| 92 | + | return fmt.Errorf("%w: annotation %d: end_line is before start_line", ErrInvalid, i) | |
| 93 | + | } | |
| 94 | + | k := key{a.Step, a.Level} | |
| 95 | + | if count[k]++; count[k] > CIMaxAnnotationsPerLevel { | |
| 96 | + | return fmt.Errorf("%w: step %d has more than %d annotations of level %s", ErrInvalid, a.Step, CIMaxAnnotationsPerLevel, a.Level) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // GroupAnnotations groups annotations by step number, each group in the order | |
| 103 | + | // of the input, and returns the step numbers in ascending order. | |
| 104 | + | func GroupAnnotations(anns []CIAnnotation) (map[int32][]CIAnnotation, []int32) { | |
| 105 | + | by := map[int32][]CIAnnotation{} | |
| 106 | + | var steps []int32 | |
| 107 | + | for _, a := range anns { | |
| 108 | + | if _, ok := by[a.Step]; !ok { | |
| 109 | + | steps = append(steps, a.Step) | |
| 110 | + | } | |
| 111 | + | by[a.Step] = append(by[a.Step], a) | |
| 112 | + | } | |
| 113 | + | slices.Sort(steps) | |
| 114 | + | return by, steps | |
| 115 | + | } | |
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 +99 -14
| @@ -30,13 +30,30 @@ type CIRunDraft struct { | |||
| 30 | 30 | // exist yet), RevokeJobs the jobs whose repository tokens are deleted by it, | |
| 31 | 31 | // and Resolutions the action resolutions of a run attempt to keep. They are | |
| 32 | 32 | // 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. | |
| 33 | 38 | 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 | |
| 40 | 57 | } | |
| 41 | 58 | ||
| 42 | 59 | // CIJobRef names one execution of a job. | |
| @@ -84,6 +101,10 @@ type CIStore interface { | |||
| 84 | 101 | RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |
| 85 | 102 | // ListOpenRuns returns the runs that have not completed. | |
| 86 | 103 | 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) | |
| 87 | 108 | // GetJob returns a job of a run at an attempt of the run, or | |
| 88 | 109 | // domain.ErrNotFound. | |
| 89 | 110 | GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) | |
| @@ -122,8 +143,8 @@ type CIStore interface { | |||
| 122 | 143 | } | |
| 123 | 144 | ||
| 124 | 145 | // 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. | |
| 127 | 148 | type CIRunCleaner interface { | |
| 128 | 149 | RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |
| 129 | 150 | RemoveLogs(ctx context.Context, runIDs []int64) error | |
| @@ -138,7 +159,8 @@ type CIChecksReader interface { | |||
| 138 | 159 | CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) | |
| 139 | 160 | } | |
| 140 | 161 | ||
| 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. | |
| 142 | 164 | type CILogRef struct { | |
| 143 | 165 | RunID int64 | |
| 144 | 166 | JobID string | |
| @@ -188,6 +210,29 @@ type CILogStore interface { | |||
| 188 | 210 | RunIDs(ctx context.Context) ([]int64, error) | |
| 189 | 211 | } | |
| 190 | 212 | ||
| 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. With maxBytes above | |
| 222 | + | // 0 the files are sized (stat) before they are read, and a summary that | |
| 223 | + | // would take the total of the Markdown read past maxBytes is returned | |
| 224 | + | // Omitted, unread, as are all the ones after it: a call never reads more | |
| 225 | + | // than maxBytes. | |
| 226 | + | List(ctx context.Context, ref CILogRef, limit int, maxBytes int64) (summaries []domain.CIStepSummary, more bool, err error) | |
| 227 | + | // RemoveJob deletes the summaries of one execution of a job. | |
| 228 | + | RemoveJob(ctx context.Context, ref CILogRef) error | |
| 229 | + | // RemoveRuns deletes the summaries of the runs. | |
| 230 | + | RemoveRuns(ctx context.Context, runIDs []int64) error | |
| 231 | + | // RunIDs lists the runs that have a summary directory, for the orphan | |
| 232 | + | // sweep. | |
| 233 | + | RunIDs(ctx context.Context) ([]int64, error) | |
| 234 | + | } | |
| 235 | + | ||
| 191 | 236 | // WorkflowTrigger starts the workflows that match pushed refs. OnPush only | |
| 192 | 237 | // queues the work and returns: planning happens in the background and its | |
| 193 | 238 | // errors never reach the push. | |
| @@ -258,10 +303,13 @@ type FetchJobResult struct { | |||
| 258 | 303 | ||
| 259 | 304 | // StepUpdate is the state of one step as a runner reports it. | |
| 260 | 305 | type StepUpdate struct { | |
| 261 | - | Number int32 | |
| 262 | - | Name string | |
| 263 | - | State domain.CIStepState | |
| 264 | - | Conclusion domain.CIConclusion | |
| 306 | + | Number int32 | |
| 307 | + | Name string | |
| 308 | + | State domain.CIStepState | |
| 309 | + | Conclusion domain.CIConclusion | |
| 310 | + | // Outcome is how the step ended before continue-on-error; none when the | |
| 311 | + | // runner does not report it. | |
| 312 | + | Outcome domain.CIConclusion | |
| 265 | 313 | StartedAt time.Time | |
| 266 | 314 | CompletedAt time.Time | |
| 267 | 315 | } | |
| @@ -273,6 +321,8 @@ type CompleteJobParams struct { | |||
| 273 | 321 | Outputs map[string]string | |
| 274 | 322 | RunnerShutdown bool | |
| 275 | 323 | Steps []StepUpdate | |
| 324 | + | // Annotations replace those of each step they name, as in UpdateJob. | |
| 325 | + | Annotations []domain.CIAnnotation | |
| 276 | 326 | } | |
| 277 | 327 | ||
| 278 | 328 | // CompleteJobResult is the stored result of a job. | |
| @@ -337,7 +387,10 @@ type JobCancel struct { | |||
| 337 | 387 | type RunnerService interface { | |
| 338 | 388 | Register(ctx context.Context, p domain.Principal, params RegisterRunnerParams) (RegisteredRunner, error) | |
| 339 | 389 | FetchJob(ctx context.Context, secret string, params FetchJobParams) (FetchJobResult, error) | |
| 340 | - | UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error | |
| 390 | + | // UpdateJob applies the steps and the annotations whole or not at all: for | |
| 391 | + | // each step number present in annotations, the annotations of that step are | |
| 392 | + | // replaced (also for a step already completed, or not in steps). | |
| 393 | + | UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate, annotations []domain.CIAnnotation) error | |
| 341 | 394 | AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error) | |
| 342 | 395 | Heartbeat(ctx context.Context, secret, jobID string) error | |
| 343 | 396 | // WatchJob calls ready once the job token is valid and the stream is | |
| @@ -350,6 +403,10 @@ type RunnerService interface { | |||
| 350 | 403 | // an error: its answer carries Error. A local ("./") or Docker | |
| 351 | 404 | // ("docker://") reference, or more than 100, is domain.ErrInvalid. | |
| 352 | 405 | ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error) | |
| 406 | + | // UploadSummary stores the summary of a step, replacing the one it has; an | |
| 407 | + | // empty markdown removes it. More than 1 MiB, invalid UTF-8 or a step | |
| 408 | + | // number outside 1 to 1,000 is domain.ErrInvalid. | |
| 409 | + | UploadSummary(ctx context.Context, secret, jobID string, step int32, markdown string) error | |
| 353 | 410 | } | |
| 354 | 411 | ||
| 355 | 412 | // JobView is a job as owners see it. | |
| @@ -445,6 +502,25 @@ type RunnerView struct { | |||
| 445 | 502 | Busy bool | |
| 446 | 503 | } | |
| 447 | 504 | ||
| 505 | + | // JobAnnotation is an annotation with the job it belongs to. | |
| 506 | + | type JobAnnotation struct { | |
| 507 | + | JobID string | |
| 508 | + | Annotation domain.CIAnnotation | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // AnnotationList are the annotations of a run, at most | |
| 512 | + | // domain.CIMaxListedAnnotations. | |
| 513 | + | type AnnotationList struct { | |
| 514 | + | Annotations []JobAnnotation | |
| 515 | + | Truncated bool | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // SummaryList are the summaries of a job, at most domain.CIMaxListedSummaries. | |
| 519 | + | type SummaryList struct { | |
| 520 | + | Summaries []domain.CIStepSummary | |
| 521 | + | Truncated bool | |
| 522 | + | } | |
| 523 | + | ||
| 448 | 524 | // RunService is the owner side of CI. *usecase.CI implements it. | |
| 449 | 525 | type RunService interface { | |
| 450 | 526 | ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRunsParams) (RunPage, error) | |
| @@ -454,6 +530,15 @@ type RunService interface { | |||
| 454 | 530 | CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error) | |
| 455 | 531 | RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) | |
| 456 | 532 | ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error) | |
| 533 | + | // ListAnnotations returns the annotations of a run attempt (0 for the | |
| 534 | + | // latest), of one job when jobID is not empty. Each job gives those of the | |
| 535 | + | // attempt in which it executed. | |
| 536 | + | ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error) | |
| 537 | + | // GetStepSummaries returns the summaries of a job at an attempt (0 for the | |
| 538 | + | // latest), by step number. maxBytes above 0 bounds the Markdown read: the | |
| 539 | + | // summaries past it come back Omitted, without their Markdown (the API | |
| 540 | + | // passes 0, no bound). | |
| 541 | + | GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64) (SummaryList, error) | |
| 457 | 542 | ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error) | |
| 458 | 543 | RemoveRunner(ctx context.Context, p domain.Principal, id string) error | |
| 459 | 544 | } | |
M internal/ports/mocks/CIStore.go +80 -0
| @@ -765,6 +765,86 @@ func (_c *CIStore_GetRunner_Call) RunAndReturn(run func(ctx context.Context, id | |||
| 765 | 765 | return _c | |
| 766 | 766 | } | |
| 767 | 767 | ||
| 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 | + | ||
| 768 | 848 | // ListActiveJobs provides a mock function for the type CIStore | |
| 769 | 849 | func (_mock *CIStore) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) { | |
| 770 | 850 | ret := _mock.Called(ctx) | |
A internal/ports/mocks/CISummaryStore.go +380 -0
| @@ -0,0 +1,380 @@ | |||
| 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, maxBytes int64) ([]domain.CIStepSummary, bool, error) { | |
| 53 | + | ret := _mock.Called(ctx, ref, limit, maxBytes) | |
| 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, int64) ([]domain.CIStepSummary, bool, error)); ok { | |
| 63 | + | return returnFunc(ctx, ref, limit, maxBytes) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int, int64) []domain.CIStepSummary); ok { | |
| 66 | + | r0 = returnFunc(ctx, ref, limit, maxBytes) | |
| 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, int64) bool); ok { | |
| 73 | + | r1 = returnFunc(ctx, ref, limit, maxBytes) | |
| 74 | + | } else { | |
| 75 | + | r1 = ret.Get(1).(bool) | |
| 76 | + | } | |
| 77 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int, int64) error); ok { | |
| 78 | + | r2 = returnFunc(ctx, ref, limit, maxBytes) | |
| 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 | + | // - maxBytes int64 | |
| 95 | + | func (_e *CISummaryStore_Expecter) List(ctx any, ref any, limit any, maxBytes any) *CISummaryStore_List_Call { | |
| 96 | + | return &CISummaryStore_List_Call{Call: _e.mock.On("List", ctx, ref, limit, maxBytes)} | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64)) *CISummaryStore_List_Call { | |
| 100 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 101 | + | var arg0 context.Context | |
| 102 | + | if args[0] != nil { | |
| 103 | + | arg0 = args[0].(context.Context) | |
| 104 | + | } | |
| 105 | + | var arg1 ports.CILogRef | |
| 106 | + | if args[1] != nil { | |
| 107 | + | arg1 = args[1].(ports.CILogRef) | |
| 108 | + | } | |
| 109 | + | var arg2 int | |
| 110 | + | if args[2] != nil { | |
| 111 | + | arg2 = args[2].(int) | |
| 112 | + | } | |
| 113 | + | var arg3 int64 | |
| 114 | + | if args[3] != nil { | |
| 115 | + | arg3 = args[3].(int64) | |
| 116 | + | } | |
| 117 | + | run( | |
| 118 | + | arg0, | |
| 119 | + | arg1, | |
| 120 | + | arg2, | |
| 121 | + | arg3, | |
| 122 | + | ) | |
| 123 | + | }) | |
| 124 | + | return _c | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, more bool, err error) *CISummaryStore_List_Call { | |
| 128 | + | _c.Call.Return(summaries, more, err) | |
| 129 | + | return _c | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (_c *CISummaryStore_List_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error)) *CISummaryStore_List_Call { | |
| 133 | + | _c.Call.Return(run) | |
| 134 | + | return _c | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Put provides a mock function for the type CISummaryStore | |
| 138 | + | func (_mock *CISummaryStore) Put(ctx context.Context, ref ports.CILogRef, step int32, markdown string) error { | |
| 139 | + | ret := _mock.Called(ctx, ref, step, markdown) | |
| 140 | + | ||
| 141 | + | if len(ret) == 0 { | |
| 142 | + | panic("no return value specified for Put") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | var r0 error | |
| 146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int32, string) error); ok { | |
| 147 | + | r0 = returnFunc(ctx, ref, step, markdown) | |
| 148 | + | } else { | |
| 149 | + | r0 = ret.Error(0) | |
| 150 | + | } | |
| 151 | + | return r0 | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // CISummaryStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' | |
| 155 | + | type CISummaryStore_Put_Call struct { | |
| 156 | + | *mock.Call | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Put is a helper method to define mock.On call | |
| 160 | + | // - ctx context.Context | |
| 161 | + | // - ref ports.CILogRef | |
| 162 | + | // - step int32 | |
| 163 | + | // - markdown string | |
| 164 | + | func (_e *CISummaryStore_Expecter) Put(ctx any, ref any, step any, markdown any) *CISummaryStore_Put_Call { | |
| 165 | + | return &CISummaryStore_Put_Call{Call: _e.mock.On("Put", ctx, ref, step, markdown)} | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (_c *CISummaryStore_Put_Call) Run(run func(ctx context.Context, ref ports.CILogRef, step int32, markdown string)) *CISummaryStore_Put_Call { | |
| 169 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 170 | + | var arg0 context.Context | |
| 171 | + | if args[0] != nil { | |
| 172 | + | arg0 = args[0].(context.Context) | |
| 173 | + | } | |
| 174 | + | var arg1 ports.CILogRef | |
| 175 | + | if args[1] != nil { | |
| 176 | + | arg1 = args[1].(ports.CILogRef) | |
| 177 | + | } | |
| 178 | + | var arg2 int32 | |
| 179 | + | if args[2] != nil { | |
| 180 | + | arg2 = args[2].(int32) | |
| 181 | + | } | |
| 182 | + | var arg3 string | |
| 183 | + | if args[3] != nil { | |
| 184 | + | arg3 = args[3].(string) | |
| 185 | + | } | |
| 186 | + | run( | |
| 187 | + | arg0, | |
| 188 | + | arg1, | |
| 189 | + | arg2, | |
| 190 | + | arg3, | |
| 191 | + | ) | |
| 192 | + | }) | |
| 193 | + | return _c | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (_c *CISummaryStore_Put_Call) Return(err error) *CISummaryStore_Put_Call { | |
| 197 | + | _c.Call.Return(err) | |
| 198 | + | return _c | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (_c *CISummaryStore_Put_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, step int32, markdown string) error) *CISummaryStore_Put_Call { | |
| 202 | + | _c.Call.Return(run) | |
| 203 | + | return _c | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // RemoveJob provides a mock function for the type CISummaryStore | |
| 207 | + | func (_mock *CISummaryStore) RemoveJob(ctx context.Context, ref ports.CILogRef) error { | |
| 208 | + | ret := _mock.Called(ctx, ref) | |
| 209 | + | ||
| 210 | + | if len(ret) == 0 { | |
| 211 | + | panic("no return value specified for RemoveJob") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | var r0 error | |
| 215 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) error); ok { | |
| 216 | + | r0 = returnFunc(ctx, ref) | |
| 217 | + | } else { | |
| 218 | + | r0 = ret.Error(0) | |
| 219 | + | } | |
| 220 | + | return r0 | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // CISummaryStore_RemoveJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveJob' | |
| 224 | + | type CISummaryStore_RemoveJob_Call struct { | |
| 225 | + | *mock.Call | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // RemoveJob is a helper method to define mock.On call | |
| 229 | + | // - ctx context.Context | |
| 230 | + | // - ref ports.CILogRef | |
| 231 | + | func (_e *CISummaryStore_Expecter) RemoveJob(ctx any, ref any) *CISummaryStore_RemoveJob_Call { | |
| 232 | + | return &CISummaryStore_RemoveJob_Call{Call: _e.mock.On("RemoveJob", ctx, ref)} | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (_c *CISummaryStore_RemoveJob_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CISummaryStore_RemoveJob_Call { | |
| 236 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 237 | + | var arg0 context.Context | |
| 238 | + | if args[0] != nil { | |
| 239 | + | arg0 = args[0].(context.Context) | |
| 240 | + | } | |
| 241 | + | var arg1 ports.CILogRef | |
| 242 | + | if args[1] != nil { | |
| 243 | + | arg1 = args[1].(ports.CILogRef) | |
| 244 | + | } | |
| 245 | + | run( | |
| 246 | + | arg0, | |
| 247 | + | arg1, | |
| 248 | + | ) | |
| 249 | + | }) | |
| 250 | + | return _c | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (_c *CISummaryStore_RemoveJob_Call) Return(err error) *CISummaryStore_RemoveJob_Call { | |
| 254 | + | _c.Call.Return(err) | |
| 255 | + | return _c | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (_c *CISummaryStore_RemoveJob_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) error) *CISummaryStore_RemoveJob_Call { | |
| 259 | + | _c.Call.Return(run) | |
| 260 | + | return _c | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // RemoveRuns provides a mock function for the type CISummaryStore | |
| 264 | + | func (_mock *CISummaryStore) RemoveRuns(ctx context.Context, runIDs []int64) error { | |
| 265 | + | ret := _mock.Called(ctx, runIDs) | |
| 266 | + | ||
| 267 | + | if len(ret) == 0 { | |
| 268 | + | panic("no return value specified for RemoveRuns") | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | var r0 error | |
| 272 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok { | |
| 273 | + | r0 = returnFunc(ctx, runIDs) | |
| 274 | + | } else { | |
| 275 | + | r0 = ret.Error(0) | |
| 276 | + | } | |
| 277 | + | return r0 | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // CISummaryStore_RemoveRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRuns' | |
| 281 | + | type CISummaryStore_RemoveRuns_Call struct { | |
| 282 | + | *mock.Call | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // RemoveRuns is a helper method to define mock.On call | |
| 286 | + | // - ctx context.Context | |
| 287 | + | // - runIDs []int64 | |
| 288 | + | func (_e *CISummaryStore_Expecter) RemoveRuns(ctx any, runIDs any) *CISummaryStore_RemoveRuns_Call { | |
| 289 | + | return &CISummaryStore_RemoveRuns_Call{Call: _e.mock.On("RemoveRuns", ctx, runIDs)} | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (_c *CISummaryStore_RemoveRuns_Call) Run(run func(ctx context.Context, runIDs []int64)) *CISummaryStore_RemoveRuns_Call { | |
| 293 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 294 | + | var arg0 context.Context | |
| 295 | + | if args[0] != nil { | |
| 296 | + | arg0 = args[0].(context.Context) | |
| 297 | + | } | |
| 298 | + | var arg1 []int64 | |
| 299 | + | if args[1] != nil { | |
| 300 | + | arg1 = args[1].([]int64) | |
| 301 | + | } | |
| 302 | + | run( | |
| 303 | + | arg0, | |
| 304 | + | arg1, | |
| 305 | + | ) | |
| 306 | + | }) | |
| 307 | + | return _c | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (_c *CISummaryStore_RemoveRuns_Call) Return(err error) *CISummaryStore_RemoveRuns_Call { | |
| 311 | + | _c.Call.Return(err) | |
| 312 | + | return _c | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func (_c *CISummaryStore_RemoveRuns_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CISummaryStore_RemoveRuns_Call { | |
| 316 | + | _c.Call.Return(run) | |
| 317 | + | return _c | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // RunIDs provides a mock function for the type CISummaryStore | |
| 321 | + | func (_mock *CISummaryStore) RunIDs(ctx context.Context) ([]int64, error) { | |
| 322 | + | ret := _mock.Called(ctx) | |
| 323 | + | ||
| 324 | + | if len(ret) == 0 { | |
| 325 | + | panic("no return value specified for RunIDs") | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | var r0 []int64 | |
| 329 | + | var r1 error | |
| 330 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]int64, error)); ok { | |
| 331 | + | return returnFunc(ctx) | |
| 332 | + | } | |
| 333 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []int64); ok { | |
| 334 | + | r0 = returnFunc(ctx) | |
| 335 | + | } else { | |
| 336 | + | if ret.Get(0) != nil { | |
| 337 | + | r0 = ret.Get(0).([]int64) | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 341 | + | r1 = returnFunc(ctx) | |
| 342 | + | } else { | |
| 343 | + | r1 = ret.Error(1) | |
| 344 | + | } | |
| 345 | + | return r0, r1 | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // CISummaryStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs' | |
| 349 | + | type CISummaryStore_RunIDs_Call struct { | |
| 350 | + | *mock.Call | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // RunIDs is a helper method to define mock.On call | |
| 354 | + | // - ctx context.Context | |
| 355 | + | func (_e *CISummaryStore_Expecter) RunIDs(ctx any) *CISummaryStore_RunIDs_Call { | |
| 356 | + | return &CISummaryStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx)} | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func (_c *CISummaryStore_RunIDs_Call) Run(run func(ctx context.Context)) *CISummaryStore_RunIDs_Call { | |
| 360 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 361 | + | var arg0 context.Context | |
| 362 | + | if args[0] != nil { | |
| 363 | + | arg0 = args[0].(context.Context) | |
| 364 | + | } | |
| 365 | + | run( | |
| 366 | + | arg0, | |
| 367 | + | ) | |
| 368 | + | }) | |
| 369 | + | return _c | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func (_c *CISummaryStore_RunIDs_Call) Return(int64s []int64, err error) *CISummaryStore_RunIDs_Call { | |
| 373 | + | _c.Call.Return(int64s, err) | |
| 374 | + | return _c | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func (_c *CISummaryStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context) ([]int64, error)) *CISummaryStore_RunIDs_Call { | |
| 378 | + | _c.Call.Return(run) | |
| 379 | + | return _c | |
| 380 | + | } | |
M internal/ports/mocks/RunService.go +180 -0
| @@ -369,6 +369,186 @@ func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p d | |||
| 369 | 369 | return _c | |
| 370 | 370 | } | |
| 371 | 371 | ||
| 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, maxBytes int64) (ports.SummaryList, error) { | |
| 374 | + | ret := _mock.Called(ctx, p, repo, jobID, attempt, maxBytes) | |
| 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, int64) (ports.SummaryList, error)); ok { | |
| 383 | + | return returnFunc(ctx, p, repo, jobID, attempt, maxBytes) | |
| 384 | + | } | |
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32, int64) ports.SummaryList); ok { | |
| 386 | + | r0 = returnFunc(ctx, p, repo, jobID, attempt, maxBytes) | |
| 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, int64) error); ok { | |
| 391 | + | r1 = returnFunc(ctx, p, repo, jobID, attempt, maxBytes) | |
| 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 | + | // - maxBytes int64 | |
| 410 | + | func (_e *RunService_Expecter) GetStepSummaries(ctx any, p any, repo any, jobID any, attempt any, maxBytes any) *RunService_GetStepSummaries_Call { | |
| 411 | + | return &RunService_GetStepSummaries_Call{Call: _e.mock.On("GetStepSummaries", ctx, p, repo, jobID, attempt, maxBytes)} | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64)) *RunService_GetStepSummaries_Call { | |
| 415 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 416 | + | var arg0 context.Context | |
| 417 | + | if args[0] != nil { | |
| 418 | + | arg0 = args[0].(context.Context) | |
| 419 | + | } | |
| 420 | + | var arg1 domain.Principal | |
| 421 | + | if args[1] != nil { | |
| 422 | + | arg1 = args[1].(domain.Principal) | |
| 423 | + | } | |
| 424 | + | var arg2 domain.RepoName | |
| 425 | + | if args[2] != nil { | |
| 426 | + | arg2 = args[2].(domain.RepoName) | |
| 427 | + | } | |
| 428 | + | var arg3 string | |
| 429 | + | if args[3] != nil { | |
| 430 | + | arg3 = args[3].(string) | |
| 431 | + | } | |
| 432 | + | var arg4 int32 | |
| 433 | + | if args[4] != nil { | |
| 434 | + | arg4 = args[4].(int32) | |
| 435 | + | } | |
| 436 | + | var arg5 int64 | |
| 437 | + | if args[5] != nil { | |
| 438 | + | arg5 = args[5].(int64) | |
| 439 | + | } | |
| 440 | + | run( | |
| 441 | + | arg0, | |
| 442 | + | arg1, | |
| 443 | + | arg2, | |
| 444 | + | arg3, | |
| 445 | + | arg4, | |
| 446 | + | arg5, | |
| 447 | + | ) | |
| 448 | + | }) | |
| 449 | + | return _c | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList, err error) *RunService_GetStepSummaries_Call { | |
| 453 | + | _c.Call.Return(summaryList, err) | |
| 454 | + | return _c | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func (_c *RunService_GetStepSummaries_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64) (ports.SummaryList, error)) *RunService_GetStepSummaries_Call { | |
| 458 | + | _c.Call.Return(run) | |
| 459 | + | return _c | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // ListAnnotations provides a mock function for the type RunService | |
| 463 | + | func (_mock *RunService) ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error) { | |
| 464 | + | ret := _mock.Called(ctx, p, repo, runID, attempt, jobID) | |
| 465 | + | ||
| 466 | + | if len(ret) == 0 { | |
| 467 | + | panic("no return value specified for ListAnnotations") | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | var r0 ports.AnnotationList | |
| 471 | + | var r1 error | |
| 472 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) (ports.AnnotationList, error)); ok { | |
| 473 | + | return returnFunc(ctx, p, repo, runID, attempt, jobID) | |
| 474 | + | } | |
| 475 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) ports.AnnotationList); ok { | |
| 476 | + | r0 = returnFunc(ctx, p, repo, runID, attempt, jobID) | |
| 477 | + | } else { | |
| 478 | + | r0 = ret.Get(0).(ports.AnnotationList) | |
| 479 | + | } | |
| 480 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) error); ok { | |
| 481 | + | r1 = returnFunc(ctx, p, repo, runID, attempt, jobID) | |
| 482 | + | } else { | |
| 483 | + | r1 = ret.Error(1) | |
| 484 | + | } | |
| 485 | + | return r0, r1 | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // RunService_ListAnnotations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAnnotations' | |
| 489 | + | type RunService_ListAnnotations_Call struct { | |
| 490 | + | *mock.Call | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // ListAnnotations is a helper method to define mock.On call | |
| 494 | + | // - ctx context.Context | |
| 495 | + | // - p domain.Principal | |
| 496 | + | // - repo domain.RepoName | |
| 497 | + | // - runID int64 | |
| 498 | + | // - attempt int32 | |
| 499 | + | // - jobID string | |
| 500 | + | func (_e *RunService_Expecter) ListAnnotations(ctx any, p any, repo any, runID any, attempt any, jobID any) *RunService_ListAnnotations_Call { | |
| 501 | + | return &RunService_ListAnnotations_Call{Call: _e.mock.On("ListAnnotations", ctx, p, repo, runID, attempt, jobID)} | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | 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 { | |
| 505 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 506 | + | var arg0 context.Context | |
| 507 | + | if args[0] != nil { | |
| 508 | + | arg0 = args[0].(context.Context) | |
| 509 | + | } | |
| 510 | + | var arg1 domain.Principal | |
| 511 | + | if args[1] != nil { | |
| 512 | + | arg1 = args[1].(domain.Principal) | |
| 513 | + | } | |
| 514 | + | var arg2 domain.RepoName | |
| 515 | + | if args[2] != nil { | |
| 516 | + | arg2 = args[2].(domain.RepoName) | |
| 517 | + | } | |
| 518 | + | var arg3 int64 | |
| 519 | + | if args[3] != nil { | |
| 520 | + | arg3 = args[3].(int64) | |
| 521 | + | } | |
| 522 | + | var arg4 int32 | |
| 523 | + | if args[4] != nil { | |
| 524 | + | arg4 = args[4].(int32) | |
| 525 | + | } | |
| 526 | + | var arg5 string | |
| 527 | + | if args[5] != nil { | |
| 528 | + | arg5 = args[5].(string) | |
| 529 | + | } | |
| 530 | + | run( | |
| 531 | + | arg0, | |
| 532 | + | arg1, | |
| 533 | + | arg2, | |
| 534 | + | arg3, | |
| 535 | + | arg4, | |
| 536 | + | arg5, | |
| 537 | + | ) | |
| 538 | + | }) | |
| 539 | + | return _c | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func (_c *RunService_ListAnnotations_Call) Return(annotationList ports.AnnotationList, err error) *RunService_ListAnnotations_Call { | |
| 543 | + | _c.Call.Return(annotationList, err) | |
| 544 | + | return _c | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | 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 { | |
| 548 | + | _c.Call.Return(run) | |
| 549 | + | return _c | |
| 550 | + | } | |
| 551 | + | ||
| 372 | 552 | // ListChecks provides a mock function for the type RunService | |
| 373 | 553 | func (_mock *RunService) ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error) { | |
| 374 | 554 | 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 | |||
| 498 | 498 | } | |
| 499 | 499 | ||
| 500 | 500 | // 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) | |
| 503 | 503 | ||
| 504 | 504 | if len(ret) == 0 { | |
| 505 | 505 | panic("no return value specified for UpdateJob") | |
| 506 | 506 | } | |
| 507 | 507 | ||
| 508 | 508 | 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) | |
| 511 | 511 | } else { | |
| 512 | 512 | r0 = ret.Error(0) | |
| 513 | 513 | } | |
| @@ -524,11 +524,12 @@ type RunnerService_UpdateJob_Call struct { | |||
| 524 | 524 | // - secret string | |
| 525 | 525 | // - jobID string | |
| 526 | 526 | // - 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)} | |
| 529 | 530 | } | |
| 530 | 531 | ||
| 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 { | |
| 532 | 533 | _c.Call.Run(func(args mock.Arguments) { | |
| 533 | 534 | var arg0 context.Context | |
| 534 | 535 | if args[0] != nil { | |
| @@ -546,11 +547,16 @@ func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret | |||
| 546 | 547 | if args[3] != nil { | |
| 547 | 548 | arg3 = args[3].([]ports.StepUpdate) | |
| 548 | 549 | } | |
| 550 | + | var arg4 []domain.CIAnnotation | |
| 551 | + | if args[4] != nil { | |
| 552 | + | arg4 = args[4].([]domain.CIAnnotation) | |
| 553 | + | } | |
| 549 | 554 | run( | |
| 550 | 555 | arg0, | |
| 551 | 556 | arg1, | |
| 552 | 557 | arg2, | |
| 553 | 558 | arg3, | |
| 559 | + | arg4, | |
| 554 | 560 | ) | |
| 555 | 561 | }) | |
| 556 | 562 | return _c | |
| @@ -561,7 +567,82 @@ func (_c *RunnerService_UpdateJob_Call) Return(err error) *RunnerService_UpdateJ | |||
| 561 | 567 | return _c | |
| 562 | 568 | } | |
| 563 | 569 | ||
| 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 { | |
| 565 | 646 | _c.Call.Return(run) | |
| 566 | 647 | return _c | |
| 567 | 648 | } | |
M internal/usecase/ci.go +20 -11
| @@ -46,19 +46,21 @@ const ( | |||
| 46 | 46 | maxCIWorkflowFiles = 256 | |
| 47 | 47 | ) | |
| 48 | 48 | ||
| 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, | |
| 50 | 50 | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | |
| 51 | 51 | // the defaults of Owner and Retention). | |
| 52 | 52 | 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 | |
| 62 | 64 | // Auth re-validates the token of a log stream; nil skips it. | |
| 63 | 65 | Auth ports.Revalidator | |
| 64 | 66 | // Remote lists the refs of github.com repositories for ResolveActions; nil | |
| @@ -295,6 +297,10 @@ type runState struct { | |||
| 295 | 297 | revoke []ports.CIJobRef | |
| 296 | 298 | // resolutions are the action resolutions to keep with the next flush. | |
| 297 | 299 | 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 | |
| 298 | 304 | } | |
| 299 | 305 | ||
| 300 | 306 | // 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 { | |||
| 379 | 385 | w.Runs = []domain.CIRun{rs.run} | |
| 380 | 386 | } | |
| 381 | 387 | 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 { | |
| 383 | 391 | return nil | |
| 384 | 392 | } | |
| 385 | 393 | v, err := c.cfg.Store.Apply(ctx, w) | |
| @@ -395,6 +403,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 395 | 403 | changedRun := rs.runDirty | |
| 396 | 404 | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | |
| 397 | 405 | rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil | |
| 406 | + | rs.annotations, rs.clear = nil, nil | |
| 398 | 407 | ||
| 399 | 408 | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | |
| 400 | 409 | 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 | |||
| 231 | 231 | if s.State == domain.CIStepPending && !cancelish { | |
| 232 | 232 | s.Conclusion = domain.CISkipped | |
| 233 | 233 | } | |
| 234 | + | s.Outcome = s.Conclusion | |
| 234 | 235 | s.State, s.CompletedAt = domain.CIStepCompleted, now | |
| 235 | 236 | } | |
| 236 | 237 | j.Phase, j.Conclusion, j.CompletedAt = domain.CIPhaseEnded, conclusion, now | |
A internal/usecase/ci_results.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 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. With maxBytes | |
| 115 | + | // above 0, no more than that many bytes of Markdown are read. | |
| 116 | + | func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32, maxBytes int64) (ports.SummaryList, error) { | |
| 117 | + | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 118 | + | return ports.SummaryList{}, err | |
| 119 | + | } | |
| 120 | + | _, j, err := c.jobOf(ctx, name, jobID, attempt) | |
| 121 | + | if err != nil { | |
| 122 | + | return ports.SummaryList{}, err | |
| 123 | + | } | |
| 124 | + | list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries, max(maxBytes, 0)) | |
| 125 | + | if err != nil { | |
| 126 | + | return ports.SummaryList{}, err | |
| 127 | + | } | |
| 128 | + | return ports.SummaryList{Summaries: list, Truncated: more}, nil | |
| 129 | + | } | |
A internal/usecase/ci_results_unit_test.go +252 -0
| @@ -0,0 +1,252 @@ | |||
| 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, int64(0)). | |
| 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, 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, 0) | |
| 230 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // The byte budget of a reader reaches the store, and a negative one means none. | |
| 234 | + | func TestGetStepSummariesPassesTheBudget(t *testing.T) { | |
| 235 | + | t.Parallel() | |
| 236 | + | u := newCIUnit(t, nil) | |
| 237 | + | repo := name(t, "proj") | |
| 238 | + | j := ciJob(t, domain.CIPhaseEnded, jobSecret) | |
| 239 | + | u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil) | |
| 240 | + | u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 1}, nil) | |
| 241 | + | u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(1), jobIDOne).Return(j, nil) | |
| 242 | + | ref := ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1} | |
| 243 | + | u.sums.EXPECT().List(mock.Anything, ref, domain.CIMaxListedSummaries, int64(4096)). | |
| 244 | + | Return([]domain.CIStepSummary{{Step: 1, Markdown: "a"}, {Step: 2, Omitted: true}}, false, nil).Once() | |
| 245 | + | u.sums.EXPECT().List(mock.Anything, ref, domain.CIMaxListedSummaries, int64(0)).Return(nil, false, nil).Once() | |
| 246 | + | ||
| 247 | + | got, err := u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 4096) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | assert.True(t, got.Summaries[1].Omitted) | |
| 250 | + | _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, -5) | |
| 251 | + | require.NoError(t, err) | |
| 252 | + | } | |
M internal/usecase/ci_runner.go +52 -16
| @@ -321,10 +321,11 @@ func sameAssignment(was, cur domain.CIJob) error { | |||
| 321 | 321 | return nil | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 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}) | |
| 328 | 329 | if err != nil { | |
| 329 | 330 | return fmt.Errorf("write job %s: %w", j.ID, err) | |
| 330 | 331 | } | |
| @@ -341,8 +342,10 @@ func logRef(j domain.CIJob) ports.CILogRef { | |||
| 341 | 342 | return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt} | |
| 342 | 343 | } | |
| 343 | 344 | ||
| 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 { | |
| 346 | 349 | j, err := c.jobFor(ctx, secret, jobID) | |
| 347 | 350 | if err != nil { | |
| 348 | 351 | return err | |
| @@ -353,16 +356,26 @@ func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports. | |||
| 353 | 356 | if err := checkStepUpdates(steps); err != nil { | |
| 354 | 357 | return err | |
| 355 | 358 | } | |
| 359 | + | if err := domain.CheckAnnotations(annotations); err != nil { | |
| 360 | + | return err | |
| 361 | + | } | |
| 356 | 362 | return c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |
| 357 | 363 | if cur.Ended() { | |
| 358 | 364 | return endedError(*cur) | |
| 359 | 365 | } | |
| 360 | 366 | next, changed, err := applySteps(cur.Steps, steps) | |
| 361 | - | if err != nil || !changed { | |
| 367 | + | if err != nil { | |
| 362 | 368 | return err | |
| 363 | 369 | } | |
| 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 | + | } | |
| 364 | 377 | cur.Steps = next | |
| 365 | - | return c.saveJob(ctx, cur) | |
| 378 | + | return c.saveJob(ctx, cur, writes...) | |
| 366 | 379 | }) | |
| 367 | 380 | } | |
| 368 | 381 | ||
| @@ -377,12 +390,12 @@ func checkStepUpdates(steps []ports.StepUpdate) error { | |||
| 377 | 390 | } | |
| 378 | 391 | switch s.State { | |
| 379 | 392 | 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) | |
| 382 | 395 | } | |
| 383 | 396 | 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) | |
| 386 | 399 | } | |
| 387 | 400 | case domain.CIStepCompleted: | |
| 388 | 401 | switch s.Conclusion { | |
| @@ -390,6 +403,11 @@ func checkStepUpdates(steps []ports.StepUpdate) error { | |||
| 390 | 403 | default: | |
| 391 | 404 | return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number) | |
| 392 | 405 | } | |
| 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 | + | } | |
| 393 | 411 | if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped { | |
| 394 | 412 | return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number) | |
| 395 | 413 | } | |
| @@ -413,21 +431,22 @@ func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep, | |||
| 413 | 431 | i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number }) | |
| 414 | 432 | if i < 0 { | |
| 415 | 433 | 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, | |
| 417 | 436 | }) | |
| 418 | 437 | changed = true | |
| 419 | 438 | continue | |
| 420 | 439 | } | |
| 421 | 440 | cur := out[i] | |
| 422 | 441 | 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 | |
| 424 | 443 | next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt | |
| 425 | 444 | if cur.Name != "" && name == "" { | |
| 426 | 445 | next.Name = cur.Name // an empty name never erases one | |
| 427 | 446 | } | |
| 428 | 447 | switch { | |
| 429 | 448 | 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 && | |
| 431 | 450 | next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt) | |
| 432 | 451 | if !same { | |
| 433 | 452 | 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, | |||
| 444 | 463 | return out, changed, nil | |
| 445 | 464 | } | |
| 446 | 465 | ||
| 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 | + | ||
| 447 | 475 | 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 && | |
| 449 | 477 | a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines | |
| 450 | 478 | } | |
| 451 | 479 | ||
| @@ -731,6 +759,9 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple | |||
| 731 | 759 | if err := checkStepUpdates(params.Steps); err != nil { | |
| 732 | 760 | return ports.CompleteJobResult{}, err | |
| 733 | 761 | } | |
| 762 | + | if err := domain.CheckAnnotations(params.Annotations); err != nil { | |
| 763 | + | return ports.CompleteJobResult{}, err | |
| 764 | + | } | |
| 734 | 765 | var res ports.CompleteJobResult | |
| 735 | 766 | err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 736 | 767 | cur := rs.find(j.ID) | |
| @@ -748,6 +779,11 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple | |||
| 748 | 779 | if err != nil { | |
| 749 | 780 | return err | |
| 750 | 781 | } | |
| 782 | + | writes, err := c.annotationWrites(ctx, *cur, params.Annotations) | |
| 783 | + | if err != nil { | |
| 784 | + | return err | |
| 785 | + | } | |
| 786 | + | rs.annotations = append(rs.annotations, writes...) | |
| 751 | 787 | cur.Steps = steps | |
| 752 | 788 | cur.Outputs = params.Outputs | |
| 753 | 789 | msg := "" | |
M internal/usecase/ci_runner_unit_test.go +13 -4
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | type ciUnit struct { | |
| 25 | 25 | store *mocks.CIStore | |
| 26 | 26 | logs *mocks.CILogStore | |
| 27 | + | sums *mocks.CISummaryStore | |
| 27 | 28 | meta *mocks.RepoMetaStore | |
| 28 | 29 | bus *mocks.EventBus | |
| 29 | 30 | clock *fakeClock | |
| @@ -37,11 +38,11 @@ func (c *fakeClock) Now() time.Time { return c.t } | |||
| 37 | 38 | func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit { | |
| 38 | 39 | t.Helper() | |
| 39 | 40 | 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), | |
| 41 | 42 | bus: mocks.NewEventBus(t), clock: &fakeClock{t: now}, | |
| 42 | 43 | } | |
| 43 | 44 | 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), | |
| 45 | 46 | Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test", | |
| 46 | 47 | } | |
| 47 | 48 | if tweak != nil { | |
| @@ -83,7 +84,7 @@ func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) { | |||
| 83 | 84 | "UpdateJob": func(u *ciUnit) error { | |
| 84 | 85 | return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{ | |
| 85 | 86 | Number: 1, State: domain.CIStepInProgress, StartedAt: now, | |
| 86 | - | }}) | |
| 87 | + | }}, nil) | |
| 87 | 88 | }, | |
| 88 | 89 | "AppendLog": func(u *ciUnit) error { | |
| 89 | 90 | _, 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) { | |||
| 122 | 123 | ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled | |
| 123 | 124 | u.expectToken(jobSecret, j) | |
| 124 | 125 | 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) | |
| 126 | 127 | got, ok := domain.IsJobEnded(err) | |
| 127 | 128 | require.True(t, ok, "%v", err) | |
| 128 | 129 | assert.Equal(t, domain.CICancelled, got.Conclusion) | |
| @@ -311,6 +312,10 @@ func TestSweep(t *testing.T) { | |||
| 311 | 312 | ctx, out := logCapture(t) | |
| 312 | 313 | u.store.EXPECT().Sweep(mock.Anything, u.clock.t.Add(-retention)).Return([]int64{3, 4}, nil) | |
| 313 | 314 | 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) | |
| 314 | 319 | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{5, 6, 7}, nil) | |
| 315 | 320 | u.store.EXPECT().GetRunSource(mock.Anything, int64(5)).Return(domain.CIRunSource{}, nil) | |
| 316 | 321 | u.store.EXPECT().GetRunSource(mock.Anything, int64(6)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| @@ -337,6 +342,8 @@ func TestSweep(t *testing.T) { | |||
| 337 | 342 | ctx, out := logCapture(t) | |
| 338 | 343 | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return([]int64{3}, nil) | |
| 339 | 344 | 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) | |
| 340 | 347 | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil) | |
| 341 | 348 | u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| 342 | 349 | u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{9}).Return(errors.New("disk on fire")).Once() | |
| @@ -353,6 +360,7 @@ func TestSweep(t *testing.T) { | |||
| 353 | 360 | ctx, out := logCapture(t) | |
| 354 | 361 | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil) | |
| 355 | 362 | 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")) | |
| 356 | 364 | u.ci.Sweep(ctx) | |
| 357 | 365 | assert.Contains(t, out.String(), "list the log directories") | |
| 358 | 366 | }) | |
| @@ -363,6 +371,7 @@ func TestSweep(t *testing.T) { | |||
| 363 | 371 | ctx, out := logCapture(t) | |
| 364 | 372 | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil) | |
| 365 | 373 | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil) | |
| 374 | + | u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, nil) | |
| 366 | 375 | u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, errors.New("kv closed")) | |
| 367 | 376 | u.ci.Sweep(ctx) // RemoveRuns is not expected: an unknown state removes nothing | |
| 368 | 377 | 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) } | |||
| 185 | 185 | func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob) { | |
| 186 | 186 | ctxLog(ctx).Warn().Str("job_id", j.ID).Str("runner", j.RunnerName).Msg("job assignment not acknowledged: cancelled") | |
| 187 | 187 | _ = c.cfg.Logs.Reset(ctx, logRef(*j)) | |
| 188 | + | _ = c.cfg.Summaries.RemoveJob(ctx, logRef(*j)) | |
| 189 | + | rs.clear = append(rs.clear, logRef(*j)) | |
| 188 | 190 | j.AssignCancels++ | |
| 189 | 191 | j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{} | |
| 190 | 192 | j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone | |
| @@ -252,14 +254,25 @@ func (c *CI) sweep(ctx context.Context) { | |||
| 252 | 254 | if err := c.cfg.Logs.RemoveRuns(ctx, ids); err != nil { | |
| 253 | 255 | log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the logs of the old runs") | |
| 254 | 256 | } | |
| 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 | + | } | |
| 255 | 260 | for _, id := range ids { | |
| 256 | 261 | c.locks.Delete(id) | |
| 257 | 262 | } | |
| 258 | 263 | log.Info().Int("runs", len(ids)).Msg("ci: old runs removed") | |
| 259 | 264 | } | |
| 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) | |
| 261 | 274 | if err != nil { | |
| 262 | - | log.Error().Err(err).Msg("ci: list the log directories") | |
| 275 | + | log.Error().Err(err).Msg("ci: list the " + dirs) | |
| 263 | 276 | return | |
| 264 | 277 | } | |
| 265 | 278 | var orphans []int64 | |
| @@ -268,12 +281,12 @@ func (c *CI) sweep(ctx context.Context) { | |||
| 268 | 281 | case errors.Is(err, domain.ErrNotFound): | |
| 269 | 282 | orphans = append(orphans, id) | |
| 270 | 283 | 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") | |
| 272 | 285 | } | |
| 273 | 286 | } | |
| 274 | 287 | 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") | |
| 277 | 290 | } | |
| 278 | 291 | } | |
| 279 | 292 | } | |
| @@ -284,10 +297,11 @@ func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |||
| 284 | 297 | return c.cfg.Store.RunIDs(ctx, repo) | |
| 285 | 298 | } | |
| 286 | 299 | ||
| 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. | |
| 288 | 302 | func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error { | |
| 289 | 303 | for _, id := range runIDs { | |
| 290 | 304 | c.locks.Delete(id) | |
| 291 | 305 | } | |
| 292 | - | return c.cfg.Logs.RemoveRuns(ctx, runIDs) | |
| 306 | + | return errors.Join(c.cfg.Logs.RemoveRuns(ctx, runIDs), c.cfg.Summaries.RemoveRuns(ctx, runIDs)) | |
| 293 | 307 | } | |
M proto/ermite/storage/ci.proto +31 -0
| @@ -135,6 +135,9 @@ message CIStep { | |||
| 135 | 135 | // count of the job: it is never stored. | |
| 136 | 136 | int64 log_start = 7; | |
| 137 | 137 | 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; | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | |
| @@ -260,3 +263,31 @@ message CIActionResolution { | |||
| 260 | 263 | // 1 = github.com, 2 = this server (the numbers of ActionSource). | |
| 261 | 264 | int32 source = 4; | |
| 262 | 265 | } | |
| 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 | + | } | |