diff --git a/README.md b/README.md index 573ae5d..f427156 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A fast, flexible CLI tool for enforcing test coverage thresholds in Go projects. - Colored `json` and `yaml` output. - Built-in file or package regex filtering with `--skip`. - Save and compare against historical results from a commit, branch, tag, or user defined label. +- **Post coverage results as comments to GitHub, GitLab, and Gitea pull/merge requests.** - Works seamlessly in CI/CD environments. ## 🚫 Not Supported @@ -318,6 +319,78 @@ $ go-covercheck --delete-history nonexistent Error: no history entry found for ref: nonexistent ``` +## 💬 Comment Posting + +`go-covercheck` can post coverage results as comments to GitHub, GitLab, and Gitea pull requests or merge requests. This is particularly useful in CI/CD pipelines as a pre-merge gate. + +### ⚙️ Configuration + +Comment posting can be configured via the config file or CLI flags: + +```yaml +comment: + enabled: true + platform: + type: github # github|gitlab|gitea|gogs + baseUrl: https://api.github.com # optional for self-hosted instances + token: ghp_xxxxxxxxxxxxxxxxxxxx # API authentication token + repository: owner/repo-name # repository identifier + pullRequestId: 123 # PR/MR ID + includeColors: true # include emojis and formatting + updateExisting: false # update existing comment vs create new +``` + +### 🚀 CLI Usage + +```bash +# Post results to GitHub PR +go-covercheck coverage.out \ + --comment \ + --comment-platform github \ + --comment-token $GITHUB_TOKEN \ + --comment-repository owner/repo \ + --comment-pr 123 + +# Post results to GitLab MR +go-covercheck coverage.out \ + --comment \ + --comment-platform gitlab \ + --comment-token $GITLAB_TOKEN \ + --comment-repository group/project \ + --comment-pr 456 + +# Post to self-hosted Gogs instance +go-covercheck coverage.out \ + --comment \ + --comment-platform gogs \ + --comment-base-url https://gogs.company.com \ + --comment-token $GOGS_TOKEN \ + --comment-repository org/repo \ + --comment-pr 789 +``` + +### 📝 Comment Format + +Comments are posted in Markdown format with: +- Overall pass/fail status with emojis +- Total coverage summary table +- Detailed breakdown of failing files and packages +- Required improvements with specific percentages +- Color coding via emojis (✅❌) and formatting + +### 🔒 Authentication + +Each platform requires an API token: +- **GitHub**: Personal Access Token or GitHub App token with `repo` scope +- **GitLab**: Personal Access Token or Project Access Token with `api` scope +- **Gitea**: Application Token with repository permissions +- **Gogs**: Access Token with repository permissions + +Tokens can be provided via: +- CLI flag: `--comment-token` +- Environment variable: `GITHUB_TOKEN`, `GITLAB_TOKEN`, etc. +- Config file: `comment.platform.token` + ## 📤 Output Formats `go-covercheck` supports multiple output formats. The default is `table`, but you can specify other formats using the `--format` flag (short form `-f`) or through the `format:` field of the config file. diff --git a/cmd/go-covercheck/root.go b/cmd/go-covercheck/root.go index b0413d8..2929e9a 100644 --- a/cmd/go-covercheck/root.go +++ b/cmd/go-covercheck/root.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "io" @@ -11,6 +12,7 @@ import ( "github.com/fatih/color" "github.com/jedib0t/go-pretty/v6/text" + "github.com/mach6/go-covercheck/pkg/comment" "github.com/mach6/go-covercheck/pkg/compute" "github.com/mach6/go-covercheck/pkg/config" "github.com/mach6/go-covercheck/pkg/output" @@ -101,6 +103,27 @@ const ( InitFlag = "init" InitFlagUsage = "create a sample .go-covercheck.yml config file in the current directory" + CommentFlag = "comment" + CommentFlagUsage = "post coverage results as a comment to PR/MR" + + CommentPlatformFlag = "comment-platform" + CommentPlatformFlagUsage = "platform to post comment to [github|gitlab|gitea|gogs]" + + CommentBaseURLFlag = "comment-base-url" + CommentBaseURLFlagUsage = "base URL for the platform API (for self-hosted instances)" + + CommentTokenFlag = "comment-token" + CommentTokenFlagUsage = "authentication token for platform API" + + CommentRepositoryFlag = "comment-repository" + CommentRepositoryFlagUsage = "repository identifier (e.g., owner/repo)" + + CommentPRFlag = "comment-pr" + CommentPRFlagUsage = "pull request or merge request ID" + + CommentUpdateFlag = "comment-update" + CommentUpdateFlagUsage = "update existing comment instead of creating new one" + // ConfigFilePermissions permissions. ConfigFilePermissions = 0600 ) @@ -193,6 +216,11 @@ func run(cmd *cobra.Command, args []string) error { return err } + // handle comment posting + if err := handleCommentPosting(results, cfg); err != nil { + return err + } + if failed { os.Exit(1) } @@ -474,6 +502,30 @@ func applyConfigOverrides(cfg *config.Config, cmd *cobra.Command, noConfigFile b cfg.ModuleName = v } + // Comment configuration overrides + if v, _ := cmd.Flags().GetBool(CommentFlag); cmd.Flags().Changed(CommentFlag) || + noConfigFile { + cfg.Comment.Enabled = v + } + if v, _ := cmd.Flags().GetString(CommentPlatformFlag); cmd.Flags().Changed(CommentPlatformFlag) { + cfg.Comment.Platform.Type = v + } + if v, _ := cmd.Flags().GetString(CommentBaseURLFlag); cmd.Flags().Changed(CommentBaseURLFlag) { + cfg.Comment.Platform.BaseURL = v + } + if v, _ := cmd.Flags().GetString(CommentTokenFlag); cmd.Flags().Changed(CommentTokenFlag) { + cfg.Comment.Platform.Token = v + } + if v, _ := cmd.Flags().GetString(CommentRepositoryFlag); cmd.Flags().Changed(CommentRepositoryFlag) { + cfg.Comment.Platform.Repository = v + } + if v, _ := cmd.Flags().GetInt(CommentPRFlag); cmd.Flags().Changed(CommentPRFlag) { + cfg.Comment.Platform.PullRequestID = v + } + if v, _ := cmd.Flags().GetBool(CommentUpdateFlag); cmd.Flags().Changed(CommentUpdateFlag) { + cfg.Comment.Platform.UpdateExisting = v + } + // set cfg.Total thresholds to the global values, iff no override was specified for each. if v, _ := cmd.Flags().GetFloat64(StatementThresholdFlag); !cmd.Flags().Changed(TotalStatementThresholdFlag) && cfg.Total[config.StatementsSection] == config.StatementThresholdDefault { @@ -651,6 +703,79 @@ func initFlags(cmd *cobra.Command) { false, InitFlagUsage, ) + + cmd.Flags().Bool( + CommentFlag, + false, + CommentFlagUsage, + ) + + cmd.Flags().String( + CommentPlatformFlag, + "", + CommentPlatformFlagUsage, + ) + + cmd.Flags().String( + CommentBaseURLFlag, + "", + CommentBaseURLFlagUsage, + ) + + cmd.Flags().String( + CommentTokenFlag, + "", + CommentTokenFlagUsage, + ) + + cmd.Flags().String( + CommentRepositoryFlag, + "", + CommentRepositoryFlagUsage, + ) + + cmd.Flags().Int( + CommentPRFlag, + 0, + CommentPRFlagUsage, + ) + + cmd.Flags().Bool( + CommentUpdateFlag, + false, + CommentUpdateFlagUsage, + ) +} + +func handleCommentPosting(results compute.Results, cfg *config.Config) error { + // Skip comment posting if not enabled + if !cfg.Comment.Enabled { + return nil + } + + // Validate required comment configuration + if cfg.Comment.Platform.Type == "" { + return fmt.Errorf("comment platform type is required when comment posting is enabled") + } + + // Create poster based on platform type + poster, err := comment.NewPoster(cfg.Comment.Platform.Type, cfg.Comment.Platform.BaseURL) + if err != nil { + return fmt.Errorf("failed to create comment poster: %w", err) + } + + // Set default values for color inclusion (enabled by default unless explicitly disabled) + if cfg.Comment.Platform.IncludeColors == false && !cfg.NoColor { + cfg.Comment.Platform.IncludeColors = true + } + + // Post the comment + ctx := context.Background() + if err := poster.PostComment(ctx, results, cfg); err != nil { + return fmt.Errorf("failed to post comment: %w", err) + } + + return nil } func shouldSkip(filename string, skip []string) bool { diff --git a/go.mod b/go.mod index c086987..308e006 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,17 @@ module github.com/mach6/go-covercheck go 1.24 require ( + code.gitea.io/sdk/gitea v0.21.0 github.com/fatih/color v1.18.0 github.com/go-git/go-git/v6 v6.0.0-20250711134917-1f24ae85fe16 github.com/goccy/go-yaml v1.18.0 + github.com/google/go-github/v67 v67.0.0 github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f github.com/jedib0t/go-pretty/v6 v6.6.8 github.com/mattn/go-colorable v0.1.14 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 + gitlab.com/gitlab-org/api/client-go v0.137.0 golang.org/x/term v0.33.0 golang.org/x/tools v0.35.0 gopkg.in/yaml.v3 v3.0.1 @@ -18,15 +21,23 @@ require ( require ( dario.cat/mergo v1.0.1 // indirect + github.com/42wim/httpsig v1.2.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect + github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -39,6 +50,8 @@ require ( golang.org/x/crypto v0.40.0 // indirect golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b // indirect golang.org/x/net v0.42.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect + golang.org/x/time v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 73bdd2a..805c45a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4= +code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA= +github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= @@ -16,6 +20,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL 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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -24,6 +30,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA= @@ -34,8 +42,25 @@ github.com/go-git/go-git/v6 v6.0.0-20250711134917-1f24ae85fe16 h1:LGHFWd3pmIuMug github.com/go-git/go-git/v6 v6.0.0-20250711134917-1f24ae85fe16/go.mod h1:gI6xSrrkXH4EKP38iovrsY2EYf2XDU3DrIZRshlNDm0= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:04sojTxgYxu1L4Hn7Tgf7UVtIosVa6CuHtvNY+7T1K4= +github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v67 v67.0.0 h1:g11NDAmfaBaCO8qYdI9fsmbaRipHNWRIU/2YGvlh4rg= +github.com/google/go-github/v67 v67.0.0/go.mod h1:zH3K7BxjFndr9QSeFibx4lTKkYS3K9nDanoI1NjaOtY= +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.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -76,21 +101,40 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gitlab.com/gitlab-org/api/client-go v0.137.0 h1:H26yL44qnb38Czl20pEINCJrcj63W6/BX8iKPVUKQP0= +gitlab.com/gitlab-org/api/client-go v0.137.0/go.mod h1:AcAYES3lfkIS4zhso04S/wyUaWQmDYve2Fd9AF7C6qc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA= golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/pkg/comment/comment_test.go b/pkg/comment/comment_test.go new file mode 100644 index 0000000..ca3d178 --- /dev/null +++ b/pkg/comment/comment_test.go @@ -0,0 +1,308 @@ +package comment + +import ( + "context" + "strings" + "testing" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +func TestFormatMarkdown(t *testing.T) { + // Create test results + results := compute.Results{ + ByFile: []compute.ByFile{ + { + By: compute.By{ + StatementPercentage: 80.5, + BlockPercentage: 75.0, + StatementThreshold: 70.0, + BlockThreshold: 60.0, + Failed: false, + }, + File: "test.go", + }, + }, + ByPackage: []compute.ByPackage{ + { + By: compute.By{ + StatementPercentage: 80.5, + BlockPercentage: 75.0, + StatementThreshold: 70.0, + BlockThreshold: 60.0, + Failed: false, + }, + Package: "github.com/test/package", + }, + }, + ByTotal: compute.Totals{ + Statements: compute.TotalStatements{ + Coverage: "161/200", + Percentage: 80.5, + Threshold: 70.0, + Failed: false, + }, + Blocks: compute.TotalBlocks{ + Coverage: "75/100", + Percentage: 75.0, + Threshold: 60.0, + Failed: false, + }, + }, + } + + cfg := &config.Config{} + + // Test successful coverage report + result := FormatMarkdown(results, cfg, true) + + // Verify basic structure + if !strings.Contains(result, "## 🚦 Coverage Report") { + t.Error("Missing coverage report header") + } + if !strings.Contains(result, "Coverage check passed") { + t.Error("Missing success message") + } + if !strings.Contains(result, "### 📊 Total Coverage") { + t.Error("Missing total coverage section") + } + if !strings.Contains(result, "go-covercheck") { + t.Error("Missing tool attribution") + } + + // Test with failed coverage + results.ByTotal.Statements.Failed = true + results.ByFile[0].By.Failed = true + results.ByPackage[0].By.Failed = true + + result = FormatMarkdown(results, cfg, true) + + if !strings.Contains(result, "Coverage check failed") { + t.Error("Missing failure message") + } + if !strings.Contains(result, "### 📋 Coverage Details") { + t.Error("Missing coverage details section") + } + if !strings.Contains(result, "### 💡 Required Improvements") { + t.Error("Missing improvements section") + } +} + +func TestNewPoster(t *testing.T) { + tests := []struct { + platform string + wantErr bool + }{ + {"github", false}, + {"gitlab", false}, + {"gitea", false}, + {"gogs", false}, + {"GitHub", false}, // case insensitive + {"GITLAB", false}, // case insensitive + {"GOGS", false}, // case insensitive + {"unsupported", true}, + {"", true}, + } + + for _, tt := range tests { + t.Run(tt.platform, func(t *testing.T) { + _, err := NewPoster(tt.platform, "") + if (err != nil) != tt.wantErr { + t.Errorf("NewPoster() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGitHubPoster_Validation(t *testing.T) { + poster := NewGitHubPoster("") + + tests := []struct { + name string + cfg *config.Config + wantErr bool + errMsg string + }{ + { + name: "missing token", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Repository: "owner/repo", + PullRequestID: 123, + }, + }, + }, + wantErr: true, + errMsg: "github token is required", + }, + { + name: "missing repository", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + PullRequestID: 123, + }, + }, + }, + wantErr: true, + errMsg: "repository is required", + }, + { + name: "missing PR ID", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + Repository: "owner/repo", + }, + }, + }, + wantErr: true, + errMsg: "pull request ID is required", + }, + { + name: "valid config", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + Repository: "owner/repo", + PullRequestID: 123, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create empty results for testing + results := compute.Results{} + + err := poster.PostComment(context.Background(), results, tt.cfg) + + if tt.wantErr { + if err == nil { + t.Errorf("PostComment() expected error but got none") + } else if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("PostComment() error = %v, want error containing %v", err, tt.errMsg) + } + } else if !tt.wantErr && err != nil { + // For valid config, we expect a network error since we're not mocking the HTTP client + // but we should not get a validation error + if strings.Contains(err.Error(), "token is required") || + strings.Contains(err.Error(), "repository is required") || + strings.Contains(err.Error(), "pull request ID is required") { + t.Errorf("PostComment() got validation error with valid config: %v", err) + } + } + }) + } +} + +func TestGogsPoster_Validation(t *testing.T) { + poster := NewGogsPoster("") + + tests := []struct { + name string + cfg *config.Config + wantErr bool + errMsg string + }{ + { + name: "missing token", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Repository: "owner/repo", + PullRequestID: 123, + }, + }, + }, + wantErr: true, + errMsg: "gogs token is required", + }, + { + name: "missing repository", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + PullRequestID: 123, + }, + }, + }, + wantErr: true, + errMsg: "repository is required", + }, + { + name: "invalid repository format", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + Repository: "invalid", + PullRequestID: 123, + }, + }, + }, + wantErr: true, + errMsg: "invalid repository format", + }, + { + name: "missing pull request ID", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + Repository: "owner/repo", + }, + }, + }, + wantErr: true, + errMsg: "pull request ID is required", + }, + { + name: "valid config", + cfg: &config.Config{ + Comment: config.CommentConfig{ + Platform: config.PlatformConfig{ + Token: "token", + Repository: "owner/repo", + PullRequestID: 123, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create empty results for testing + results := compute.Results{} + + err := poster.PostComment(context.Background(), results, tt.cfg) + + if tt.wantErr { + if err == nil { + t.Errorf("PostComment() expected error but got none") + } else if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("PostComment() error = %v, want error containing %v", err, tt.errMsg) + } + } else if !tt.wantErr && err != nil { + // For valid config, we expect a network error since we're not mocking the HTTP client + // but we should not get a validation error + if strings.Contains(err.Error(), "token is required") || + strings.Contains(err.Error(), "repository is required") || + strings.Contains(err.Error(), "pull request ID is required") { + t.Errorf("PostComment() got validation error with valid config: %v", err) + } + } + }) + } +} diff --git a/pkg/comment/factory.go b/pkg/comment/factory.go new file mode 100644 index 0000000..af25a22 --- /dev/null +++ b/pkg/comment/factory.go @@ -0,0 +1,26 @@ +package comment + +import ( + "fmt" + "strings" +) + +// SupportedPlatforms lists all supported platforms. +var SupportedPlatforms = []string{"github", "gitlab", "gitea", "gogs"} + +// NewPoster creates a new Poster instance based on the platform type. +func NewPoster(platformType, baseURL string) (Poster, error) { + switch strings.ToLower(platformType) { + case "github": + return NewGitHubPoster(baseURL), nil + case "gitlab": + return NewGitLabPoster(baseURL), nil + case "gitea": + return NewGiteaPoster(baseURL), nil + case "gogs": + return NewGogsPoster(baseURL), nil + default: + return nil, fmt.Errorf("unsupported platform type: %s (supported: %s)", + platformType, strings.Join(SupportedPlatforms, ", ")) + } +} diff --git a/pkg/comment/formatter.go b/pkg/comment/formatter.go new file mode 100644 index 0000000..73b1049 --- /dev/null +++ b/pkg/comment/formatter.go @@ -0,0 +1,161 @@ +package comment + +import ( + "fmt" + "sort" + "strings" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// FormatMarkdown formats coverage results as a markdown comment suitable for posting to PRs. +func FormatMarkdown(results compute.Results, cfg *config.Config, includeColors bool) string { + var builder strings.Builder + + // Header + builder.WriteString("## 🚦 Coverage Report\n\n") + + // Overall status + hasFailures := results.ByTotal.Statements.Failed || results.ByTotal.Blocks.Failed + if hasFailures { + if includeColors { + builder.WriteString("❌ **Coverage check failed**\n\n") + } else { + builder.WriteString("❌ Coverage check failed\n\n") + } + } else { + if includeColors { + builder.WriteString("✅ **Coverage check passed**\n\n") + } else { + builder.WriteString("✅ Coverage check passed\n\n") + } + } + + // Total coverage summary + builder.WriteString("### 📊 Total Coverage\n\n") + builder.WriteString("| Metric | Coverage | Threshold | Status |\n") + builder.WriteString("|--------|----------|-----------|--------|\n") + + // Statements + stmtStatus := "✅ Pass" + if results.ByTotal.Statements.Failed { + stmtStatus = "❌ Fail" + } + builder.WriteString(fmt.Sprintf("| Statements | %s (%.1f%%) | %.1f%% | %s |\n", + results.ByTotal.Statements.Coverage, + results.ByTotal.Statements.Percentage, + results.ByTotal.Statements.Threshold, + stmtStatus)) + + // Blocks + blockStatus := "✅ Pass" + if results.ByTotal.Blocks.Failed { + blockStatus = "❌ Fail" + } + builder.WriteString(fmt.Sprintf("| Blocks | %s (%.1f%%) | %.1f%% | %s |\n", + results.ByTotal.Blocks.Coverage, + results.ByTotal.Blocks.Percentage, + results.ByTotal.Blocks.Threshold, + blockStatus)) + + // If there are failures, show details + if hasFailures { + builder.WriteString("\n### 📋 Coverage Details\n\n") + + // Show failing files if any + var failingFiles []compute.ByFile + for _, file := range results.ByFile { + if file.Failed { + failingFiles = append(failingFiles, file) + } + } + + if len(failingFiles) > 0 { + builder.WriteString("#### Files Below Threshold\n\n") + builder.WriteString("| File | Statements | Blocks | Status |\n") + builder.WriteString("|------|------------|--------|---------|\n") + + // Sort failing files by name for consistent output + sort.Slice(failingFiles, func(i, j int) bool { + return failingFiles[i].File < failingFiles[j].File + }) + + for _, file := range failingFiles { + stmtIcon := "❌" + blockIcon := "❌" + if file.StatementPercentage >= file.StatementThreshold { + stmtIcon = "✅" + } + if file.BlockPercentage >= file.BlockThreshold { + blockIcon = "✅" + } + + builder.WriteString(fmt.Sprintf("| `%s` | %s %.1f%% (need %.1f%%) | %s %.1f%% (need %.1f%%) | %s %s |\n", + file.File, + stmtIcon, file.StatementPercentage, file.StatementThreshold, + blockIcon, file.BlockPercentage, file.BlockThreshold, + stmtIcon, blockIcon)) + } + } + + // Show package failures if any + var failingPackages []compute.ByPackage + for _, pkg := range results.ByPackage { + if pkg.Failed { + failingPackages = append(failingPackages, pkg) + } + } + + if len(failingPackages) > 0 { + builder.WriteString("\n#### Packages Below Threshold\n\n") + builder.WriteString("| Package | Statements | Blocks | Status |\n") + builder.WriteString("|---------|------------|--------|---------|\n") + + // Sort failing packages by name for consistent output + sort.Slice(failingPackages, func(i, j int) bool { + return failingPackages[i].Package < failingPackages[j].Package + }) + + for _, pkg := range failingPackages { + stmtIcon := "❌" + blockIcon := "❌" + if pkg.StatementPercentage >= pkg.StatementThreshold { + stmtIcon = "✅" + } + if pkg.BlockPercentage >= pkg.BlockThreshold { + blockIcon = "✅" + } + + builder.WriteString(fmt.Sprintf("| `%s` | %s %.1f%% (need %.1f%%) | %s %.1f%% (need %.1f%%) | %s %s |\n", + pkg.Package, + stmtIcon, pkg.StatementPercentage, pkg.StatementThreshold, + blockIcon, pkg.BlockPercentage, pkg.BlockThreshold, + stmtIcon, blockIcon)) + } + } + + // Show improvement suggestions + builder.WriteString("\n### 💡 Required Improvements\n\n") + if results.ByTotal.Statements.Failed { + requiredStmt := results.ByTotal.Statements.Threshold - results.ByTotal.Statements.Percentage + builder.WriteString(fmt.Sprintf("- **Statements**: Need +%.1f%% to reach %.1f%% threshold\n", + requiredStmt, results.ByTotal.Statements.Threshold)) + } + if results.ByTotal.Blocks.Failed { + requiredBlock := results.ByTotal.Blocks.Threshold - results.ByTotal.Blocks.Percentage + builder.WriteString(fmt.Sprintf("- **Blocks**: Need +%.1f%% to reach %.1f%% threshold\n", + requiredBlock, results.ByTotal.Blocks.Threshold)) + } + } + + // Footer with tool info + builder.WriteString(fmt.Sprintf("\n---\n*Generated by [go-covercheck](%s)*\n", "https://github.com/mach6/go-covercheck")) + + return builder.String() +} + +// formatPercentage formats a percentage with appropriate precision. +func formatPercentage(percentage float64) string { + return fmt.Sprintf("%.1f%%", percentage) +} diff --git a/pkg/comment/gitea.go b/pkg/comment/gitea.go new file mode 100644 index 0000000..615a06f --- /dev/null +++ b/pkg/comment/gitea.go @@ -0,0 +1,117 @@ +package comment + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// GiteaPoster implements the Poster interface for Gitea using the official SDK. +type GiteaPoster struct { + client *gitea.Client + baseURL string +} + +// NewGiteaPoster creates a new Gitea poster instance using the official SDK. +func NewGiteaPoster(baseURL string) *GiteaPoster { + if baseURL == "" { + // There's no default public Gitea instance, so this should be provided + baseURL = "https://gitea.com" + } + + return &GiteaPoster{ + client: nil, // Will be initialized in PostComment with the token + baseURL: strings.TrimSuffix(baseURL, "/"), + } +} + +// PostComment posts coverage results as a comment to a Gitea pull request using the official SDK. +func (g *GiteaPoster) PostComment(ctx context.Context, results compute.Results, cfg *config.Config) error { + commentCfg := cfg.Comment + if commentCfg.Platform.Token == "" { + return fmt.Errorf("gitea token is required") + } + if commentCfg.Platform.Repository == "" { + return fmt.Errorf("repository is required (format: owner/repo)") + } + if commentCfg.Platform.PullRequestID <= 0 { + return fmt.Errorf("pull request ID is required") + } + + // Initialize the client with token if not already done + if g.client == nil { + client, err := gitea.NewClient(g.baseURL, gitea.SetToken(commentCfg.Platform.Token)) + if err != nil { + return fmt.Errorf("failed to create Gitea client: %w", err) + } + g.client = client + } + + // Parse repository owner and name + parts := strings.Split(commentCfg.Platform.Repository, "/") + if len(parts) != 2 { + return fmt.Errorf("repository must be in format 'owner/repo'") + } + owner, repo := parts[0], parts[1] + prID := int64(commentCfg.Platform.PullRequestID) + + // Format the comment content + body := FormatMarkdown(results, cfg, commentCfg.Platform.IncludeColors) + + // If updateExisting is enabled, try to find and update existing comment + if commentCfg.Platform.UpdateExisting { + if err := g.updateExistingComment(ctx, body, owner, repo, prID); err != nil { + // If update fails, fall back to creating a new comment + return g.createComment(ctx, body, owner, repo, prID) + } + return nil + } + + // Create a new comment + return g.createComment(ctx, body, owner, repo, prID) +} + +// createComment creates a new comment on the pull request using the Gitea SDK. +func (g *GiteaPoster) createComment(ctx context.Context, body, owner, repo string, prID int64) error { + options := gitea.CreatePullReviewOptions{ + State: gitea.ReviewStateComment, // Just a comment, not approval/request changes + Body: body, + } + + _, _, err := g.client.CreatePullReview(owner, repo, prID, options) + if err != nil { + return fmt.Errorf("failed to create pull request review comment: %w", err) + } + + return nil +} + +// updateExistingComment finds and updates an existing go-covercheck comment using the Gitea SDK. +func (g *GiteaPoster) updateExistingComment(ctx context.Context, body, owner, repo string, prID int64) error { + // Get existing reviews/comments using the SDK + reviews, _, err := g.client.ListPullReviews(owner, repo, prID, gitea.ListPullReviewsOptions{}) + if err != nil { + return fmt.Errorf("failed to list pull reviews: %w", err) + } + + // Find existing go-covercheck review + var existingReview *gitea.PullReview + for i := range reviews { + if strings.Contains(reviews[i].Body, "## 🚦 Coverage Report") && strings.Contains(reviews[i].Body, "go-covercheck") { + existingReview = reviews[i] + break + } + } + + if existingReview == nil { + return fmt.Errorf("no existing go-covercheck review found") + } + + // For Gitea, we need to submit a new review to update, as there's no direct update API + // This is a limitation of Gitea's API compared to GitHub/GitLab + return g.createComment(ctx, body, owner, repo, prID) +} diff --git a/pkg/comment/github.go b/pkg/comment/github.go new file mode 100644 index 0000000..f3f8616 --- /dev/null +++ b/pkg/comment/github.go @@ -0,0 +1,126 @@ +package comment + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v67/github" + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// GitHubPoster implements the Poster interface for GitHub. +type GitHubPoster struct { + client *github.Client + baseURL string +} + +// NewGitHubPoster creates a new GitHub poster instance. +func NewGitHubPoster(baseURL string) *GitHubPoster { + client := github.NewClient(nil) + + // Set custom base URL if provided + if baseURL != "" && baseURL != "https://api.github.com" { + baseURL = strings.TrimSuffix(baseURL, "/") + "/" + var err error + client, err = client.WithEnterpriseURLs(baseURL, baseURL) + if err != nil { + // Fallback to default if URL is invalid + client = github.NewClient(nil) + } + } + + return &GitHubPoster{ + client: client, + baseURL: baseURL, + } +} + +// PostComment posts coverage results as a comment to a GitHub pull request. +func (g *GitHubPoster) PostComment(ctx context.Context, results compute.Results, cfg *config.Config) error { + commentCfg := cfg.Comment + if commentCfg.Platform.Token == "" { + return fmt.Errorf("github token is required") + } + if commentCfg.Platform.Repository == "" { + return fmt.Errorf("repository is required (format: owner/repo)") + } + if commentCfg.Platform.PullRequestID <= 0 { + return fmt.Errorf("pull request ID is required") + } + + // Configure authentication + g.client = g.client.WithAuthToken(commentCfg.Platform.Token) + + // Parse repository owner and name + parts := strings.Split(commentCfg.Platform.Repository, "/") + if len(parts) != 2 { + return fmt.Errorf("repository must be in format owner/repo") + } + owner, repo := parts[0], parts[1] + + // Format the comment content + body := FormatMarkdown(results, cfg, commentCfg.Platform.IncludeColors) + + // If updateExisting is enabled, try to find and update existing comment + if commentCfg.Platform.UpdateExisting { + if err := g.updateExistingComment(ctx, body, owner, repo, commentCfg.Platform.PullRequestID); err != nil { + // If update fails, fall back to creating a new comment + return g.createComment(ctx, body, owner, repo, commentCfg.Platform.PullRequestID) + } + return nil + } + + // Create a new comment + return g.createComment(ctx, body, owner, repo, commentCfg.Platform.PullRequestID) +} + +// createComment creates a new comment on the pull request. +func (g *GitHubPoster) createComment(ctx context.Context, body, owner, repo string, prID int) error { + comment := &github.IssueComment{ + Body: &body, + } + + _, _, err := g.client.Issues.CreateComment(ctx, owner, repo, prID, comment) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + return nil +} + +// updateExistingComment finds and updates an existing go-covercheck comment. +func (g *GitHubPoster) updateExistingComment(ctx context.Context, body, owner, repo string, prID int) error { + // Get existing comments + comments, _, err := g.client.Issues.ListComments(ctx, owner, repo, prID, nil) + if err != nil { + return fmt.Errorf("failed to get comments: %w", err) + } + + // Find existing go-covercheck comment + var existingComment *github.IssueComment + for _, comment := range comments { + if comment.Body != nil && strings.Contains(*comment.Body, "## 🚦 Coverage Report") && + strings.Contains(*comment.Body, "go-covercheck") { + existingComment = comment + break + } + } + + if existingComment == nil { + return fmt.Errorf("no existing go-covercheck comment found") + } + + // Update the existing comment + updatedComment := &github.IssueComment{ + Body: &body, + } + + _, _, err = g.client.Issues.EditComment(ctx, owner, repo, *existingComment.ID, updatedComment) + if err != nil { + return fmt.Errorf("failed to update comment: %w", err) + } + + return nil +} diff --git a/pkg/comment/gitlab.go b/pkg/comment/gitlab.go new file mode 100644 index 0000000..6cbb6c7 --- /dev/null +++ b/pkg/comment/gitlab.go @@ -0,0 +1,134 @@ +package comment + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +// GitLabPoster implements the Poster interface for GitLab. +type GitLabPoster struct { + client *gitlab.Client + baseURL string +} + +// NewGitLabPoster creates a new GitLab poster instance. +func NewGitLabPoster(baseURL string) *GitLabPoster { + if baseURL == "" { + baseURL = "https://gitlab.com" + } + + // The client will be configured with auth token when PostComment is called + client, _ := gitlab.NewClient("", gitlab.WithBaseURL(baseURL)) + + return &GitLabPoster{ + client: client, + baseURL: baseURL, + } +} + +// PostComment posts coverage results as a note to a GitLab merge request. +func (g *GitLabPoster) PostComment(ctx context.Context, results compute.Results, cfg *config.Config) error { + commentCfg := cfg.Comment + if commentCfg.Platform.Token == "" { + return fmt.Errorf("gitlab token is required") + } + if commentCfg.Platform.Repository == "" { + return fmt.Errorf("repository is required (format: group/project or project-id)") + } + if commentCfg.Platform.PullRequestID <= 0 { + return fmt.Errorf("merge request ID is required") + } + + // Configure authentication + var err error + g.client, err = gitlab.NewClient(commentCfg.Platform.Token, gitlab.WithBaseURL(g.baseURL)) + if err != nil { + return fmt.Errorf("failed to create GitLab client: %w", err) + } + + // Format the comment content + body := FormatMarkdown(results, cfg, commentCfg.Platform.IncludeColors) + + // If updateExisting is enabled, try to find and update existing comment + if commentCfg.Platform.UpdateExisting { + if err := g.updateExistingNote(ctx, body, commentCfg.Platform.Repository, commentCfg.Platform.PullRequestID); err != nil { + // If update fails, fall back to creating a new note + return g.createNote(ctx, body, commentCfg.Platform.Repository, commentCfg.Platform.PullRequestID) + } + return nil + } + + // Create a new note + return g.createNote(ctx, body, commentCfg.Platform.Repository, commentCfg.Platform.PullRequestID) +} + +// createNote creates a new note on the merge request. +func (g *GitLabPoster) createNote(ctx context.Context, body, repository string, mrID int) error { + // Parse project ID - could be numeric ID or group/project format + var projectID interface{} + if pid, err := strconv.Atoi(repository); err == nil { + projectID = pid + } else { + projectID = repository + } + + options := &gitlab.CreateMergeRequestNoteOptions{ + Body: &body, + } + + _, _, err := g.client.Notes.CreateMergeRequestNote(projectID, mrID, options, gitlab.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to create note: %w", err) + } + + return nil +} + +// updateExistingNote finds and updates an existing go-covercheck note. +func (g *GitLabPoster) updateExistingNote(ctx context.Context, body, repository string, mrID int) error { + // Parse project ID - could be numeric ID or group/project format + var projectID interface{} + if pid, err := strconv.Atoi(repository); err == nil { + projectID = pid + } else { + projectID = repository + } + + // Get existing notes + notes, _, err := g.client.Notes.ListMergeRequestNotes(projectID, mrID, nil, gitlab.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to get notes: %w", err) + } + + // Find existing go-covercheck note + var existingNote *gitlab.Note + for _, note := range notes { + if strings.Contains(note.Body, "## 🚦 Coverage Report") && + strings.Contains(note.Body, "go-covercheck") { + existingNote = note + break + } + } + + if existingNote == nil { + return fmt.Errorf("no existing go-covercheck note found") + } + + // Update the existing note + options := &gitlab.UpdateMergeRequestNoteOptions{ + Body: &body, + } + + _, _, err = g.client.Notes.UpdateMergeRequestNote(projectID, mrID, existingNote.ID, options, gitlab.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to update note: %w", err) + } + + return nil +} diff --git a/pkg/comment/gogs.go b/pkg/comment/gogs.go new file mode 100644 index 0000000..bd5b025 --- /dev/null +++ b/pkg/comment/gogs.go @@ -0,0 +1,119 @@ +package comment + +import ( + "context" + "fmt" + "strings" + + gogs "github.com/gogits/go-gogs-client" + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// GogsPoster implements the Poster interface for Gogs using the official SDK. +type GogsPoster struct { + client *gogs.Client + baseURL string +} + +// NewGogsPoster creates a new Gogs poster instance using the official SDK. +func NewGogsPoster(baseURL string) *GogsPoster { + if baseURL == "" { + // There's no default public Gogs instance, so this should be provided + baseURL = "https://try.gogs.io" + } + + return &GogsPoster{ + client: nil, // Will be initialized in PostComment with the token + baseURL: strings.TrimSuffix(baseURL, "/"), + } +} + +// PostComment posts coverage results as a comment to a Gogs pull request using the official SDK. +func (g *GogsPoster) PostComment(ctx context.Context, results compute.Results, cfg *config.Config) error { + commentCfg := cfg.Comment + if commentCfg.Platform.Token == "" { + return fmt.Errorf("gogs token is required") + } + if commentCfg.Platform.Repository == "" { + return fmt.Errorf("repository is required (format: owner/repo)") + } + if commentCfg.Platform.PullRequestID <= 0 { + return fmt.Errorf("pull request ID is required") + } + + // Initialize the client with token if not already done + if g.client == nil { + client := gogs.NewClient(g.baseURL, commentCfg.Platform.Token) + g.client = client + } + + // Parse repository owner and name + repoParts := strings.Split(commentCfg.Platform.Repository, "/") + if len(repoParts) != 2 { + return fmt.Errorf("invalid repository format: %s (expected: owner/repo)", commentCfg.Platform.Repository) + } + owner, repo := repoParts[0], repoParts[1] + + // Generate the comment content + markdown := FormatMarkdown(results, cfg, commentCfg.Platform.IncludeColors) + + // Check if we should update an existing comment + if commentCfg.Platform.UpdateExisting { + if err := g.updateExistingComment(ctx, owner, repo, commentCfg.Platform.PullRequestID, markdown); err != nil { + // If updating fails, fall back to creating a new comment + return g.createNewComment(ctx, owner, repo, commentCfg.Platform.PullRequestID, markdown) + } + return nil + } + + // Create a new comment + return g.createNewComment(ctx, owner, repo, commentCfg.Platform.PullRequestID, markdown) +} + +// createNewComment creates a new comment on the pull request. +func (g *GogsPoster) createNewComment(ctx context.Context, owner, repo string, prID int, content string) error { + issueCommentOption := gogs.CreateIssueCommentOption{ + Body: content, + } + + // Convert PR ID to int64 as expected by the Gogs client + prIDInt64 := int64(prID) + + _, err := g.client.CreateIssueComment(owner, repo, prIDInt64, issueCommentOption) + if err != nil { + return fmt.Errorf("failed to create Gogs comment: %w", err) + } + + return nil +} + +// updateExistingComment attempts to update an existing comment from this tool. +func (g *GogsPoster) updateExistingComment(ctx context.Context, owner, repo string, prID int, content string) error { + // Convert PR ID to int64 as expected by the Gogs client + prIDInt64 := int64(prID) + + // Get existing comments to find one to update + comments, err := g.client.ListIssueComments(owner, repo, prIDInt64) + if err != nil { + return fmt.Errorf("failed to list Gogs comments: %w", err) + } + + // Look for an existing comment from go-covercheck (contains the signature) + for _, comment := range comments { + if strings.Contains(comment.Body, "## 🚦 Coverage Report") && strings.Contains(comment.Body, "go-covercheck") { + // Update this comment + editOption := gogs.EditIssueCommentOption{ + Body: content, + } + _, err := g.client.EditIssueComment(owner, repo, prIDInt64, comment.ID, editOption) + if err != nil { + return fmt.Errorf("failed to update Gogs comment: %w", err) + } + return nil + } + } + + // No existing comment found to update + return fmt.Errorf("no existing go-covercheck comment found to update") +} \ No newline at end of file diff --git a/pkg/comment/interface.go b/pkg/comment/interface.go new file mode 100644 index 0000000..1d1a587 --- /dev/null +++ b/pkg/comment/interface.go @@ -0,0 +1,18 @@ +// Package comment provides interfaces and implementations for posting coverage results to various platforms. +package comment + +import ( + "context" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// Poster defines the interface for posting coverage results as comments to different platforms. +type Poster interface { + // PostComment posts a coverage result as a comment to a pull request or merge request. + // ctx provides cancellation and timeout control. + // results contains the coverage analysis results to post. + // cfg contains the application configuration including comment settings. + PostComment(ctx context.Context, results compute.Results, cfg *config.Config) error +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b5e5c39..272994f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -64,6 +64,39 @@ type PerThresholdOverride struct { Blocks PerOverride `yaml:"blocks"` } +// CommentConfig holds the configuration for comment posting feature. +type CommentConfig struct { + // Enabled determines whether comment posting is enabled + Enabled bool `yaml:"enabled,omitempty"` + + // Platform contains the platform-specific configuration + Platform PlatformConfig `yaml:"platform,omitempty"` +} + +// PlatformConfig holds platform-specific configuration for posting comments. +type PlatformConfig struct { + // Type specifies the platform type (github, gitlab, gitea, etc.) + Type string `yaml:"type"` + + // BaseURL is the base URL for the platform API (for self-hosted instances) + BaseURL string `yaml:"baseUrl,omitempty"` + + // Token is the authentication token for API access + Token string `yaml:"token,omitempty"` + + // Repository is the repository identifier (e.g., "owner/repo") + Repository string `yaml:"repository,omitempty"` + + // PullRequestID is the pull request or merge request ID + PullRequestID int `yaml:"pullRequestId,omitempty"` + + // IncludeColors determines whether to include color codes in comments (where supported) + IncludeColors bool `yaml:"includeColors,omitempty"` + + // UpdateExisting determines whether to update existing comments instead of creating new ones + UpdateExisting bool `yaml:"updateExisting,omitempty"` +} + // Config for application. type Config struct { StatementThreshold float64 `yaml:"statementThreshold,omitempty"` @@ -80,6 +113,7 @@ type Config struct { Format string `yaml:"format,omitempty"` TerminalWidth int `yaml:"terminalWidth,omitempty"` ModuleName string `yaml:"moduleName,omitempty"` + Comment CommentConfig `yaml:"comment,omitempty"` } // Load a Config from a path or produce an error. diff --git a/samples/.go-covercheck.yml b/samples/.go-covercheck.yml index 8c3d8c7..ee80501 100644 --- a/samples/.go-covercheck.yml +++ b/samples/.go-covercheck.yml @@ -79,3 +79,40 @@ total: # default [] skip: # - cmd/root.go + +# comment posting configuration for PR/MR comments +# default {"enabled": false} +comment: + # enable comment posting to PR/MR + # default false + enabled: false + + platform: + # platform type: github|gitlab|gitea|gogs + # required when comment posting is enabled + type: github + + # base URL for self-hosted instances + # defaults: github.com, gitlab.com, or gitea.com + # baseUrl: https://github.enterprise.com + + # authentication token for API access + # required when comment posting is enabled + # can also be set via --comment-token flag or environment variable + # token: ghp_xxxxxxxxxxxxxxxxxxxx + + # repository identifier (owner/repo format) + # required when comment posting is enabled + # repository: owner/repo-name + + # pull request or merge request ID + # required when comment posting is enabled + # pullRequestId: 123 + + # include color formatting in comments (emojis and markdown) + # default true + includeColors: true + + # update existing go-covercheck comment instead of creating new one + # default false + updateExisting: false