avelino-awesome-go/.github/scripts/check-quality/main.go
Avelino f74f180683
Migrate PR quality checks from Node.js to Go (#6055)
* automate PR review: rewrite quality checks in Go and add diff validation

- Replace check-quality.js with Go implementation that adds:
  open source license detection, repo maturity (5+ months),
  CI/CD presence check, and README existence verification
- Add new check-pr-diff tool that validates README changes:
  single item per PR, link consistency with PR body,
  description format, non-promotional language, and
  category minimum size (3 items)
- Update pr-quality-check workflow to run both Go tools
  with separate quality and diff reporting sections
- Improve CONTRIBUTING.md with new "What is checked automatically"
  section documenting all blocking and non-blocking CI checks,
  plus what still requires manual maintainer review
- Improve PR template with clearer structure separating
  CI-validated requirements from manual review items

https://claude.ai/code/session_01JcdbDdtERhAXFFZmu3pCVY

* add actionable fix instructions to automated PR review comments

Each failed check now includes a "How to fix" section with specific
commands and steps the contributor needs to take, e.g.:
- missing go.mod → shows exact `go mod init` + `go mod tidy` commands
- missing semver release → shows `git tag v1.0.0` command and GitHub UI link
- low Go Report Card grade → shows `gofmt`, `go vet` commands
- missing PR body links → shows the exact text to paste
- description without period → shows the corrected entry line
- link mismatch → shows both URLs and which to update

https://claude.ai/code/session_01JcdbDdtERhAXFFZmu3pCVY

* add auto-merge when all PR quality checks pass

Adds an auto-merge job that enables GitHub's native auto-merge (squash)
after the quality and diff checks succeed. GitHub will only execute the
merge once ALL required branch protection status checks are green
(including the tests workflow).

Requires "Allow auto-merge" to be enabled in repo settings.

https://claude.ai/code/session_01JcdbDdtERhAXFFZmu3pCVY

* add workflow to re-run quality checks on all open PRs

Creates a manual workflow_dispatch workflow that iterates through all
open PRs and closes/reopens each one to re-trigger the quality check
pipeline. Includes a 2s delay between PRs to respect API rate limits.

Usage: Actions tab → "Re-check all open PRs" → Run workflow

https://claude.ai/code/session_01JcdbDdtERhAXFFZmu3pCVY

* skip quality checks for PRs that don't modify README.md

The quality checks (forge link, pkg.go.dev, Go Report Card, etc.) only
apply to package addition/removal PRs. Internal PRs (workflow changes,
docs, scripts) were failing because they naturally don't have those
fields in the PR body.

Now the workflow first detects if README.md is in the changed files:
- If yes: runs full quality + diff checks as before
- If no: posts a "Skipped" notice and passes cleanly

Fixes false failures like PR #6055.

https://claude.ai/code/session_01JcdbDdtERhAXFFZmu3pCVY

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-27 07:05:00 -03:00

504 lines
16 KiB
Go

// check-quality validates PR body links and repository quality against
// CONTRIBUTING.md minimum standards. It outputs a markdown report and
// labels via GITHUB_OUTPUT for the PR Quality Checks workflow.
//
// Checks performed:
// - Repo accessible, not archived, has go.mod and SemVer release
// - Open source license present
// - Repository age >= 5 months
// - CI/CD (GitHub Actions) configured
// - README exists
// - Go Report Card grade A- or better
// - pkg.go.dev page reachable
// - Coverage link reachable
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
)
var (
reForgeLink = regexp.MustCompile(`(?i)forge\s+link[^:]*:\s*(https?://(?:github\.com|gitlab\.com|bitbucket\.org)/\S+)`)
rePkgGoDev = regexp.MustCompile(`(?i)pkg\.go\.dev:\s*(https?://pkg\.go\.dev/\S+)`)
reGoReport = regexp.MustCompile(`(?i)goreportcard\.com:\s*(https?://goreportcard\.com/\S+)`)
reCoverage = regexp.MustCompile(`(?i)coverage[^:]*:\s*(https?://(?:coveralls\.io|(?:app\.)?codecov\.io)/\S+)`)
reGrade = regexp.MustCompile(`(?i)Grade:\s*([A-F][+-]?)`)
reGithubRepo = regexp.MustCompile(`^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$`)
reSemver = regexp.MustCompile(`^v\d+\.\d+\.\d+`)
)
type prEvent struct {
PullRequest struct {
Body string `json:"body"`
} `json:"pull_request"`
}
type repoData struct {
Archived bool `json:"archived"`
License *struct {
SPDXID string `json:"spdx_id"`
Name string `json:"name"`
} `json:"license"`
Message string `json:"message"` // present on API errors
}
type contentEntry struct {
Name string `json:"name"`
}
type tagEntry struct {
Name string `json:"name"`
}
type workflowsResponse struct {
TotalCount int `json:"total_count"`
}
func main() {
event := readEvent()
body := event.PullRequest.Body
forgeLink := captureMatch(body, reForgeLink)
pkgLink := captureMatch(body, rePkgGoDev)
gorepLink := captureMatch(body, reGoReport)
covLink := captureMatch(body, reCoverage)
var (
critical []string
warnings []string
criticalFail bool
repoOk bool
pkgOk bool
gorepOk bool
coverageOk bool
labels []string
)
// --- Repo checks ---
if forgeLink == "" {
critical = append(critical, icon(false)+" **Repo link**: missing from PR body")
critical = append(critical, fix("Add the following to your PR description:", "```\nForge link: https://github.com/your-org/your-project\n```"))
criticalFail = true
} else {
r := checkGithubRepo(forgeLink)
if !r.ok {
critical = append(critical, fmt.Sprintf("%s **Repo**: %s", icon(false), r.reason))
critical = append(critical, repoFixMessage(r.reason, forgeLink))
criticalFail = true
} else {
critical = append(critical, icon(true)+" **Repo**: accessible, has go.mod and SemVer release")
repoOk = true
}
if r.licenseChecked {
if r.hasLicense {
warnings = append(warnings, fmt.Sprintf("%s **License**: %s", warnIcon(true), r.licenseName))
} else {
warnings = append(warnings, warnIcon(false)+" **License**: no open source license detected")
warnings = append(warnings, fix("Add a LICENSE file to your repository root.", "Choose one at https://choosealicense.com — common choices for Go projects: MIT, Apache-2.0, BSD-3-Clause."))
labels = append(labels, "needs-license")
}
}
if r.maturityChecked {
if r.hasFiveMonths {
warnings = append(warnings, warnIcon(true)+" **Maturity**: repo has 5+ months of history")
} else {
warnings = append(warnings, warnIcon(false)+" **Maturity**: repo appears to have less than 5 months of history")
warnings = append(warnings, fix("Your repository needs at least 5 months of history since the first commit.", "Please resubmit after the repository meets this requirement."))
labels = append(labels, "needs-maturity")
}
}
if r.ciChecked {
if r.hasCI {
warnings = append(warnings, warnIcon(true)+" **CI/CD**: GitHub Actions workflows detected")
} else {
warnings = append(warnings, warnIcon(false)+" **CI/CD**: no GitHub Actions workflows found")
warnings = append(warnings, fix("Add a CI workflow to run tests automatically.", "Create `.github/workflows/test.yml` — see https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-go"))
}
}
if r.readmeChecked {
if r.hasReadme {
warnings = append(warnings, warnIcon(true)+" **README**: present")
} else {
warnings = append(warnings, warnIcon(false)+" **README**: not found in repository root")
warnings = append(warnings, fix("Add a `README.md` to your repository root.", "It should explain what the project does, how to install it, and how to use it, in English."))
}
}
}
// --- pkg.go.dev ---
if pkgLink == "" {
critical = append(critical, icon(false)+" **pkg.go.dev**: missing from PR body")
critical = append(critical, fix("Add the following to your PR description:", "```\npkg.go.dev: https://pkg.go.dev/github.com/your-org/your-project\n```"))
criticalFail = true
} else if !isReachable(pkgLink) {
critical = append(critical, icon(false)+" **pkg.go.dev**: unreachable")
critical = append(critical, fix("The pkg.go.dev page could not be reached.", "Ensure your module path in `go.mod` matches the URL. After pushing a tagged release, pkg.go.dev indexes the module automatically — this can take a few minutes. You can trigger it manually by visiting `https://pkg.go.dev/your-module-path`."))
criticalFail = true
} else {
critical = append(critical, icon(true)+" **pkg.go.dev**: OK")
pkgOk = true
}
// --- Go Report Card ---
if gorepLink == "" {
critical = append(critical, icon(false)+" **Go Report Card**: missing from PR body")
if forgeLink != "" {
critical = append(critical, fix("Add the following to your PR description:", fmt.Sprintf("```\ngoreportcard.com: https://goreportcard.com/report/%s\n```", strings.TrimPrefix(strings.TrimPrefix(forgeLink, "https://"), "http://"))))
} else {
critical = append(critical, fix("Add the following to your PR description:", "```\ngoreportcard.com: https://goreportcard.com/report/github.com/your-org/your-project\n```"))
}
criticalFail = true
} else {
grade, ok := checkGoReportCard(gorepLink)
if !ok {
critical = append(critical, fmt.Sprintf("%s **Go Report Card**: %s", icon(false), grade))
if grade == "unreachable" || grade == "fetch error" {
critical = append(critical, fix("The Go Report Card page could not be reached.", "Visit https://goreportcard.com and generate a report for your project. Then add the correct link to your PR body."))
} else {
critical = append(critical, fix(fmt.Sprintf("Your project received grade **%s** — minimum required is **A-**.", grade), "Run `gofmt -s -w .` to fix formatting, `go vet ./...` to fix vet issues, and review the report at "+gorepLink+" for specific problems to address."))
}
criticalFail = true
} else {
msg := icon(true) + " **Go Report Card**: OK"
if grade != "" {
msg += fmt.Sprintf(" (grade %s)", grade)
}
critical = append(critical, msg)
gorepOk = true
}
}
// --- Coverage ---
if covLink == "" {
warnings = append(warnings, warnIcon(false)+" **Coverage**: missing from PR body")
warnings = append(warnings, fix("Add a coverage service link to your PR description:", "```\nCoverage: https://app.codecov.io/gh/your-org/your-project\n```\nPopular options: [Codecov](https://codecov.io), [Coveralls](https://coveralls.io). Integrate one with your CI to track coverage automatically."))
} else if !isReachable(covLink) {
warnings = append(warnings, warnIcon(false)+" **Coverage**: unreachable")
warnings = append(warnings, fix("The coverage link could not be reached.", "Ensure the coverage service is configured for your repository and the link is correct. If you just set it up, it may need a CI run to generate the first report."))
} else {
warnings = append(warnings, warnIcon(true)+" **Coverage**: link accessible")
coverageOk = true
}
// --- Build comment ---
var lines []string
lines = append(lines, "## Automated Quality Checks", "")
lines = append(lines, "### Required checks", "")
lines = append(lines, critical...)
lines = append(lines, "", "### Additional checks", "")
lines = append(lines, warnings...)
lines = append(lines, "")
if criticalFail {
lines = append(lines, "---")
lines = append(lines, "> **Action needed:** one or more required checks failed. Please update your PR body with the missing links and ensure the repository meets the [quality standards](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md#quality-standards).")
}
lines = append(lines, "")
lines = append(lines, "_These checks are automated and do not replace maintainer review. See [CONTRIBUTING.md](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md) for full guidelines._")
comment := strings.Join(lines, "\n")
// --- Labels ---
if forgeLink == "" || pkgLink == "" || gorepLink == "" {
labels = append(labels, "needs-info")
}
if !coverageOk {
labels = append(labels, "needs-coverage")
}
if criticalFail {
labels = append(labels, "quality:fail")
}
if !criticalFail && repoOk && pkgOk && gorepOk {
labels = append(labels, "quality:ok")
}
labelsJSON, _ := json.Marshal(labels)
setOutput("comment", comment)
setOutput("fail", boolStr(criticalFail))
setOutput("labels", string(labelsJSON))
}
// --- Event reading ---
func readEvent() prEvent {
var ev prEvent
path := os.Getenv("GITHUB_EVENT_PATH")
if path == "" {
return ev
}
data, err := os.ReadFile(path)
if err != nil {
return ev
}
_ = json.Unmarshal(data, &ev)
return ev
}
// --- Regex helpers ---
func captureMatch(s string, re *regexp.Regexp) string {
m := re.FindStringSubmatch(s)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
// --- HTTP helpers ---
var httpClient = &http.Client{Timeout: 30 * time.Second}
func isReachable(url string) bool {
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return false
}
resp, err := httpClient.Do(req)
if err != nil {
return false
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
return true
}
// Fallback to GET
req2, _ := http.NewRequest(http.MethodGet, url, nil)
resp2, err := httpClient.Do(req2)
if err != nil {
return false
}
resp2.Body.Close()
return resp2.StatusCode >= 200 && resp2.StatusCode < 400
}
func githubGet(url string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("User-Agent", "awesome-go-quality-check")
req.Header.Set("Accept", "application/vnd.github+json")
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return body, resp.StatusCode, err
}
// --- GitHub repo checks ---
type repoCheckResult struct {
ok bool
reason string
hasLicense bool
licenseName string
licenseChecked bool
hasFiveMonths bool
maturityChecked bool
hasCI bool
ciChecked bool
hasReadme bool
readmeChecked bool
}
func checkGithubRepo(repoURL string) repoCheckResult {
m := reGithubRepo.FindStringSubmatch(repoURL)
if m == nil {
return repoCheckResult{reason: "invalid repo url"}
}
owner, repo := m[1], m[2]
base := "https://api.github.com"
// Fetch repo metadata
data, status, err := githubGet(fmt.Sprintf("%s/repos/%s/%s", base, owner, repo))
if err != nil || status >= 400 {
return repoCheckResult{reason: "repo api not reachable"}
}
var rd repoData
if json.Unmarshal(data, &rd) != nil {
return repoCheckResult{reason: "repo api not reachable"}
}
if rd.Message != "" {
return repoCheckResult{reason: "repo api not reachable"}
}
if rd.Archived {
return repoCheckResult{reason: "repo is archived"}
}
result := repoCheckResult{}
// License
result.licenseChecked = true
if rd.License != nil && rd.License.SPDXID != "" && rd.License.SPDXID != "NOASSERTION" {
result.hasLicense = true
result.licenseName = rd.License.SPDXID
}
// go.mod
hasGoMod := false
goModData, goModStatus, _ := githubGet(fmt.Sprintf("%s/repos/%s/%s/contents/go.mod", base, owner, repo))
if goModStatus == 200 {
var entry contentEntry
if json.Unmarshal(goModData, &entry) == nil && entry.Name == "go.mod" {
hasGoMod = true
}
}
// SemVer tags
hasSemver := false
tagsData, tagsStatus, _ := githubGet(fmt.Sprintf("%s/repos/%s/%s/tags?per_page=100", base, owner, repo))
if tagsStatus == 200 {
var tags []tagEntry
if json.Unmarshal(tagsData, &tags) == nil {
for _, t := range tags {
if reSemver.MatchString(t.Name) {
hasSemver = true
break
}
}
}
}
// Maturity (5+ months)
result.maturityChecked = true
fiveMonthsAgo := time.Now().AddDate(0, -5, 0).Format(time.RFC3339)
commitsData, commitsStatus, _ := githubGet(fmt.Sprintf("%s/repos/%s/%s/commits?per_page=1&until=%s", base, owner, repo, fiveMonthsAgo))
if commitsStatus == 200 {
var commits []json.RawMessage
if json.Unmarshal(commitsData, &commits) == nil && len(commits) > 0 {
result.hasFiveMonths = true
}
}
// CI/CD (GitHub Actions)
result.ciChecked = true
wfData, wfStatus, _ := githubGet(fmt.Sprintf("%s/repos/%s/%s/actions/workflows", base, owner, repo))
if wfStatus == 200 {
var wf workflowsResponse
if json.Unmarshal(wfData, &wf) == nil && wf.TotalCount > 0 {
result.hasCI = true
}
}
// README
result.readmeChecked = true
readmeData, readmeStatus, _ := githubGet(fmt.Sprintf("%s/repos/%s/%s/readme", base, owner, repo))
if readmeStatus == 200 {
var entry contentEntry
if json.Unmarshal(readmeData, &entry) == nil && entry.Name != "" {
result.hasReadme = true
}
}
result.ok = hasGoMod && hasSemver
if !hasGoMod {
result.reason = "missing go.mod"
} else if !hasSemver {
result.reason = "missing semver release"
}
return result
}
// --- Go Report Card ---
func checkGoReportCard(url string) (grade string, ok bool) {
if !isReachable(url) {
return "unreachable", false
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "fetch error", false
}
resp, err := httpClient.Do(req)
if err != nil {
return "fetch error", false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "fetch error", false
}
m := reGrade.FindSubmatch(body)
if m == nil {
return "unknown", true // reachable but no grade found
}
g := strings.ToUpper(string(m[1]))
pass := g == "A" || g == "A+" || g == "A-"
return g, pass
}
// --- Output helpers ---
func setOutput(name, value string) {
path := os.Getenv("GITHUB_OUTPUT")
if path == "" {
fmt.Printf("%s=%s\n", name, value)
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s<<EOF\n%s\nEOF\n", name, value)
}
func icon(ok bool) string {
if ok {
return "\u2705"
}
return "\u274C"
}
func warnIcon(ok bool) string {
if ok {
return "\u2705"
}
return "\u26A0\uFE0F"
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func fix(problem, howToFix string) string {
return fmt.Sprintf(" > **How to fix:** %s\n > %s", problem, howToFix)
}
func repoFixMessage(reason, repoURL string) string {
switch reason {
case "invalid repo url":
return fix("The forge link is not a valid repository URL.", "Use the full URL, e.g. `https://github.com/org/project`.")
case "repo api not reachable":
return fix("Could not reach the repository via GitHub API.", "Ensure the repository is **public** and the URL is correct.")
case "repo is archived":
return fix("This repository is archived on GitHub.", "Archived repositories are not accepted. The project must be actively maintained or at least open to contributions.")
case "missing go.mod":
return fix("No `go.mod` file found at the repository root.", "Initialize Go modules in your project:\n > ```\n > go mod init github.com/your-org/your-project\n > go mod tidy\n > git add go.mod go.sum && git commit -m \"add go module\" && git push\n > ```")
case "missing semver release":
return fix("No SemVer release tag (e.g. `v1.0.0`) found.", "Create a tagged release:\n > ```\n > git tag v1.0.0\n > git push origin v1.0.0\n > ```\n > Or create a release via GitHub's UI at `"+repoURL+"/releases/new`.")
default:
return fix(reason, "Review the [quality standards](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md#quality-standards).")
}
}