diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 860db5d2..5404a0c0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,36 +1,47 @@ -## We want to ensure high quality of the packages. Make sure that you've checked the boxes below before sending a pull request. +## Required links + +_Provide the links below. Our CI will automatically validate them._ + +- [ ] Forge link (github.com, gitlab.com, etc): +- [ ] pkg.go.dev: +- [ ] goreportcard.com: +- [ ] Coverage service link ([codecov](https://codecov.io/), [coveralls](https://coveralls.io/), etc.): + +## Pre-submission checklist - [ ] I have read the [Contribution Guidelines](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md#contribution-guidelines) -- [ ] I have read the [Maintainers Note](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md#maintainers) - [ ] I have read the [Quality Standards](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md#quality-standards) -_Not every repository (project) will require every option, but most projects should. Check the Contribution Guidelines for details._ +## Repository requirements +_These are validated automatically by CI:_ + +- [ ] The repo has a `go.mod` file and at least one SemVer release (`vX.Y.Z`). +- [ ] The repo has an open source license. - [ ] The repo documentation has a pkg.go.dev link. +- [ ] The repo documentation has a goreportcard link (grade A- or better). - [ ] The repo documentation has a coverage service link. -- [ ] The repo documentation has a goreportcard link. -- [ ] The repo has a version-numbered release and a go.mod file. -- [ ] The repo has a continuous integration process that automatically runs tests that must pass before new pull requests are merged. -- [ ] Continuous integration is used to attempt to catch issues prior to releasing this package to end-users. -## Please provide some links to your package to ease the review +_These are recommended and reported as warnings:_ -- [ ] forge link (github.com, gitlab.com, etc): -- [ ] pkg.go.dev: -- [ ] goreportcard.com: -- [ ] coverage service link ([codecov](https://codecov.io/), [coveralls](https://coveralls.io/), etc.): +- [ ] The repo has a continuous integration process (GitHub Actions, etc.). +- [ ] CI runs tests that must pass before merging. ## Pull Request content -- [ ] The package has been added to the list in alphabetical order. -- [ ] The package has an appropriate description with correct grammar. -- [ ] As far as I know, the package has not been listed here before. +_These are validated automatically by CI:_ + +- [ ] This PR adds/removes/changes **only one** package. +- [ ] The package has been added in **alphabetical order**. +- [ ] The link text is the **exact project name**. +- [ ] The description is clear, concise, non-promotional, and **ends with a period**. +- [ ] The link in README.md matches the forge link above. ## Category quality -_Note that new categories can be added only when there are 3 packages or more._ +_Note: new categories require a minimum of 3 packages._ -Packages added a long time ago might not meet the current guidelines anymore. It would be very helpful if you could check 3-5 packages above and below your submission to ensure that they also still meet the Quality Standards. +Packages added a long time ago might not meet the current guidelines anymore. It would be very helpful if you could check 3-5 packages above and below your submission to ensure they still meet the Quality Standards. Please delete one of the following lines: diff --git a/.github/scripts/check-pr-diff/main.go b/.github/scripts/check-pr-diff/main.go new file mode 100644 index 00000000..0479e14b --- /dev/null +++ b/.github/scripts/check-pr-diff/main.go @@ -0,0 +1,394 @@ +// check-pr-diff validates the actual changes in a PR against CONTRIBUTING.md rules: +// - PR modifies only README.md (for package additions) +// - PR adds or removes exactly one item +// - Added link matches the forge link declared in PR body +// - Link text matches the repository/project name +// - Description ends with a period and is non-promotional +// - Category has minimum 3 items after the change +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "strings" +) + +var ( + reForgeLink = regexp.MustCompile(`(?i)forge\s+link[^:]*:\s*(https?://(?:github\.com|gitlab\.com|bitbucket\.org)/\S+)`) + reEntry = regexp.MustCompile(`^- \[([^\]]+)\]\(([^)]+)\)\s+-\s+(.+)$`) + reHeading = regexp.MustCompile(`^#{2,3}\s+(.+)`) +) + +// Words that indicate promotional language in descriptions. +var promotionalWords = []string{ + "best", "fastest", "ultimate", "world-class", "blazing", + "revolutionary", "cutting-edge", "enterprise-grade", "next-generation", + "state-of-the-art", "unparalleled", "unmatched", "superior", + "number one", "#1", "award-winning", "top-rated", +} + +type prEvent struct { + PullRequest struct { + Body string `json:"body"` + } `json:"pull_request"` +} + +type entry struct { + name string + url string + description string + raw string +} + +func main() { + event := readEvent() + body := event.PullRequest.Body + forgeLink := captureMatch(body, reForgeLink) + + var ( + results []string + warnings []string + hasFail bool + ) + + // 1. Check which files were changed + changedFiles := getChangedFiles() + includesReadme := false + for _, f := range changedFiles { + if f == "README.md" { + includesReadme = true + break + } + } + + if !includesReadme { + results = append(results, icon(false)+" **README change**: no changes to README.md detected") + results = append(results, fix("Your PR should add or remove an entry in README.md.", "Edit README.md following the format: `- [project-name](url) - Short description.`")) + hasFail = true + outputResults(results, warnings, hasFail) + return + } + + readmeOnly := len(changedFiles) == 1 && changedFiles[0] == "README.md" + if readmeOnly { + results = append(results, icon(true)+" **Files changed**: only README.md") + } else { + var others []string + for _, f := range changedFiles { + if f != "README.md" { + others = append(others, f) + } + } + warnings = append(warnings, fmt.Sprintf("%s **Extra files changed**: %s (expected only README.md for package additions)", warnIcon(false), strings.Join(others, ", "))) + warnings = append(warnings, fix("Package addition PRs should only modify README.md.", "If you need other changes, please open a separate PR.")) + } + + // 2. Parse the diff + diff := getDiff() + if diff == "" { + results = append(results, icon(false)+" **Diff**: could not read diff for README.md") + hasFail = true + outputResults(results, warnings, hasFail) + return + } + + added, removed := parseDiffEntries(diff) + totalChanges := len(added) + len(removed) + + // 3. Single item check + switch { + case len(added) == 1 && len(removed) == 0: + results = append(results, icon(true)+" **Single item**: one package added") + case len(removed) == 1 && len(added) == 0: + results = append(results, icon(true)+" **Single item**: one package removed") + case len(added) == 1 && len(removed) == 1: + warnings = append(warnings, warnIcon(false)+" **Changes**: 1 added + 1 removed (update or move — please confirm in PR description)") + case totalChanges == 0: + warnings = append(warnings, warnIcon(false)+" **Entries**: no package entries detected in diff (might be a category or formatting change)") + default: + results = append(results, fmt.Sprintf("%s **Single item**: %d added, %d removed (expected exactly 1 change per PR)", icon(false), len(added), len(removed))) + results = append(results, fix("Each PR should add, remove, or change only **one** package.", "Please split this into separate PRs — one package per PR.")) + hasFail = true + } + + // 4. Validate added entries + for _, e := range added { + // 4a. Link matches forge link in PR body + if forgeLink != "" { + if normalizeURL(e.url) == normalizeURL(forgeLink) { + results = append(results, icon(true)+" **Link consistency**: README link matches forge link in PR body") + } else { + results = append(results, fmt.Sprintf("%s **Link consistency**: README link `%s` does not match forge link `%s`", icon(false), e.url, forgeLink)) + results = append(results, fix("The URL you added to README.md must match the forge link in your PR description.", fmt.Sprintf("Either update the README entry to use `%s`, or update `Forge link:` in your PR body to `%s`.", forgeLink, e.url))) + hasFail = true + } + } + + // 4b. Link text matches repo name + repoName := extractRepoName(e.url) + if repoName != "" { + if strings.EqualFold(e.name, repoName) { + results = append(results, icon(true)+" **Link text**: matches repository name") + } else { + warnings = append(warnings, fmt.Sprintf("%s **Link text**: `%s` differs from repo name `%s`", warnIcon(false), e.name, repoName)) + warnings = append(warnings, fix("The link text should be the exact project name.", fmt.Sprintf("If the project name really is `%s`, this is fine. Otherwise change it to: `- [%s](%s) - ...`", e.name, repoName, e.url))) + } + } + + // 4c. Description ends with period + if strings.HasSuffix(e.description, ".") || strings.HasSuffix(e.description, "!") { + results = append(results, icon(true)+" **Description**: ends with punctuation") + } else { + results = append(results, icon(false)+" **Description**: must end with a period") + results = append(results, fix("Add a period `.` at the end of the description.", fmt.Sprintf("Change to: `- [%s](%s) - %s.`", e.name, e.url, e.description))) + hasFail = true + } + + // 4d. Non-promotional check + descLower := strings.ToLower(e.description) + var promoFound []string + for _, w := range promotionalWords { + if strings.Contains(descLower, w) { + promoFound = append(promoFound, fmt.Sprintf("%q", w)) + } + } + if len(promoFound) > 0 { + warnings = append(warnings, fmt.Sprintf("%s **Promotional language**: description contains: %s", warnIcon(false), strings.Join(promoFound, ", "))) + warnings = append(warnings, fix("Descriptions should be factual and neutral, not promotional.", "Remove superlatives and marketing language. Good: `Lightweight HTTP router for Go.` Bad: `The fastest, best HTTP router ever.`")) + } else { + results = append(results, icon(true)+" **Description tone**: no promotional language detected") + } + + // 4e. Category minimum items + cat, count := getCategoryItemCount("README.md", e.url) + if count >= 3 { + results = append(results, fmt.Sprintf("%s **Category size**: %s has %d items", icon(true), cat, count)) + } else if count > 0 { + results = append(results, fmt.Sprintf("%s **Category size**: %s has only %d item(s) (minimum 3 required)", icon(false), cat, count)) + results = append(results, fix("Categories must have at least 3 packages.", "Either add more packages to this category in the same PR, or add your package to an existing category that already has 3+ items.")) + hasFail = true + } + } + + outputResults(results, warnings, hasFail) +} + +// --- 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 +} + +func captureMatch(s string, re *regexp.Regexp) string { + m := re.FindStringSubmatch(s) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +// --- Git helpers --- + +func getDiff() string { + base := os.Getenv("GITHUB_BASE_REF") + if base == "" { + base = "main" + } + out, err := exec.Command("git", "diff", "origin/"+base+"...HEAD", "--", "README.md").Output() + if err == nil && len(out) > 0 { + return string(out) + } + out, err = exec.Command("git", "diff", "HEAD~1", "--", "README.md").Output() + if err == nil { + return string(out) + } + return "" +} + +func getChangedFiles() []string { + base := os.Getenv("GITHUB_BASE_REF") + if base == "" { + base = "main" + } + out, err := exec.Command("git", "diff", "--name-only", "origin/"+base+"...HEAD").Output() + if err == nil && len(out) > 0 { + return splitLines(string(out)) + } + out, err = exec.Command("git", "diff", "--name-only", "HEAD~1").Output() + if err == nil { + return splitLines(string(out)) + } + return nil +} + +func splitLines(s string) []string { + var lines []string + for _, l := range strings.Split(strings.TrimSpace(s), "\n") { + l = strings.TrimSpace(l) + if l != "" { + lines = append(lines, l) + } + } + return lines +} + +// --- Diff parsing --- + +func parseDiffEntries(diff string) (added, removed []entry) { + for _, line := range strings.Split(diff, "\n") { + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + content := strings.TrimSpace(line[1:]) + if e, ok := parseEntry(content); ok { + added = append(added, e) + } + } + if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + content := strings.TrimSpace(line[1:]) + if e, ok := parseEntry(content); ok { + removed = append(removed, e) + } + } + } + return +} + +func parseEntry(line string) (entry, bool) { + m := reEntry.FindStringSubmatch(line) + if m == nil { + return entry{}, false + } + return entry{name: m[1], url: m[2], description: m[3], raw: line}, true +} + +// --- URL helpers --- + +func normalizeURL(u string) string { + u = strings.TrimRight(u, "/") + return strings.ToLower(u) +} + +func extractRepoName(rawURL string) string { + parts := strings.Split(strings.Trim(rawURL, "/"), "/") + if len(parts) >= 2 { + return parts[len(parts)-1] + } + return "" +} + +// --- README parsing --- + +func getCategoryItemCount(readmePath, entryURL string) (category string, count int) { + data, err := os.ReadFile(readmePath) + if err != nil { + return "unknown", -1 + } + + var currentCat string + var catItems int + var foundCat string + + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if m := reHeading.FindStringSubmatch(trimmed); m != nil { + if foundCat != "" { + break // passed the target category + } + currentCat = m[1] + catItems = 0 + } + if reEntry.MatchString(trimmed) { + catItems++ + if strings.Contains(trimmed, entryURL) { + foundCat = currentCat + } + } + } + + if foundCat == "" { + return "unknown", -1 + } + return foundCat, catItems +} + +// --- Output --- + +func outputResults(results, warnings []string, hasFail bool) { + var lines []string + lines = append(lines, "## PR Diff Validation", "") + + if len(results) > 0 { + lines = append(lines, "### Content checks", "") + lines = append(lines, results...) + lines = append(lines, "") + } + + if len(warnings) > 0 { + lines = append(lines, "### Warnings", "") + lines = append(lines, warnings...) + lines = append(lines, "") + } + + if hasFail { + lines = append(lines, "---") + lines = append(lines, "> **Action needed:** one or more content checks failed. Please review the [contribution guidelines](https://github.com/avelino/awesome-go/blob/main/CONTRIBUTING.md).") + } + + lines = append(lines, "") + lines = append(lines, "_Automated diff validation — does not replace maintainer review._") + + comment := strings.Join(lines, "\n") + setOutput("diff_comment", comment) + setOutput("diff_fail", boolStr(hasFail)) +} + +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< **How to fix:** %s\n > %s", problem, howToFix) +} diff --git a/.github/scripts/check-quality.js b/.github/scripts/check-quality.js deleted file mode 100644 index fd6928fa..00000000 --- a/.github/scripts/check-quality.js +++ /dev/null @@ -1,330 +0,0 @@ -/* - PR Quality Checks `CONTRIBUTING.md` - - Extracts links from PR body (repo, pkg.go.dev, goreportcard, coverage) - - Validates minimum standards from CONTRIBUTING.md (basic automated subset): - * Repo accessible and not archived - * Has go.mod and at least one SemVer release - * Go Report Card reachable with acceptable grade (A- or better) - * Coverage link reachable - * pkg.go.dev page exists - - Outputs a markdown report as `comment` and sets `fail=true` if critical checks fail -*/ - -'use strict'; - -const fs = require('fs'); -const https = require('https'); - -const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT; - -/** - * Read and parse the GitHub event JSON pointed to by GITHUB_EVENT_PATH. - * - * Attempts to read the file at the path given by the environment variable - * `GITHUB_EVENT_PATH` and return its parsed JSON object. If the file is - * missing, unreadable, or contains invalid JSON, returns an empty object. - * - * @returns {Object} The parsed event payload, or an empty object on error. - */ -function readEvent() { - try { - return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); - } catch { - return {}; - } -} - -/** - * Extracts the first capture group from `body` using `regex` and returns it trimmed. - * - * @param {string} body - Input text to match against. - * @param {RegExp} regex - Regular expression that contains at least one capture group; the first group's value is returned. - * @return {string} The trimmed first capture group if matched, otherwise an empty string. - */ -function capture(body, regex) { - const m = body.match(regex); - return m && m[1] ? m[1].trim() : ''; -} - -/** - * Check whether a URL is reachable using an HTTP HEAD request with a GET fallback. - * - * Performs a HEAD request to the provided URL and: - * - resolves { ok: true, status } for 2xx–3xx responses, - * - resolves { ok: false, status } for 4xx responses, - * - for other responses (including redirects or servers that don't support HEAD) retries with GET and resolves { ok: boolean, status } based on the GET status. - * Network or request errors resolve to { ok: false }. - * - * The returned Promise never rejects; it always resolves with an object describing reachability. - * - * @param {string} url - The URL to check. - * @returns {Promise<{ok: boolean, status?: number}>} Reachability result; `status` is the HTTP status code when available. - */ -function httpHeadOrGet(url) { - return new Promise((resolve) => { - const req = https.request(url, { method: 'HEAD' }, (res) => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) { - resolve({ ok: true, status: res.statusCode }); - } else if (res.statusCode && res.statusCode >= 400 && res.statusCode < 500) { - resolve({ ok: false, status: res.statusCode }); - } else { - // retry with GET on redirect or unsupported HEAD - const req2 = https.request(url, { method: 'GET' }, (res2) => { - resolve({ ok: (res2.statusCode || 500) < 400, status: res2.statusCode }); - }); - req2.on('error', () => resolve({ ok: false })); - req2.end(); - return; - } - }); - req.on('error', () => resolve({ ok: false })); - req.end(); - }); -} - -/** - * Parse a GitHub repository URL and return its owner and repository name. - * @param {string} repoUrl - URL pointing to a GitHub repository (e.g. "https://github.com/owner/repo"). - * @returns {{owner: string, repo: string}|null} An object with `owner` and `repo` when the URL is a valid GitHub repository path; otherwise `null`. - */ -function parseGithubRepo(repoUrl) { - try { - const u = new URL(repoUrl); - if (u.hostname !== 'github.com') return null; - const [, owner, repo] = u.pathname.split('/'); - if (!owner || !repo) return null; - return { owner, repo }; - } catch { - return null; - } -} - -/** - * Fetch JSON from the given URL via HTTPS GET and return the parsed object. - * - * Performs an HTTPS GET request to the provided URL and returns the parsed - * JSON body. If the response body is not valid JSON or a network error - * occurs, resolves to null. - * - * @param {string} url - The URL to fetch. - * @param {Object} [headers={}] - Optional HTTP headers to include in the request. - * @returns {Promise} The parsed JSON object, or null on error or invalid JSON. - */ -async function fetchJson(url, headers = {}) { - return new Promise((resolve) => { - https - .get(url, { headers }, (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch { - resolve(null); - } - }); - }) - .on('error', () => resolve(null)); - }); -} - -/** - * Validate a GitHub repository URL for Go project quality requirements. - * - * Performs API checks to ensure the repository exists, is not archived, - * contains a top-level `go.mod` file, and has at least one SemVer-formatted - * release (tag matching `^v\d+\.\d+\.\d+`). - * - * @param {string} repoUrl - A repository URL (expected to be a GitHub URL). - * @returns {Promise<{ok: boolean, reason?: string}>} Resolves with an object where - * `ok` is true only if the repo is reachable, not archived, has `go.mod`, and - * has a SemVer release. When `ok` is false, `reason` is one of: - * - "invalid repo url" - * - "repo api not reachable" - * - "repo is archived" - * - "missing go.mod" - * - "missing semver release" - */ -async function checkGithubRepo(repoUrl) { - const parsed = parseGithubRepo(repoUrl); - if (!parsed) return { ok: false, reason: 'invalid repo url' }; - const { owner, repo } = parsed; - const base = 'https://api.github.com'; - const headers = { - 'User-Agent': 'awesome-go-quality-check', - 'Accept': 'application/vnd.github+json', - }; - const token = process.env.GITHUB_TOKEN; - if (token) headers.Authorization = `Bearer ${token}`; - const repoData = await fetchJson(`${base}/repos/${owner}/${repo}`, headers); - if (!repoData) return { ok: false, reason: 'repo api not reachable' }; - if (repoData.archived) return { ok: false, reason: 'repo is archived' }; - const hasGoMod = await fetchJson(`${base}/repos/${owner}/${repo}/contents/go.mod`, headers); - const releases = await fetchJson(`${base}/repos/${owner}/${repo}/releases`, headers); - const hasRelease = Array.isArray(releases) && releases.some((r) => /^v\d+\.\d+\.\d+/.test(r.tag_name || '')); - const hasGoModOk = Boolean(hasGoMod && hasGoMod.name === 'go.mod'); - return { - ok: Boolean(hasGoModOk && hasRelease), - reason: !hasGoModOk ? 'missing go.mod' : !hasRelease ? 'missing semver release' : undefined, - }; -} - -/** - * Check a Go Report Card page for reachability and grade (pass if A- or better). - * - * Performs a HEAD/GET reachability check; if reachable, fetches the page and - * attempts to parse a `Grade: X` token (case-insensitive). Returns: - * - { ok: false, reason: 'unreachable' } if the initial reachability check fails. - * - { ok: false, reason: 'fetch error' } if the page fetch errors. - * - { ok: true, grade: 'unknown' } if the page is reachable but no grade is found. - * - { ok: true, grade: 'A'|'A+'|'A-'|... } for parsed grades; `ok` is true only - * when the parsed grade is A, A+ or A- (passes), otherwise `ok` is false. - * - * @param {string} url - URL of the Go Report Card page to check. - * @return {Promise} Result object containing `ok` and either `grade` or `reason`. - */ -async function checkGoReportCard(url) { - // Accept A- or better - const res = await httpHeadOrGet(url); - if (!res.ok) return { ok: false, reason: 'unreachable' }; - // Fetch page to parse grade (best-effort) - return new Promise((resolve) => { - https - .get(url, (res2) => { - let html = ''; - res2.on('data', (c) => (html += c)); - res2.on('end', () => { - const m = html.match(/Grade:\s*([A-F][+-]?)/i); - if (!m) return resolve({ ok: true, grade: 'unknown' }); - const grade = m[1].toUpperCase(); - const pass = /^A[-+]?$/.test(grade); - resolve({ ok: pass, grade }); - }); - }) - .on('error', () => resolve({ ok: false, reason: 'fetch error' })); - }); -} - -/** - * Verify that a pkg.go.dev page is reachable. - * - * Performs an HTTP HEAD (with GET fallback) to the provided URL and returns an object indicating reachability. - * - * @param {string} url - The pkg.go.dev URL to check. - * @return {{ok: boolean}} An object with `ok: true` when the page is reachable, otherwise `{ok: false}`. - */ -async function checkPkgGoDev(url) { - const res = await httpHeadOrGet(url); - return { ok: res.ok }; -} - -/** - * Check whether a coverage service URL is reachable. - * - * @param {string} url - The coverage page URL to validate (e.g., Coveralls or Codecov link). - * @returns {{ok: boolean}} An object with `ok: true` if the URL responded successfully, otherwise `ok: false`. - */ -async function checkCoverage(url) { - const res = await httpHeadOrGet(url); - return { ok: res.ok }; -} - -/** - * Writes a named workflow output to the GitHub Actions GITHUB_OUTPUT file using a heredoc block. - * - * If the GITHUB_OUTPUT environment variable is unset, this is a no-op. - * - * @param {string} name - The output name (key) to write. - * @param {string} value - The output value; written between the heredoc delimiters. - */ -function setOutput(name, value) { - if (!GITHUB_OUTPUT) return; - fs.appendFileSync(GITHUB_OUTPUT, `${name}< { - const msg = `Quality checks failed to run: ${e?.message || e}`; - if (GITHUB_OUTPUT) { - fs.appendFileSync(GITHUB_OUTPUT, `comment<= 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< **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).") + } +} diff --git a/.github/workflows/pr-quality-check.yaml b/.github/workflows/pr-quality-check.yaml index 324ae43a..9f166b02 100644 --- a/.github/workflows/pr-quality-check.yaml +++ b/.github/workflows/pr-quality-check.yaml @@ -6,37 +6,111 @@ on: permissions: pull-requests: write - contents: read + contents: write jobs: + detect: + name: Detect PR type + runs-on: ubuntu-latest + outputs: + is_package_pr: ${{ steps.check.outputs.is_package_pr }} + steps: + - name: Check if README.md is modified + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + files=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename' 2>/dev/null || echo "") + if echo "$files" | grep -q '^README.md$'; then + echo "is_package_pr=true" >> "$GITHUB_OUTPUT" + echo "README.md is modified — this is a package PR" + else + echo "is_package_pr=false" >> "$GITHUB_OUTPUT" + echo "README.md not modified — skipping quality checks" + fi + quality: + name: Repository quality checks + needs: detect + if: needs.detect.outputs.is_package_pr == 'true' runs-on: ubuntu-latest environment: action + container: golang:latest steps: - uses: actions/checkout@v6 - - name: Setup Node - uses: actions/setup-node@v6 with: - node-version: 20 + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Fetch base branch + run: git fetch origin ${{ github.base_ref }} + - name: Run quality checks - id: check + id: quality continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node .github/scripts/check-quality.js + run: go run ./.github/scripts/check-quality/ + + - name: Run diff checks + id: diff + continue-on-error: true + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: go run ./.github/scripts/check-pr-diff/ + - name: Post quality report comment uses: marocchino/sticky-pull-request-comment@v2 with: header: pr-quality-check - message: ${{ steps.check.outputs.comment }} + message: | + ${{ steps.quality.outputs.comment }} + + --- + + ${{ steps.diff.outputs.diff_comment }} + - name: Sync labels uses: actions-ecosystem/action-add-labels@v1 - if: ${{ steps.check.outputs.labels != '' }} + if: ${{ steps.quality.outputs.labels != '' }} with: github_token: ${{ secrets.GITHUB_TOKEN }} - labels: ${{ join(fromJson(steps.check.outputs.labels), '\n') }} + labels: ${{ join(fromJson(steps.quality.outputs.labels), '\n') }} + - name: Fail if critical checks failed - if: ${{ steps.check.outputs.fail == 'true' }} + if: ${{ steps.quality.outputs.fail == 'true' || steps.diff.outputs.diff_fail == 'true' }} run: | - echo "Critical quality checks failed." + echo "Quality or diff checks failed." exit 1 + + skip-notice: + name: Skip quality checks (non-package PR) + needs: detect + if: needs.detect.outputs.is_package_pr == 'false' + runs-on: ubuntu-latest + steps: + - name: Post skip notice + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-quality-check + message: | + ## Automated Quality Checks + + **Skipped** — this PR does not modify `README.md`, so package quality checks do not apply. + + _This is expected for maintenance, documentation, or workflow PRs._ + + auto-merge: + name: Enable auto-merge + needs: quality + if: always() && needs.quality.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Enable auto-merge via squash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --auto \ + --squash diff --git a/.github/workflows/recheck-open-prs.yaml b/.github/workflows/recheck-open-prs.yaml new file mode 100644 index 00000000..1353122a --- /dev/null +++ b/.github/workflows/recheck-open-prs.yaml @@ -0,0 +1,46 @@ +name: Re-check all open PRs + +on: + workflow_dispatch: + +permissions: + pull-requests: write + contents: read + +jobs: + recheck: + name: Re-trigger checks on open PRs + runs-on: ubuntu-latest + steps: + - name: Re-run quality checks on all open PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Fetching all open PR numbers..." + prs=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 500 --json number --jq '.[].number') + total=$(echo "$prs" | wc -w) + echo "Found $total open PRs" + + count=0 + failed=0 + for pr in $prs; do + count=$((count + 1)) + echo "[$count/$total] Re-triggering PR #$pr..." + + # Close and reopen to trigger the 'reopened' event, + # which re-runs pr-quality-check.yaml + if gh pr close "$pr" --repo "$GITHUB_REPOSITORY" 2>/dev/null && \ + gh pr reopen "$pr" --repo "$GITHUB_REPOSITORY" 2>/dev/null; then + echo " OK" + else + echo " FAILED (PR #$pr may be a draft or have restrictions)" + failed=$((failed + 1)) + fi + + # Respect GitHub API rate limits (30 requests/min for mutations) + # Each PR uses 2 calls (close + reopen), so ~2s delay keeps us safe + sleep 2 + done + + echo "" + echo "Done: $((count - failed))/$total PRs re-triggered, $failed failed" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71c58d08..85d4681b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,10 +11,10 @@ We appreciate and recognize [all contributors](https://github.com/avelino/awesom - [Quick checklist](#quick-checklist) - [Quality standards](#quality-standards) +- [What is checked automatically](#what-is-checked-automatically) - [Preparing for review](#preparing-for-review) -- [Local validations (optional)](#local-validations-optional) - [How to add an item to the list](#how-to-add-an-item-to-the-list) - - [Examples of good and bad entries](#examples-of-good-and-bad-entries) + - [Entry formatting rules](#entry-formatting-rules) - [PR body example](#pr-body-example) - [Congrats, your project got accepted - what now](#congrats-your-project-got-accepted---what-now) - [Maintenance expectations for projects listed here](#maintenance-expectations-for-projects-listed-here) @@ -27,17 +27,17 @@ We appreciate and recognize [all contributors](https://github.com/avelino/awesom ## Quick checklist -Before opening a pull request, please ensure the following: +Before opening a pull request, ensure the following: -- One PR adds, removes, or changes only one item. -- The item is in the correct category and in alphabetical order. -- The link text is the exact project/package name. -- The description is concise, non-promotional, and ends with a period. -- The repository has: at least 5 months of history, an open source license, a `go.mod`, and at least one SemVer release (vX.Y.Z). -- Documentation in English: README and pkg.go.dev doc comments for public APIs. -- Tests meet the coverage guideline (≥80% for non-data packages, ≥90% for data packages) when applicable. -- Include links in the PR body to pkg.go.dev, Go Report Card, and a coverage report. -- For ongoing development: issues and PRs are responded to within ~2 weeks; or, if the project is mature/stable, there are no bug reports older than 6 months. +- [ ] One PR adds, removes, or changes **only one item**. +- [ ] The item is in the **correct category** and in **alphabetical order**. +- [ ] The link text is the **exact project/package name**. +- [ ] The description is **concise, non-promotional, and ends with a period**. +- [ ] The repository has: at least **5 months of history**, an **open source license**, a `go.mod`, and at least one **SemVer release** (`vX.Y.Z`). +- [ ] Documentation in English: **README** and **pkg.go.dev doc comments** for public APIs. +- [ ] Tests meet the coverage guideline (**≥80%** for non-data packages, **≥90%** for data packages) when applicable. +- [ ] Include links in the PR body to **pkg.go.dev**, **Go Report Card**, and a **coverage report**. +- [ ] For ongoing development: issues and PRs are responded to within ~2 weeks; or, if the project is mature/stable, there are no bug reports older than 6 months. To set this list apart from and complement the excellent [Go wiki Projects page](https://go.dev/wiki/Projects), and other lists, awesome-go is a specially curated list of high-quality, actively maintained Go packages and resources. @@ -65,6 +65,51 @@ To be on the list, project repositories should adhere to the following quality s Categories must have at least 3 items. +## What is checked automatically + +When you open a PR, the following checks run automatically via CI. Fixing these before submitting saves review time. + +### Blocking checks (PR cannot be merged if these fail) + +| Check | What it validates | +|-------|-------------------| +| **Repo accessible** | Repository URL responds and is not archived | +| **go.mod present** | `go.mod` exists at the repository root | +| **SemVer release** | At least one tag matching `vX.Y.Z` exists | +| **pkg.go.dev reachable** | The provided pkg.go.dev link loads | +| **Go Report Card grade** | Grade is A-, A, or A+ | +| **PR body links present** | Forge link, pkg.go.dev, and Go Report Card are provided | +| **Single item per PR** | Only one package added or removed per PR | +| **Link consistency** | URL added to README matches the forge link in the PR body | +| **Description format** | Entry ends with a period | +| **Alphabetical order** | Entry is in the correct alphabetical position | +| **No duplicate links** | URL is not already in the list | +| **Entry format** | Matches `- [name](url) - Description.` pattern | +| **Category minimum** | Category has at least 3 items | + +### Non-blocking checks (reported as warnings) + +| Check | What it validates | +|-------|-------------------| +| **Open source license** | GitHub detects a recognized OSS license | +| **Repository maturity** | First commit is at least 5 months old | +| **CI/CD configured** | GitHub Actions workflows are present | +| **README present** | Repository has a README file | +| **Coverage link** | A Codecov or Coveralls link is provided and reachable | +| **Link text** | Link text matches the repository name | +| **Non-promotional** | Description avoids superlative/marketing language | +| **Extra files** | Only README.md is modified (for package additions) | + +### Still reviewed manually by maintainers + +- Package is in the **correct category** for its functionality +- Package is **generally useful** to the Go community +- Description is **accurate and clear** +- Test coverage is **real** (not just benchmarks) +- Documentation quality (README detail, pkg.go.dev comments) +- Package **functions as documented** +- For surrounding packages: still meet quality standards + ## Preparing for review Projects listed must have the following in their documentation. When submitting, you will be asked @@ -80,26 +125,6 @@ One way to accomplish the above is to add badges to your project's README file. - Go to to generate a Go Report Card report, then click on the report badge in the upper-right corner to see details on how to add the badge to your README. - Codecov, coveralls, and gocover all offer ways to create badges for code coverage reports. Another option is to generate a badge as part of a continuous integration process. See [Code Coverage](COVERAGE.md) for an example. -## Local validations (optional) - -While automated checks run on pull requests, doing a quick local validation helps speed up reviews: - -- Repository readiness - - Ensure `go.mod` exists and there is at least one SemVer release (e.g., `v1.2.3`). - - Confirm the project has an open source license. -- Documentation readiness - - README is in English and explains what the project does and how to use it. - - Public APIs have doc comments and the pkg.go.dev page loads. -- Quality reports - - Go Report Card is reachable with grade A- or better. - - Coverage link (Codecov, Coveralls, etc.) is reachable. - - Local coverage sanity check (optional): - - `go test ./... -coverprofile=coverage.out` - - `go tool cover -func=coverage.out` (check percentage) -- List formatting - - Correct category and alphabetical order. - - Link text equals the project name; description ends with a period and is non-promotional. - ## How to add an item to the list Open a pull request against the README.md document that adds the repository to the list. @@ -116,7 +141,7 @@ that the resulting list has at least 3 projects in every category, and that the Fill out the template in your PR with the links asked for. If you accidentally remove the PR template from the submission, you can find it [here](https://github.com/avelino/awesome-go/blob/main/.github/PULL_REQUEST_TEMPLATE.md). -### Examples of good and bad entries +### Entry formatting rules Good: