commit 1fa1d6330772480541abb826829474bce276cd02 Author: Arsen Musayelyan Date: Wed Nov 2 22:29:44 2022 -0700 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12014fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/lure-analyzer +/lure-repo-bot \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae8b171 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# LURE Repo Bot + +A Github bot that reviews PRs to the LURE repo by analyzing the script for errors and providing comments on how to fix them. + +There is also a command-line tool at `./cmd/lure-analyzer` that does the same thing but as a command. + +## Configuration + +### `LURE_BOT_ADDR` + +The listen address for the webhook server. `:8080` by default. + +### `LURE_BOT_GITHUB_TOKEN` + +The Github token to be used for writing PR reviews + +### `LURE_BOT_SECRET` + +The secret used when setting up the Github webhook, used to verify the authenticity of webhook data. \ No newline at end of file diff --git a/cmd/lure-analyzer/main.go b/cmd/lure-analyzer/main.go new file mode 100644 index 0000000..5a54171 --- /dev/null +++ b/cmd/lure-analyzer/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "go.arsenm.dev/lure-repo-bot/internal/analyze" + "go.arsenm.dev/lure-repo-bot/internal/shutils" + "go.arsenm.dev/lure-repo-bot/internal/spdx" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + +func init() { + err := spdx.Update() + if err != nil { + fatalErr(err) + } +} + +func main() { + ctx := context.Background() + + var files []*os.File + for _, arg := range os.Args[1:] { + file, err := os.Open(arg) + if err != nil { + fatalErr(err) + } + files = append(files, file) + } + + wd, err := os.Getwd() + if err != nil { + fatalErr(err) + } + + issuesFound := false + for _, file := range files { + fl, err := syntax.NewParser().Parse(file, "lure.sh") + if err != nil { + fatalErr(err) + } + + var nopRWC shutils.NopRWC + runner, err := interp.New( + interp.Env(expand.ListEnviron()), + interp.StdIO(nopRWC, nopRWC, os.Stderr), + interp.ExecHandler(shutils.NopExec), + interp.ReadDirHandler(shutils.NopReadDir), + interp.OpenHandler(shutils.NopOpen), + interp.StatHandler(shutils.NopStat), + ) + if err != nil { + fatalErr(err) + } + + err = runner.Run(ctx, fl) + if err != nil { + fatalErr(err) + } + + findings, err := analyze.AnalyzeScript(runner, fl) + if err != nil { + fatalErr(err) + } + + flName := strings.TrimPrefix(file.Name(), wd) + flName = strings.TrimPrefix(flName, "/") + + fmt.Println(flName + ":") + if len(findings) == 0 { + fmt.Println("\tNo issues found!") + } else { + issuesFound = true + for _, finding := range findings { + var name string + if finding.Index != nil { + name = fmt.Sprintf( + "%s[%v] %s", + finding.ItemName, + finding.Index, + finding.ItemType, + ) + } else { + name = fmt.Sprintf( + "%s %s", + finding.ItemName, + finding.ItemType, + ) + } + + msg := fmt.Sprintf(finding.Msg, name) + + if finding.ExtraMsg == "" { + fmt.Printf("\tLine %d: %s\n", finding.Line, msg) + } else { + fmt.Printf("\tLine %d: %s\n\t\t%s\n", finding.Line, msg, finding.ExtraMsg) + } + } + } + } + + if issuesFound { + os.Exit(1) + } +} + +func fatalErr(a ...any) { + fmt.Println(append([]any{"error:"}, a...)...) + os.Exit(1) +} diff --git a/github.go b/github.go new file mode 100644 index 0000000..c6a4665 --- /dev/null +++ b/github.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "fmt" + + "github.com/google/go-github/v48/github" + "go.arsenm.dev/lure-repo-bot/internal/analyze" + "go.arsenm.dev/lure-repo-bot/internal/types" + "golang.org/x/oauth2" +) + +func newClient(ctx context.Context, token string) *github.Client { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + return github.NewClient(tc) +} + +func writeFindings(ctx context.Context, c *github.Client, findings []analyze.Finding, path string, pr *types.PullRequest) error { + comments := make([]*github.DraftReviewComment, len(findings)) + + for i, finding := range findings { + var name string + if finding.Index != nil { + name = fmt.Sprintf( + "`%s[%v]` %s", + finding.ItemName, + finding.Index, + finding.ItemType, + ) + } else { + name = fmt.Sprintf( + "`%s` %s", + finding.ItemName, + finding.ItemType, + ) + } + + msg := fmt.Sprintf(finding.Msg, name) + + if finding.ExtraMsg != "" { + msg += "\n\n" + finding.ExtraMsg + } + + if finding.Line == 0 { + finding.Line = 1 + } + + fmt.Println(finding.Line) + + comments[i] = &github.DraftReviewComment{ + Line: github.Int(int(finding.Line)), + Path: github.String(path), + Body: github.String(msg), + Side: github.String("RIGHT"), + } + } + + if len(findings) > 0 { + rev, _, err := c.PullRequests.CreateReview( + ctx, + pr.Base.Repo.Owner.Login, + pr.Base.Repo.Name, + int(pr.Number), + &github.PullRequestReviewRequest{ + Comments: comments, + }, + ) + if err != nil { + return err + } + + _, _, err = c.PullRequests.SubmitReview( + ctx, + pr.Base.Repo.Owner.Login, + pr.Base.Repo.Name, + int(pr.Number), + *rev.ID, + &github.PullRequestReviewRequest{ + Body: github.String("Please re-request review from the bot after applying these fixes"), + Event: github.String("COMMENT"), + }, + ) + if err != nil { + return err + } + } else { + rev, _, err := c.PullRequests.CreateReview( + ctx, + pr.Base.Repo.Owner.Login, + pr.Base.Repo.Name, + int(pr.Number), + &github.PullRequestReviewRequest{}, + ) + if err != nil { + return err + } + + _, _, err = c.PullRequests.SubmitReview( + ctx, + pr.Base.Repo.Owner.Login, + pr.Base.Repo.Name, + int(pr.Number), + *rev.ID, + &github.PullRequestReviewRequest{ + Body: github.String("No issues found!"), + Event: github.String("APPROVE"), + }, + ) + if err != nil { + return err + } + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93379a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module go.arsenm.dev/lure-repo-bot + +go 1.19 + +require ( + github.com/adrg/strutil v0.3.0 + github.com/go-git/go-git/v5 v5.4.2 + github.com/google/go-github/v48 v48.0.0 + github.com/mitchellh/go-spdx v0.1.0 + golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be + mvdan.cc/sh/v3 v3.5.1 +) + +require ( + github.com/Microsoft/go-winio v0.4.16 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect + github.com/acomagu/bufpipe v1.0.3 // indirect + github.com/emirpasic/gods v1.12.0 // indirect + github.com/go-git/gcfg v1.5.0 // indirect + github.com/go-git/go-billy/v5 v5.3.1 // indirect + github.com/golang/protobuf v1.3.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/sergi/go-diff v1.1.0 // indirect + github.com/stretchr/testify v1.8.0 // indirect + github.com/xanzy/ssh-agent v0.3.0 // indirect + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect + golang.org/x/net v0.0.0-20210326060303-6b1517762897 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/sys v0.1.0 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + google.golang.org/appengine v1.6.7 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7f1e443 --- /dev/null +++ b/go.sum @@ -0,0 +1,141 @@ +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/adrg/strutil v0.3.0 h1:bi/HB2zQbDihC8lxvATDTDzkT4bG7PATtVnDYp5rvq4= +github.com/adrg/strutil v0.3.0/go.mod h1:Jz0wzBVE6Uiy9wxo62YEqEY1Nwto3QlLl1Il5gkLKWU= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v48 v48.0.0 h1:9H5fWVXFK6ZsRriyPbjtnFAkJnoj0WKFtTYfpCRrTm8= +github.com/google/go-github/v48 v48.0.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6KdvN3Gig= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +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/mitchellh/go-spdx v0.1.0 h1:50JnVzkL3kWreQ5Qb4Pi3Qx9e+bbYrt8QglJDpfeBEs= +github.com/mitchellh/go-spdx v0.1.0/go.mod h1:FFi4Cg1fBuN/JCtPtP8PEDmcBjvO3gijQVl28YjIBVQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 h1:QfTh0HpN6hlw6D3vu8DAwC8pBIwikq0AI1evdm+FksE= +golang.org/x/exp v0.0.0-20221031165847-c99f073a8326/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs= +golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.5.1 h1:hmP3UOw4f+EYexsJjFxvU38+kn+V/s2CclXHanIBkmQ= +mvdan.cc/sh/v3 v3.5.1/go.mod h1:1JcoyAKm1lZw/2bZje/iYKWicU/KMd0rsyJeKHnsK4E= diff --git a/internal/analyze/analyze.go b/internal/analyze/analyze.go new file mode 100644 index 0000000..52b6991 --- /dev/null +++ b/internal/analyze/analyze.go @@ -0,0 +1,384 @@ +package analyze + +import ( + "encoding/hex" + "net/mail" + "net/url" + "strings" + + "go.arsenm.dev/lure-repo-bot/internal/spdx" + "golang.org/x/exp/slices" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + +type Finding struct { + ItemType string + ItemName string + Line uint + Index any + Msg string + ExtraMsg string +} + +func AnalyzeScript(r *interp.Runner, fl *syntax.File) ([]Finding, error) { + var findings []Finding + + if _, ok := r.Vars["name"]; !ok { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: "name", + Msg: "The %s is required", + }) + } + + if _, ok := r.Vars["version"]; !ok { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: "version", + Msg: "The %s is required", + }) + } + + if _, ok := r.Vars["release"]; !ok { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: "release", + Msg: "The %s is required", + }) + } + + if _, ok := r.Funcs["package"]; !ok { + findings = append(findings, Finding{ + ItemType: "function", + ItemName: "package", + Msg: "The %s is required", + }) + } + + for name, scriptVar := range r.Vars { + _, scriptVar = scriptVar.Resolve(r.Env) + val := getVal(&scriptVar) + + // Remove any override suffix, so that we + // check all the overrides as well + cutName, _, _ := strings.Cut(name, "_") + + // build_vars has an underscore, and thus is a special case + // that must be accounted for + if strings.HasPrefix("name", "build_vars") { + cutName = "build_vars" + } + + switch cutName { + case "release": + valStr, ok := mustBeStr(val, name, &findings) + if !ok { + continue + } + + if !isNumeric(strings.TrimPrefix(valStr, "-")) { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be an integer", + }) + continue + } + case "epoch": + valStr, ok := mustBeStr(val, name, &findings) + if !ok { + continue + } + + if !isNumeric(valStr) { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be a positive integer", + }) + continue + } + case "homepage": + valStr, ok := mustBeStr(val, name, &findings) + if !ok { + continue + } + + _, err := url.ParseRequestURI(valStr) + if err != nil { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be a valid URL", + }) + continue + } + case "maintainer": + valStr, ok := mustBeStr(val, name, &findings) + if !ok { + continue + } + + addr, err := mail.ParseAddress(valStr) + if err != nil { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be a valid RFC 5322 address", + }) + continue + } + + if addr.Name == "" || addr.Address == "" { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must contain a name and email (e.g. Arsen Musayelyan )", + }) + continue + } + case "architectures": + valSlice, ok := mustBeArray(val, name, &findings) + if !ok { + continue + } + + if slices.Contains(valSlice, "noarch") || slices.Contains(valSlice, "any") { + findings = append(findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be set to 'all' to represent noarch/any", + }) + continue + } + case "licenses": + valSlice, ok := mustBeArray(val, name, &findings) + if !ok { + continue + } + + for _, val := range valSlice { + if strings.Contains(strings.ToLower(val), "custom") { + continue + } + + license := spdx.Licenses.License(val) + if license == nil { + similar := spdx.FindSimilarLicense(val) + + f := Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s contains an invalid SPDX license identifier: '" + val + "'.", + ExtraMsg: "A list of SPDX license identifiers can be found at https://spdx.org/licenses/.", + } + + if similar != "" { + f.Msg += " Did you mean '" + similar + "'?" + } + + findings = append(findings, f) + continue + } + } + case "provides": + mustBeArray(val, name, &findings) + case "conflicts": + mustBeArray(val, name, &findings) + case "deps": + mustBeArray(val, name, &findings) + case "build_deps": + mustBeArray(val, name, &findings) + case "replaces": + mustBeArray(val, name, &findings) + case "sources": + valSlice, ok := mustBeArray(val, name, &findings) + if !ok { + continue + } + + for i, val := range valSlice { + u, err := url.ParseRequestURI(val) + if err != nil { + findings = append(findings, Finding{ + ItemType: "element", + Index: i, + ItemName: name, + Msg: "The %s must be a valid URL", + }) + continue + } + query := u.Query() + + var validParams []string + if strings.HasPrefix(u.Scheme, "git+") { + validParams = []string{"tag", "branch", "commit", "depth", "name"} + } else { + validParams = []string{"archive"} + } + + for paramName := range query { + if strings.HasPrefix(paramName, "~") { + paramName = strings.TrimPrefix(paramName, "~") + } else { + continue + } + + if !slices.Contains(validParams, paramName) { + findings = append(findings, Finding{ + ItemType: "element", + ItemName: name, + Index: i, + Msg: "The %s contains an invalid parameter name '~" + paramName + "'", + }) + continue + } + } + } + case "checksums": + valSlice, ok := mustBeArray(val, name, &findings) + if !ok { + continue + } + + sourcesName := strings.Replace(name, "checksums", "sources", 1) + srcs, ok := r.Vars[sourcesName] + if !ok || len(srcs.List) != len(valSlice) { + findings = append(findings, Finding{ + ItemType: "array", + ItemName: name, + Msg: "The %s is not the same size as its corresponding sources array", + }) + } + + for i, val := range valSlice { + if val != "SKIP" && len(val) != 64 { + findings = append(findings, Finding{ + ItemType: "element", + ItemName: name, + Index: i, + Msg: "The %s contains an invalid SHA256 checksum. SHA256 hashes must be 64 characters in length.", + }) + continue + } + + if val != "SKIP" { + _, err := hex.DecodeString(val) + if err != nil { + findings = append(findings, Finding{ + ItemType: "element", + ItemName: name, + Index: i, + Msg: "The %s contains an invalid SHA256 checksum. SHA256 hashes must be valid hexadecimal.", + }) + continue + } + } + } + case "backup": + mustBeArray(val, name, &findings) + case "scripts": + mustBeMap(val, name, &findings) + } + } + + lns := FindLines(fl) + for i, finding := range findings { + if finding.ItemType == "function" { + ln, ok := lns.Funcs[finding.ItemName] + if ok { + findings[i].Line = ln + } + } else { + ln, ok := lns.Vars[finding.ItemName] + if ok { + findings[i].Line = ln + } + } + } + + return findings, nil +} + +func mustBeStr(val any, name string, findings *[]Finding) (string, bool) { + valStr, ok := val.(string) + if !ok { + *findings = append(*findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be a string", + }) + } + return valStr, ok +} + +func mustBeArray(val any, name string, findings *[]Finding) ([]string, bool) { + valSlice, ok := val.([]string) + if !ok { + *findings = append(*findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be an array", + }) + } + return valSlice, ok +} + +func mustBeMap(val any, name string, findings *[]Finding) (map[string]string, bool) { + valMap, ok := val.(map[string]string) + if !ok { + *findings = append(*findings, Finding{ + ItemType: "variable", + ItemName: name, + Msg: "The %s must be a map", + }) + } + return valMap, ok +} + +func getVal(v *expand.Variable) any { + if v.Str != "" { + return v.Str + } else if v.List != nil { + return v.List + } else if v.Map != nil { + return v.Map + } + + return nil +} + +func isNumeric(s string) bool { + index := strings.IndexFunc(s, func(r rune) bool { + return r < '0' || r > '9' + }) + return index == -1 +} + +type Lines struct { + Vars map[string]uint + Funcs map[string]uint +} + +func FindLines(fl *syntax.File) Lines { + out := Lines{map[string]uint{}, map[string]uint{}} + + for _, stmt := range fl.Stmts { + switch cmd := stmt.Cmd.(type) { + case *syntax.CallExpr: + if len(cmd.Assigns) == 0 { + continue + } + + name := cmd.Assigns[0].Name.Value + line := cmd.Assigns[0].Pos().Line() + out.Vars[name] = line + case *syntax.FuncDecl: + out.Funcs[cmd.Name.Value] = cmd.Pos().Line() + } + } + + return out +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go new file mode 100644 index 0000000..4078d1e --- /dev/null +++ b/internal/queue/queue.go @@ -0,0 +1,47 @@ +// Package queue provides an unbounded FIFO queue +// for payloads received via webhooks. +package queue + +import ( + "container/list" +) + +type Queue[T any] struct { + buf *list.List + out chan T + valAdded chan struct{} +} + +func New[T any]() *Queue[T] { + q := &Queue[T]{ + buf: list.New().Init(), + out: make(chan T), + valAdded: make(chan struct{}), + } + + go func() { + for { + if q.buf.Len() == 0 { + <-q.valAdded + } + + e := q.buf.Front() + q.out <- e.Value.(T) + q.buf.Remove(e) + } + }() + + return q +} + +func (q *Queue[T]) Add(val T) { + q.buf.PushBack(val) + select { + case q.valAdded <- struct{}{}: + default: + } +} + +func (q *Queue[T]) Channel() <-chan T { + return q.out +} diff --git a/internal/shutils/nop.go b/internal/shutils/nop.go new file mode 100644 index 0000000..a72747a --- /dev/null +++ b/internal/shutils/nop.go @@ -0,0 +1,55 @@ +/* + * LURE - Linux User REpository + * Copyright (C) 2022 Arsen Musayelyan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package shutils + +import ( + "context" + "io" + "os" +) + +func NopReadDir(context.Context, string) ([]os.FileInfo, error) { + return nil, os.ErrNotExist +} + +func NopStat(context.Context, string, bool) (os.FileInfo, error) { + return nil, os.ErrNotExist +} + +func NopExec(context.Context, []string) error { + return nil +} + +func NopOpen(context.Context, string, int, os.FileMode) (io.ReadWriteCloser, error) { + return NopRWC{}, nil +} + +type NopRWC struct{} + +func (NopRWC) Read([]byte) (int, error) { + return 0, io.EOF +} + +func (NopRWC) Write([]byte) (int, error) { + return 0, io.EOF +} + +func (NopRWC) Close() error { + return nil +} diff --git a/internal/spdx/spdx.go b/internal/spdx/spdx.go new file mode 100644 index 0000000..e06ca70 --- /dev/null +++ b/internal/spdx/spdx.go @@ -0,0 +1,102 @@ +package spdx + +import ( + "context" + "log" + "sync" + "time" + + "github.com/adrg/strutil/metrics" + "github.com/mitchellh/go-spdx" +) + +type syncLicenseList struct { + *spdx.LicenseList + *sync.Mutex +} + +var Licenses = syncLicenseList{ + LicenseList: &spdx.LicenseList{}, + Mutex: &sync.Mutex{}, +} + +func (sll syncLicenseList) License(id string) *spdx.LicenseInfo { + sll.Lock() + l := sll.LicenseList.License(id) + sll.Unlock() + return l +} + +func StartUpdater(ctx context.Context) { + err := Update() + if err != nil { + log.Fatalln("Error updating SPDX license list:", err) + } + + ticker := time.NewTicker(time.Hour) + go func() { + for { + select { + case <-ticker.C: + err = Update() + if err != nil { + log.Println("Error updating SPDX license list:", err) + } + case <-ctx.Done(): + ticker.Stop() + return + } + } + }() +} + +func Update() error { + list, err := spdx.List() + if err != nil { + return err + } + Licenses.Lock() + Licenses.LicenseList = list + Licenses.Unlock() + + return nil +} + +// findSimilar finds the most similar license ID +// to the one provided +func FindSimilarLicense(s string) string { + Licenses.Lock() + defer Licenses.Unlock() + + jw := metrics.NewJaroWinkler() + jw.CaseSensitive = false + + sims := make([]float64, len(Licenses.Licenses)) + for i, license := range Licenses.Licenses { + sims[i] = jw.Compare(s, license.ID) + } + + index := maxIndex(sims) + + if index == -1 { + return "" + } else { + return Licenses.Licenses[index].ID + } +} + +func maxIndex(ff []float64) int { + if len(ff) == 0 { + return -1 + } + + m := ff[0] + mi := 0 + for i, f := range ff { + if f > m { + m = f + mi = i + } + } + return mi +} diff --git a/internal/types/github.go b/internal/types/github.go new file mode 100644 index 0000000..21842ee --- /dev/null +++ b/internal/types/github.go @@ -0,0 +1,310 @@ +package types + +import "time" + +type PullRequestPayload struct { + IsGitea bool `json:"-"` + Action string `json:"action"` + Number int64 `json:"number"` + Changes struct { + Title struct { + From string `json:"from"` + } `json:"title"` + Body struct { + From string `json:"from"` + } `json:"body"` + } `json:"changes"` + Label Label `json:"label"` + PullRequest PullRequest `json:"pull_request"` + Repository Repository `json:"repository"` + Organization Organization `json:"organization"` + Sender User `json:"sender"` +} + +type Repository struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Private bool `json:"private"` + Owner User `json:"owner"` + HTMLURL string `json:"html_url"` + Description string `json:"description"` + Fork bool `json:"fork"` + URL string `json:"url"` + ForksURL string `json:"forks_url"` + KeysURL string `json:"keys_url"` + CollaboratorsURL string `json:"collaborators_url"` + TeamsURL string `json:"teams_url"` + HooksURL string `json:"hooks_url"` + IssueEventsURL string `json:"issue_events_url"` + EventsURL string `json:"events_url"` + AssigneesURL string `json:"assignees_url"` + BranchesURL string `json:"branches_url"` + TagsURL string `json:"tags_url"` + BlobsURL string `json:"blobs_url"` + GitTagsURL string `json:"git_tags_url"` + GitRefsURL string `json:"git_refs_url"` + TreesURL string `json:"trees_url"` + StatusesURL string `json:"statuses_url"` + LanguagesURL string `json:"languages_url"` + StargazersURL string `json:"stargazers_url"` + ContributorsURL string `json:"contributors_url"` + SubscribersURL string `json:"subscribers_url"` + SubscriptionURL string `json:"subscription_url"` + CommitsURL string `json:"commits_url"` + GitCommitsURL string `json:"git_commits_url"` + CommentsURL string `json:"comments_url"` + IssueCommentURL string `json:"issue_comment_url"` + ContentsURL string `json:"contents_url"` + CompareURL string `json:"compare_url"` + MergesURL string `json:"merges_url"` + ArchiveURL string `json:"archive_url"` + DownloadsURL string `json:"downloads_url"` + IssuesURL string `json:"issues_url"` + PullsURL string `json:"pulls_url"` + MilestonesURL string `json:"milestones_url"` + NotificationsURL string `json:"notifications_url"` + LabelsURL string `json:"labels_url"` + ReleasesURL string `json:"releases_url"` + DeploymentsURL string `json:"deployments_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PushedAt time.Time `json:"pushed_at"` + GitURL string `json:"git_url"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + SvnURL string `json:"svn_url"` + Homepage string `json:"homepage"` + Size int64 `json:"size"` + StargazersCount int64 `json:"stargazers_count"` + WatchersCount int64 `json:"watchers_count"` + Language string `json:"language"` + HasIssues bool `json:"has_issues"` + HasProjects bool `json:"has_projects"` + HasDownloads bool `json:"has_downloads"` + HasWiki bool `json:"has_wiki"` + HasPages bool `json:"has_pages"` + ForksCount int64 `json:"forks_count"` + MirrorURL string `json:"mirror_url"` + Archived bool `json:"archived"` + Disabled bool `json:"disabled"` + OpenIssuesCount int64 `json:"open_issues_count"` + License License `json:"license"` + Forks int64 `json:"forks"` + OpenIssues int64 `json:"open_issues"` + Watchers int64 `json:"watchers"` + DefaultBranch string `json:"default_branch"` +} + +type License struct { + Key string `json:"key"` + Name string `json:"name"` + URL string `json:"url"` + SpdxID string `json:"spdx_id"` + NodeID string `json:"node_id"` + HTMLURL string `json:"html_url"` +} + +type PullRequest struct { + URL string `json:"url"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + HTMLURL string `json:"html_url"` + DiffURL string `json:"diff_url"` + PatchURL string `json:"patch_url"` + IssueURL string `json:"issue_url"` + CommitsURL string `json:"commits_url"` + ReviewCommentsURL string `json:"review_comments_url"` + ReviewCommentURL string `json:"review_comment_url"` + CommentsURL string `json:"comments_url"` + StatusesURL string `json:"statuses_url"` + Number int64 `json:"number"` + State string `json:"state"` + Locked bool `json:"locked"` + Title string `json:"title"` + User User `json:"user"` + Body string `json:"body"` + Labels []Label `json:"labels"` + Milestone Milestone `json:"milestone"` + ActiveLockReason string `json:"active_lock_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt time.Time `json:"closed_at"` + MergedAt time.Time `json:"merged_at"` + MergeCommitSha string `json:"merge_commit_sha"` + Assignee User `json:"assignee"` + Assignees []User `json:"assignees"` + RequestedReviewers []User `json:"requested_reviewers"` + RequestedTeams []Team `json:"requested_teams"` + Head Commit `json:"head"` + Base Commit `json:"base"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + HTML struct { + Href string `json:"href"` + } `json:"html"` + Issue struct { + Href string `json:"href"` + } `json:"issue"` + Comments struct { + Href string `json:"href"` + } `json:"comments"` + ReviewComments struct { + Href string `json:"href"` + } `json:"review_comments"` + ReviewComment struct { + Href string `json:"href"` + } `json:"review_comment"` + Commits struct { + Href string `json:"href"` + } `json:"commits"` + Statuses struct { + Href string `json:"href"` + } `json:"statuses"` + } `json:"_links"` + AuthorAssociation string `json:"author_association"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + Mergeable bool `json:"mergeable"` + Rebaseable bool `json:"rebaseable"` + MergeableState string `json:"mergeable_state"` + MergedBy User `json:"merged_by"` + Comments int64 `json:"comments"` + ReviewComments int64 `json:"review_comments"` + MaintainerCanModify bool `json:"maintainer_can_modify"` + Commits int64 `json:"commits"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + ChangedFiles int64 `json:"changed_files"` +} + +type Label struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + URL string `json:"url"` + Name string `json:"name"` + Color string `json:"color"` + Default bool `json:"default"` + Description string `json:"description"` +} + +type Milestone struct { + URL string `json:"url"` + HTMLURL string `json:"html_url"` + LabelsURL string `json:"labels_url"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Creator User `json:"creator"` + OpenIssues int64 `json:"open_issues"` + ClosedIssues int64 `json:"closed_issues"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DueOn *time.Time `json:"due_on"` + ClosedAt time.Time `json:"closed_at"` +} + +type User struct { + Login string `json:"login"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + AvatarURL string `json:"avatar_url"` + GravatarID string `json:"gravatar_id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + FollowersURL string `json:"followers_url"` + FollowingURL string `json:"following_url"` + GistsURL string `json:"gists_url"` + StarredURL string `json:"starred_url"` + SubscriptionsURL string `json:"subscriptions_url"` + OrganizationsURL string `json:"organizations_url"` + ReposURL string `json:"repos_url"` + EventsURL string `json:"events_url"` + ReceivedEventsURL string `json:"received_events_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` +} + +type Team struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Privacy string `json:"privacy"` + Permission string `json:"permission"` + MembersURL string `json:"members_url"` + RepositoriesURL string `json:"repositories_url"` +} + +type Commit struct { + Label string `json:"label"` + Ref string `json:"ref"` + Sha string `json:"sha"` + User User `json:"user"` + Repo Repository `json:"repo"` +} + +type Organization struct { + Login string `json:"login"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + URL string `json:"url"` + ReposURL string `json:"repos_url"` + EventsURL string `json:"events_url"` + HooksURL string `json:"hooks_url"` + IssuesURL string `json:"issues_url"` + MembersURL string `json:"members_url"` + PublicMembersURL string `json:"public_members_url"` + AvatarURL string `json:"avatar_url"` + Description string `json:"description"` + Name string `json:"name"` + Company string `json:"company"` + Blog string `json:"blog"` + Location string `json:"location"` + Email string `json:"email"` + TwitterUsername string `json:"twitter_username"` + IsVerified bool `json:"is_verified"` + HasOrganizationProjects bool `json:"has_organization_projects"` + HasRepositoryProjects bool `json:"has_repository_projects"` + PublicRepos int64 `json:"public_repos"` + PublicGists int64 `json:"public_gists"` + Followers int64 `json:"followers"` + Following int64 `json:"following"` + HTMLURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Type string `json:"type"` + TotalPrivateRepos int64 `json:"total_private_repos"` + OwnedPrivateRepos int64 `json:"owned_private_repos"` + PrivateGists int64 `json:"private_gists"` + DiskUsage int64 `json:"disk_usage"` + Collaborators int64 `json:"collaborators"` + BillingEmail string `json:"billing_email"` + Plan Plan `json:"plan"` + DefaultRepositoryPermission string `json:"default_repository_permission"` + MembersCanCreateRepositories bool `json:"members_can_create_repositories"` + TwoFactorRequirementEnabled bool `json:"two_factor_requirement_enabled"` + MembersAllowedRepositoryCreationType string `json:"members_allowed_repository_creation_type"` + MembersCanCreatePublicRepositories bool `json:"members_can_create_public_repositories"` + MembersCanCreatePrivateRepositories bool `json:"members_can_create_private_repositories"` + MembersCanCreateint64ernalRepositories bool `json:"members_can_create_int64ernal_repositories"` + MembersCanCreatePages bool `json:"members_can_create_pages"` + MembersCanForkPrivateRepositories bool `json:"members_can_fork_private_repositories"` +} + +type Plan struct { + Name string `json:"name"` + Space int64 `json:"space"` + PrivateRepos int64 `json:"private_repos"` + FilledSeats int64 `json:"filled_seats"` + Seats int64 `json:"seats"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2f3c87e --- /dev/null +++ b/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "go.arsenm.dev/lure-repo-bot/internal/queue" + "go.arsenm.dev/lure-repo-bot/internal/spdx" + "go.arsenm.dev/lure-repo-bot/internal/types" +) + +func main() { + ctx := context.Background() + + ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + spdx.StartUpdater(ctx) + + jobQueue := queue.New[*types.PullRequestPayload]() + + startWebhookWorkers(ctx, jobQueue) + + addr := ":8080" + if os.Getenv("LURE_BOT_ADDR") != "" { + addr = os.Getenv("LURE_BOT_ADDR") + } + + serveWebhook(ctx, addr, jobQueue) +} diff --git a/webhook.go b/webhook.go new file mode 100644 index 0000000..37a58ee --- /dev/null +++ b/webhook.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "strings" + + "go.arsenm.dev/lure-repo-bot/internal/queue" + "go.arsenm.dev/lure-repo-bot/internal/types" +) + +type prQueue = *queue.Queue[*types.PullRequestPayload] + +func serveWebhook(ctx context.Context, addr string, jobQueue prQueue) { + mux := http.NewServeMux() + + mux.HandleFunc("/webhook", func(res http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + res.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if req.Header.Get("X-GitHub-Event") != "pull_request" { + http.Error(res, "Only pull_request events are accepted by this bot", http.StatusBadRequest) + return + } + + payload, err := secureDecode(req) + if err != nil { + http.Error(res, err.Error(), http.StatusInternalServerError) + return + } + + jobQueue.Add(payload) + }) + + srv := http.Server{ + Addr: addr, + Handler: mux, + BaseContext: func(net.Listener) context.Context { + return ctx + }, + } + + go func() { + <-ctx.Done() + srv.Shutdown(context.Background()) + }() + + srv.ListenAndServe() +} + +func secureDecode(req *http.Request) (*types.PullRequestPayload, error) { + sigStr := req.Header.Get("X-Hub-Signature-256") + sig, err := hex.DecodeString(strings.TrimPrefix(sigStr, "sha256=")) + if err != nil { + return nil, err + } + + secretStr, ok := os.LookupEnv("LURE_BOT_SECRET") + if !ok { + return nil, errors.New("LURE_BOT_SECRET must be set to the secret used for setting up the github webhook") + } + secret := []byte(secretStr) + + h := hmac.New(sha256.New, secret) + r := io.TeeReader(req.Body, h) + + payload := &types.PullRequestPayload{} + err = json.NewDecoder(r).Decode(payload) + if err != nil { + return nil, err + } + + if !hmac.Equal(h.Sum(nil), sig) { + return nil, errors.New("webhook signature mismatch") + } + + return payload, nil +} diff --git a/workers.go b/workers.go new file mode 100644 index 0000000..e94ace3 --- /dev/null +++ b/workers.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "runtime" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/google/go-github/v48/github" + "go.arsenm.dev/lure-repo-bot/internal/analyze" + "go.arsenm.dev/lure-repo-bot/internal/shutils" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/interp" + "mvdan.cc/sh/v3/syntax" +) + +func startWebhookWorkers(ctx context.Context, jobQueue prQueue) { + client := newClient(ctx, os.Getenv("LURE_BOT_GITHUB_TOKEN")) + + for i := 0; i < runtime.NumCPU(); i++ { + go startWebhookWorker(ctx, jobQueue, client) + } +} + +func startWebhookWorker(ctx context.Context, jobQueue prQueue, client *github.Client) { + for { + select { + case <-ctx.Done(): + return + case payload := <-jobQueue.Channel(): + if payload.Action == "opened" || payload.Action == "ready_for_review" || payload.Action == "review_requested" { + if payload.PullRequest.Draft { + continue + } + + // Check if review was requested from the bot + if payload.Action == "review_requested" { + user, _, err := client.Users.Get(ctx, "") + if err != nil { + log.Println("Error getting github user:", err) + continue + } + + found := false + for _, reviewer := range payload.PullRequest.RequestedReviewers { + if reviewer.ID == *user.ID { + found = true + break + } + } + + if !found { + continue + } + } + + fls, _, err := client.PullRequests.ListFiles( + ctx, + payload.PullRequest.Base.Repo.Owner.Login, + payload.PullRequest.Base.Repo.Name, + int(payload.PullRequest.Number), + nil, + ) + if err != nil { + log.Println("Error listing PR files:", err) + continue + } + + head := payload.PullRequest.Head + + tmpdir, err := os.MkdirTemp("/tmp", "lure-repo-bot.*") + if err != nil { + log.Println("Error creating temporary directory:", err) + continue + } + + r, err := git.PlainClone(tmpdir, true, &git.CloneOptions{URL: head.Repo.HTMLURL}) + if err != nil { + log.Println("Error cloning git repo:", err) + continue + } + + files, err := getFiles(r, head.Sha) + if err != nil { + log.Println("Error getting files in repo:", err) + continue + } + + err = files.ForEach(func(f *object.File) error { + if !strings.Contains(f.Name, "lure.sh") { + return nil + } + + if !fileInPR(fls, f) { + return nil + } + + fmt.Println(f.Name, fileInPR(fls, f)) + + r, err := f.Reader() + if err != nil { + return err + } + + fl, err := syntax.NewParser().Parse(r, "lure.sh") + if err != nil { + return err + } + + var nopRWC shutils.NopRWC + runner, err := interp.New( + interp.Env(expand.ListEnviron()), + interp.StdIO(nopRWC, nopRWC, os.Stderr), + interp.ExecHandler(shutils.NopExec), + interp.ReadDirHandler(shutils.NopReadDir), + interp.OpenHandler(shutils.NopOpen), + interp.StatHandler(shutils.NopStat), + ) + if err != nil { + return err + } + + err = runner.Run(ctx, fl) + if err != nil { + return err + } + + findings, err := analyze.AnalyzeScript(runner, fl) + if err != nil { + return err + } + + return writeFindings(ctx, client, findings, f.Name, &payload.PullRequest) + }) + if err != nil { + log.Println("Error analyzing files:", err) + continue + } + + err = os.RemoveAll(tmpdir) + if err != nil { + log.Println("Error removing temporary directory:", err) + continue + } + } + } + } +} + +func fileInPR(prFiles []*github.CommitFile, file *object.File) bool { + for _, prFile := range prFiles { + if *prFile.Filename == file.Name { + return true + } + } + return false +} + +func getFiles(r *git.Repository, sha string) (*object.FileIter, error) { + co, err := r.CommitObject(plumbing.NewHash(sha)) + if err != nil { + return nil, err + } + + tree, err := co.Tree() + if err != nil { + return nil, err + } + + return tree.Files(), nil +}