Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
devShells.default = pkgs.mkShell {
buildInputs = [
pkgs.go_1_25
pkgs.gh
pkgs.gotools
pkgs.gnumake
dagger.packages.${system}.dagger
Expand All @@ -29,4 +30,3 @@
}
);
}

23 changes: 14 additions & 9 deletions ghcontrib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
- `<emoji> <type>: <description>`
- `<emoji> <type>(scope): <description>`
- `<emoji> <type>!: <description>`
- `<emoji> <type>(scope)!: <description>`
- 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. |


Expand Down
20 changes: 11 additions & 9 deletions ghcontrib/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ 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
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
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
Expand All @@ -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
Expand Down
38 changes: 22 additions & 16 deletions ghcontrib/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
43 changes: 1 addition & 42 deletions ghcontrib/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions ghcontrib/pr_test.go
Original file line number Diff line number Diff line change
@@ -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",
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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")
}
})
}
}
Loading
Loading