diff --git a/flake.lock b/flake.lock index 7956bcd..0f00751 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1776312396, - "narHash": "sha256-+kIlfrvisYdTgdix932MnQEKjq0yJsJL+vnG/I6QJ68=", + "lastModified": 1778107833, + "narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=", "owner": "dagger", "repo": "nix", - "rev": "851584fd8093ab942b4aa833cdef53050b27279b", + "rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496", "type": "github" }, "original": { @@ -40,11 +40,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1776329215, - "narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=", + "lastModified": 1779414690, + "narHash": "sha256-gOTcX/9MZVMUE0Xvb4IEcv+0TQJkZFNEnL757ljU360=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b86751bc4085f48661017fa226dee99fab6c651b", + "rev": "6dedf69f94d03cbe7bdde106f2d4c23ae2a853bf", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 2ed1623..547be45 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,7 @@ devShells.default = pkgs.mkShell { buildInputs = [ pkgs.go_1_25 + pkgs.gh pkgs.gotools pkgs.gnumake dagger.packages.${system}.dagger @@ -29,4 +30,3 @@ } ); } - diff --git a/ghcontrib/README.md b/ghcontrib/README.md index 89f3e5d..9b98812 100644 --- a/ghcontrib/README.md +++ b/ghcontrib/README.md @@ -3,21 +3,26 @@ Various GitHub contribution utilities and standards conforming checks. Currently includes checks for: -- required conventional PR title prefixes: - - `โœจ feat: ` โ€” new features - - `๐Ÿ”ง fix: ` โ€” bug fixes - - `๐Ÿงน chore: ` โ€” maintenance tasks - - `โ™ป๏ธ refactor: ` โ€” refactors - - `๐ŸŽจ design: ` โ€” design and UX updates - - `๐Ÿ“š docs: ` โ€” documentation updates - - `โœ๏ธ RFD: ` โ€” requests for discussion +- required conventional PR title format: + - ` : ` + - ` (scope): ` + - ` !: ` + - ` (scope)!: ` +- allowed emoji/type pairs: + - `โœจ feat` / `:sparkles: feat` โ€” new features + - `๐Ÿ”ง fix` / `:wrench: fix` โ€” bug fixes + - `๐Ÿงน chore` / `:broom: chore` โ€” maintenance tasks + - `โ™ป๏ธ refactor` / `:recycle: refactor` โ€” refactors + - `๐ŸŽจ design` / `:art: design` โ€” design and UX updates + - `๐Ÿ“š docs` / `:books: docs` โ€” documentation updates + - `โœ๏ธ RFD` / `:pencil2: RFD` โ€” requests for discussion - Linear magic-word references in the PR title or body, e.g. `fixes PCC-123` or `related to DES-456`. Must include a supported Paper Compute Co. Linear team. | Function | Description | |----------|-------------| -| `check-pull-request` | Fetches a PR via `gh pr view` and validates that its title starts with a required prefix. Fails the pipeline if the PR is non-conforming. | +| `check-pull-request` | Fetches a PR via `gh pr view` and validates that its title follows the required conventional title format. Fails the pipeline if the PR is non-conforming. | | `check-pull-request-linear-magic-word` | Fetches a PR via `gh pr view` and validates only the Linear magic-word reference. | diff --git a/ghcontrib/go.mod b/ghcontrib/go.mod index 7a84d3a..d71945c 100644 --- a/ghcontrib/go.mod +++ b/ghcontrib/go.mod @@ -5,14 +5,14 @@ go 1.25.5 require ( github.com/Khan/genqlient v0.8.1 github.com/dagger/otel-go v1.43.0 - github.com/vektah/gqlparser/v2 v2.5.32 - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/trace v1.43.0 + github.com/vektah/gqlparser/v2 v2.5.33 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 ) require ( - dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 - github.com/99designs/gqlgen v0.17.89 // indirect + dagger.io/dagger v0.20.8 + github.com/99designs/gqlgen v0.17.90 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect @@ -20,7 +20,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect go.opentelemetry.io/otel/log v0.17.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect @@ -30,18 +30,20 @@ require ( ) require ( + github.com/adrg/xdg v0.5.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/sosodev/duration v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/ghcontrib/go.sum b/ghcontrib/go.sum index 8f7b38b..e1b5a9c 100644 --- a/ghcontrib/go.sum +++ b/ghcontrib/go.sum @@ -1,9 +1,13 @@ dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ= dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es= -github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8= -github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ= +dagger.io/dagger v0.20.8 h1:n+Xtzp9ufNwCH3Ftob92Smu3smfUDoQshDYM6Ys4yf0= +dagger.io/dagger v0.20.8/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es= +github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk= +github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -29,6 +33,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -37,12 +43,12 @@ github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9Ij github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= -github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= +github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= @@ -59,8 +65,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= @@ -69,20 +75,20 @@ go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf6 go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= diff --git a/ghcontrib/pr.go b/ghcontrib/pr.go index 2d55658..c4c635f 100644 --- a/ghcontrib/pr.go +++ b/ghcontrib/pr.go @@ -8,33 +8,12 @@ import ( "strings" ) -// validPRPrefixes defines the set of allowed PR title prefixes. -var validPRPrefixes = []string{ - // Literal emojis - "โœจ feat: ", - "๐Ÿ”ง fix: ", - "๐Ÿงน chore: ", - "โ™ป๏ธ refactor: ", - "๐ŸŽจ design: ", - "๐Ÿ“š docs: ", - "โœ๏ธ RFD: ", - - // Colon based emoji derivatives - ":sparkles: feat: ", - ":wrench: fix: ", - ":broom: chore: ", - ":recycle: refactor: ", - ":art: design: ", - ":books: docs: ", - ":pencil2: RFD: ", -} - // Linear's magic words compiled from: // https://linear.app/docs/github#link-through-pull-requests var linearMagicWordPattern = regexp.MustCompile(`(?i)\b(?:close|closes|closed|closing|closing fix|fix|fixes|fixed|fixing|resolve|resolves|resolved|resolving|complete|completes|completed|completing|implements|implemented|implementing|ref|refs|references|part of|related to|contributes to|toward|towards)\s+(?-i:(?:PCC|DES|REL|CTO)-[0-9]+)\b`) // CheckPullRequest validates that a pull request conforms to project standards. -// It checks that the PR title starts with one of the required prefixes. +// It checks that the PR title follows the required emoji conventional format. // // This is intended to be called from a GitHub Actions workflow where the // GitHub token and PR metadata are available. @@ -107,26 +86,6 @@ func (m *Ghcontrib) CheckPullRequestLinearMagicWord( return fmt.Sprintf("โœ… PR #%d has a valid Linear magic word", number), nil } -func validatePullRequestTitle(title string, number int) error { - for _, prefix := range validPRPrefixes { - if strings.HasPrefix(title, prefix) { - return nil - } - } - - prefixList := make([]string, len(validPRPrefixes)) - for i, p := range validPRPrefixes { - prefixList[i] = fmt.Sprintf(" - %q", p) - } - - return fmt.Errorf( - "PR #%d title %q does not match any required prefix.\n\nTitle must start with one of:\n%s", - number, - title, - strings.Join(prefixList, "\n"), - ) -} - func validatePullRequestLinearMagicWord(title, body string, number int) error { if linearMagicWordPattern.MatchString(title) || linearMagicWordPattern.MatchString(body) { return nil diff --git a/ghcontrib/pr_test.go b/ghcontrib/pr_test.go new file mode 100644 index 0000000..cb8ec4b --- /dev/null +++ b/ghcontrib/pr_test.go @@ -0,0 +1,62 @@ +package main + +import "testing" + +func TestValidatePullRequestTitleAcceptsValidTitles(t *testing.T) { + t.Parallel() + + tests := []string{ + "โœจ feat: add the percolator", + "โœจ feat!: replace the percolator", + "โœจ feat(percolator): add the percolator", + "โœจ feat(percolator)!: add the percolator", + ":sparkles: feat(percolator): add the percolator", + "๐Ÿ”ง fix: the build was not working on tuesdays", + "๐Ÿ”ง fix(percolator): the build was not working on tuesday", + ":wrench: fix(percolator): the build was not working on tuesday", + "๐Ÿงน chore(ci): update workflow dependencies", + "โ™ป๏ธ refactor(api.v2): simplify request routing", + "๐ŸŽจ design(console-ui): tighten spacing", + "๐Ÿ“š docs(readme): describe installation", + "โœ๏ธ RFD(agent-runtime): propose sandbox lifecycle", + } + + for _, title := range tests { + title := title + t.Run(title, func(t *testing.T) { + t.Parallel() + + if err := validatePullRequestTitle(title, 42); err != nil { + t.Fatalf("expected title to be valid: %v", err) + } + }) + } +} + +func TestValidatePullRequestTitleRejectsInvalidTitles(t *testing.T) { + t.Parallel() + + tests := []string{ + "feat: add the percolator", + "๐Ÿ”ง feat: add the percolator", + "โœจ fix: add the percolator", + "๐Ÿ”ง fix(percolator):", + "๐Ÿ”ง fix(): add the percolator", + "๐Ÿ”ง fix(Percolator): add the percolator", + "๐Ÿ”ง fix(percolator) add the percolator", + "๐Ÿ”ง fix(percolator/subsystem): add the percolator", + "๐Ÿ› fix: add the percolator", + ":bug: fix: add the percolator", + } + + for _, title := range tests { + title := title + t.Run(title, func(t *testing.T) { + t.Parallel() + + if err := validatePullRequestTitle(title, 42); err == nil { + t.Fatal("expected title to be invalid") + } + }) + } +} diff --git a/ghcontrib/pr_title.go b/ghcontrib/pr_title.go new file mode 100644 index 0000000..85c5b01 --- /dev/null +++ b/ghcontrib/pr_title.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +type prTitleSpec struct { + Type string + Tokens []string +} + +type prTitlePattern struct { + Format string + Regexp *regexp.Regexp +} + +// validPRTitleSpecs defines the allowed PR title emoji/type pairs. +var validPRTitleSpecs = []prTitleSpec{ + {Type: "feat", Tokens: []string{"โœจ", ":sparkles:"}}, + {Type: "fix", Tokens: []string{"๐Ÿ”ง", ":wrench:"}}, + {Type: "chore", Tokens: []string{"๐Ÿงน", ":broom:"}}, + {Type: "refactor", Tokens: []string{"โ™ป๏ธ", ":recycle:"}}, + {Type: "design", Tokens: []string{"๐ŸŽจ", ":art:"}}, + {Type: "docs", Tokens: []string{"๐Ÿ“š", ":books:"}}, + {Type: "RFD", Tokens: []string{"โœ๏ธ", ":pencil2:"}}, +} + +var validPRTitlePatterns = buildPRTitlePatterns(validPRTitleSpecs) + +func validatePullRequestTitle(title string, number int) error { + for _, pattern := range validPRTitlePatterns { + if pattern.Regexp.MatchString(title) { + return nil + } + } + + formatList := make([]string, 0, len(validPRTitlePatterns)) + for _, pattern := range validPRTitlePatterns { + formatList = append(formatList, " - "+pattern.Format) + } + + return fmt.Errorf( + "PR #%d title %q does not match the required title format.\n\nTitle must follow: [(scope)]: \nAllowed formats:\n%s", + number, + title, + strings.Join(formatList, "\n"), + ) +} + +func buildPRTitlePatterns(specs []prTitleSpec) []prTitlePattern { + patterns := make([]prTitlePattern, 0, len(specs)*2) + for _, spec := range specs { + for _, token := range spec.Tokens { + patterns = append(patterns, prTitlePattern{ + Format: fmt.Sprintf("%s %s[(scope)]: description", token, spec.Type), + Regexp: regexp.MustCompile(fmt.Sprintf( + `^%s %s(?:\([a-z0-9][a-z0-9._-]*\))?!?: .+$`, + regexp.QuoteMeta(token), + regexp.QuoteMeta(spec.Type), + )), + }) + } + } + return patterns +} diff --git a/ghrelease/README.md b/ghrelease/README.md index 321592e..55844a4 100644 --- a/ghrelease/README.md +++ b/ghrelease/README.md @@ -2,6 +2,18 @@ GitHub release management. +Create inspects commit subjects since the latest `v*` tag to choose the next +semantic version and generate release notes. Scoped conventional subjects are +classified the same as their unscoped form: + +| Commit subject | Release behavior | +|----------------|------------------| +| `โœจ feat: ...` or `โœจ feat(scope): ...` | Minor bump, Features section | +| `๐Ÿ”ง fix: ...` or `๐Ÿ”ง fix(scope): ...` | Patch bump, Fixes section | +| `๐Ÿงน chore: ...` or `๐Ÿงน chore(scope): ...` | Patch bump, Chores section | +| `โ™ป๏ธ refactor: ...` or `โ™ป๏ธ refactor(scope): ...` | Patch bump, Refactors section | +| `feat!: ...`, `fix!: ...`, `chore!: ...`, or `refactor!: ...` | Major bump, Breaking Changes section | + Upload takes a directory of build artifacts to a release. Artifacts can optionally be flattened from an `//` layout into a flat directory with files renamed diff --git a/ghrelease/create-release.sh b/ghrelease/create-release.sh index 609d9cf..5e027fb 100644 --- a/ghrelease/create-release.sh +++ b/ghrelease/create-release.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -e -cd /src +cd "${SRC_DIR:-/src}" # --- Determine the latest semver tag --- LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -n1) @@ -29,30 +29,56 @@ BREAKING="" FEATURES="" FIXES="" CHORES="" +REFACTORS="" OTHER="" +commit_type() { + SUBJECT="$1" + + case "$SUBJECT" in + "โš ๏ธ breaking:"*|":warning: breaking:"*) echo "breaking"; return ;; + "โœจ feat"*|":sparkles: feat"*) SUBJECT="feat${SUBJECT#* feat}" ;; + "๐Ÿ”ง fix"*|":wrench: fix"*) SUBJECT="fix${SUBJECT#* fix}" ;; + "๐Ÿงน chore"*|":broom: chore"*) SUBJECT="chore${SUBJECT#* chore}" ;; + "โ™ป๏ธ refactor"*|":recycle: refactor"*) SUBJECT="refactor${SUBJECT#* refactor}" ;; + esac + + case "$SUBJECT" in + "breaking:"*|"feat!:"*|"feat("*")!:"*|"fix!:"*|"fix("*")!:"*|"chore!:"*|"chore("*")!:"*|"refactor!:"*|"refactor("*")!:"*) echo "breaking" ;; + "feat:"*|"feat("*"):"*) echo "feat" ;; + "fix:"*|"fix("*"):"*) echo "fix" ;; + "chore:"*|"chore("*"):"*) echo "chore" ;; + "refactor:"*|"refactor("*"):"*|"refactor!:"*|"refactor("*")!:"*) echo "refactor" ;; + *) echo "other" ;; + esac +} + IFS=' ' for LINE in $COMMITS; do - case "$LINE" in - *"โš ๏ธ breaking:"*|*":warning: breaking:"*|*"breaking:"*) + case "$(commit_type "$LINE")" in + breaking) BUMP="major" BREAKING="${BREAKING} - ${LINE}" ;; - *"โœจ feat:"*|*":sparkles: feat:"*|*"feat:"*) + feat) if [ "$BUMP" != "major" ]; then BUMP="minor" fi FEATURES="${FEATURES} - ${LINE}" ;; - *"๐Ÿ”ง fix:"*|*":wrench: fix:"*|*"fix:"*) + fix) FIXES="${FIXES} - ${LINE}" ;; - *"๐Ÿงน chore:"*|*":broom: chore:"*|*"chore:"*) + chore) CHORES="${CHORES} +- ${LINE}" + ;; + refactor) + REFACTORS="${REFACTORS} - ${LINE}" ;; *) @@ -119,6 +145,13 @@ ${CHORES} " fi +if [ -n "$REFACTORS" ]; then + NOTES="${NOTES}## โ™ป๏ธ Refactors +${REFACTORS} + +" +fi + if [ -n "$OTHER" ]; then NOTES="${NOTES}## ๐Ÿ“ฆ Other Changes ${OTHER} diff --git a/ghrelease/main.go b/ghrelease/main.go index 58a4c62..c7f7d0e 100644 --- a/ghrelease/main.go +++ b/ghrelease/main.go @@ -124,9 +124,9 @@ func (m *Ghrelease) WithDryRun() *Ghrelease { // version, generates categorized release notes, and creates a new GitHub // release. Commits since the last tag are classified as follows: // -// - "โš ๏ธ breaking:" โ†’ major bump -// - "โœจ feat:" โ†’ minor bump -// - "๐Ÿ”ง fix:", "๐Ÿงน chore:", and everything else โ†’ patch bump +// - "โš ๏ธ breaking:" or a conventional "!" marker on feat/fix/chore/refactor โ†’ major bump +// - "โœจ feat:" or "โœจ feat(scope):" โ†’ minor bump +// - "๐Ÿ”ง fix:", "๐Ÿงน chore:", "โ™ป๏ธ refactor:", and everything else โ†’ patch bump // // The highest-priority bump wins. WithSource must be called before Create. // Chain WithDryRun before Create to skip the actual release creation.