diff --git a/.gitignore b/.gitignore index 8cf2792..24248be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Binaries for programs and plugins +go2spec *.exe *.exe~ *.dll @@ -24,6 +25,11 @@ go.work.sum # env file .env +# Local go2spec validation/output artifacts +comparison-runs/ +go-github-sandrolain-httpcache/ +go-github-sandrolain-httpcache_*.orig.tar.gz + # Editor/IDE .idea/ .vscode/ diff --git a/check_version.go b/check_version.go index cb66361..c99cb65 100644 --- a/check_version.go +++ b/check_version.go @@ -3,7 +3,6 @@ package main import ( "fmt" "log" - "os" "os/exec" "regexp" "slices" @@ -14,9 +13,6 @@ import ( ) var ( - // describeRegexp parses the count and revision part of the “git describe --long” output. - describeRegexp = regexp.MustCompile(`-\d+-g([0-9a-f]+)\s*$`) - // semverRegexp checks if a string is a valid Go semver, // from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string // with leading "v" added. @@ -24,10 +20,22 @@ var ( // uversionPrereleaseRegexp checks for upstream pre-release // so that '-' can be replaced with '~' in pkgVersionFromGit. - // To be kept in sync with the regexp portion of uversionmanglePattern in spec.go uversionPrereleaseRegexp = regexp.MustCompile(`(\d)[_\.\-\+]?(RC|rc|pre|dev|beta|alpha)[.]?(\d*)$`) + + packagingDateNow = time.Now ) +func packagingDateString() string { + return packagingDateNow().Format("20060102") +} + +func shortCommitHash(hash string) string { + if len(hash) > 7 { + return hash[:7] + } + return hash +} + // pkgVersionFromGit determines the actual version to be packaged // from the git repository status and user preference. // Besides returning the upstream version, the "upstream" struct @@ -42,8 +50,10 @@ func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePre var cmd *exec.Cmd // the temporary shell commands we execute // If the user specifies a valid tag as the preferred revision, that tag should be used without additional heuristics. - if u.rr != nil { - if out, err := u.rr.VCS.Tags(gitdir); err == nil && slices.Contains(out, preferredRev) { + if preferredRev != "" { + cmd = exec.Command("git", "tag", "--list", preferredRev) + cmd.Dir = gitdir + if out, err := cmd.Output(); err == nil && slices.Contains(strings.Fields(string(out)), preferredRev) { latestTag = preferredRev } } @@ -110,50 +120,6 @@ func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePre // Packaging @master (prerelease) - mainVer := "" - //if u.hasRelease { - // mainVer = u.version + "+" - //} - - // Find committer date, UNIX timestamp - cmd = exec.Command("git", "log", "--pretty=format:%ct", "-n1", "--no-show-signature") - cmd.Dir = gitdir - lastCommitUnixBytes, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git log: %w", err) - } - lastCommitUnix, err := strconv.ParseInt(strings.TrimSpace(string(lastCommitUnixBytes)), 0, 64) - if err != nil { - return "", fmt.Errorf("parse last commit date: %w", err) - } - - dateStr := time.Unix(lastCommitUnix, 0).UTC().Format("20060102") - - // Fetch commit hash - cmd = exec.Command("git", "describe", "--long", "--tags") - cmd.Dir = gitdir - lastCommitHash := "" - describeBytes, err := cmd.Output() - if err != nil { - // In case there are no tags at all, we just use the sha of the current commit - cmd = exec.Command("git", "rev-parse", "--short", "HEAD") - cmd.Dir = gitdir - cmd.Stderr = os.Stderr - revparseBytes, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git rev-parse: %w", err) - } - lastCommitHash = strings.TrimSpace(string(revparseBytes)) - u.commitIsh = lastCommitHash - } else { - submatches := describeRegexp.FindSubmatch(describeBytes) - if submatches == nil { - return "", fmt.Errorf("git describe output %q does not match expected format", string(describeBytes)) - } - lastCommitHash = string(submatches[1]) - u.commitIsh = strings.TrimSpace(string(describeBytes)) - } - // Fetch full commit hash cmd = exec.Command("git", "rev-parse", "HEAD") cmd.Dir = gitdir @@ -162,18 +128,10 @@ func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePre return "", fmt.Errorf("git rev-parse HEAD: %w", err) } fullCommitHash := strings.TrimSpace(string(fullCommitHashBytes)) + lastCommitHash := shortCommitHash(fullCommitHash) + u.commitIsh = lastCommitHash - u.version = fmt.Sprintf("%sgit%s.%s", - mainVer, - time.Unix(lastCommitUnix, 0).UTC().Format("20060102"), - lastCommitHash) - - if u.hasRelease { - // have tag: 1.2.3.20250101+git9f107c8 - u.version = fmt.Sprintf("%s.%s+git%s", u.version, dateStr, lastCommitHash) - } else { - // without tag: 0.git20250101.96ee002 96ee0021ea0fb9174681b8004d8deba3c499d7f5 - u.version = fmt.Sprintf("0+git%s.%s\n%%define commit_id %s", dateStr, lastCommitHash, fullCommitHash) - } + // Snapshot versions are based on the packaging date, not the commit date. + u.version = fmt.Sprintf("0+git%s.%s\n%%define commit_id %s", packagingDateString(), lastCommitHash, fullCommitHash) return u.version, nil } diff --git a/description.go b/description.go index b6a9069..31b642d 100644 --- a/description.go +++ b/description.go @@ -13,8 +13,7 @@ import ( //go:embed description.json var descriptionJSONBytes []byte -// reformatForControl reformats the wrapped description -// to conform to Debian’s control format. +// reformatForControl reformats wrapped text for the RPM spec's %description. func reformatForControl(raw string) string { output := "" next_prefix := "" @@ -47,8 +46,8 @@ func reformatForControl(raw string) string { return output } -// markdownToLongDescription converts Markdown to plain text -// and reformat it for expanded description in debian/control. +// markdownToLongDescription converts Markdown to plain text for the RPM spec's +// %description section. func markdownToLongDescription(markdown string) (string, error) { r, _ := glamour.NewTermRenderer( glamour.WithStylesFromJSONBytes(descriptionJSONBytes), @@ -63,23 +62,17 @@ func markdownToLongDescription(markdown string) (string, error) { return reformatForControl(out), nil } -// getDescriptionForGopkg reads from README.md (or equivalent) from GitHub, -// intended for extended description in debian/control. +// getLongDescriptionForGopkg reads README.md (or equivalent) from pkg.go.dev, +// intended for the RPM spec's %description section. func getLongDescriptionForGopkg(gopkg string) (string, error) { - owner, repo, err := findGitHubRepo(gopkg) + info, err := getPkgsiteInfo(context.TODO(), gopkg) if err != nil { - return "", fmt.Errorf("find github repo: %w", err) + return "", fmt.Errorf("get pkgsite metadata: %w", err) } - - rr, _, err := gitHub.Repositories.GetReadme(context.TODO(), owner, repo, nil) - if err != nil { - return "", fmt.Errorf("get readme: %w", err) - } - - content, err := rr.GetContent() - if err != nil { - return "", fmt.Errorf("get content: %w", err) + if info.Module.Readme == nil || strings.TrimSpace(info.Module.Readme.Contents) == "" { + return "", fmt.Errorf("pkgsite module %q has no README", info.Module.Path) } + content := info.Module.Readme.Contents // Supported filename suffixes are from // https://github.com/github/markup/blob/master/README.md @@ -88,10 +81,11 @@ func getLongDescriptionForGopkg(gopkg string) (string, error) { // fairly involved, but it’d be the most correct solution to the problem at // hand. Our current code just knows markdown, which is good enough since // most (Go?) projects in fact use markdown for their README files. - if !strings.HasSuffix(rr.GetName(), "md") && - !strings.HasSuffix(rr.GetName(), "markdown") && - !strings.HasSuffix(rr.GetName(), "mdown") && - !strings.HasSuffix(rr.GetName(), "mkdn") { + readmeName := strings.ToLower(info.Module.Readme.Filepath) + if !strings.HasSuffix(readmeName, "md") && + !strings.HasSuffix(readmeName, "markdown") && + !strings.HasSuffix(readmeName, "mdown") && + !strings.HasSuffix(readmeName, "mkdn") { return reformatForControl(content), nil } diff --git a/go.mod b/go.mod index 6297e56..5fbaabb 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,9 @@ go 1.25.3 require ( github.com/charmbracelet/glamour v0.10.0 - github.com/google/go-github/v60 v60.0.0 github.com/mattn/go-isatty v0.0.20 github.com/sandrolain/httpcache v1.4.0 golang.org/x/net v0.47.0 - golang.org/x/tools/go/vcs v0.1.0-deprecated ) require ( @@ -22,7 +20,6 @@ require ( github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect diff --git a/go.sum b/go.sum index c517765..67d4107 100644 --- a/go.sum +++ b/go.sum @@ -28,13 +28,6 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -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/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= -github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= -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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -76,6 +69,3 @@ golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools/go/vcs v0.1.0-deprecated h1:cOIJqWBl99H1dH5LWizPa+0ImeeJq3t3cJjaeOWUAL4= -golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 7aad5ed..18b334e 100644 --- a/main.go +++ b/main.go @@ -3,26 +3,11 @@ package main import ( "net/http" "os" + "time" - "github.com/google/go-github/v60/github" "github.com/sandrolain/httpcache" ) -var ( - gitHub *github.Client -) - -// TokenTransport implements http.RoundTripper for Bearer token authentication -type TokenTransport struct { - Token string - Transport http.RoundTripper -} - -func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Add("Authorization", "Bearer "+t.Token) - return t.Transport.RoundTrip(req) -} - func printHelp() { helpText := `go2spec - A tool to package Go modules into RPM spec files. @@ -41,28 +26,10 @@ If there are no commands provided, the tool will default to executing the 'pack' } func main() { - token := os.Getenv("GITHUB_TOKEN") - - var client *http.Client - if token != "" { - // Use token authentication for better rate limits - client = &http.Client{ - Transport: &TokenTransport{ - Token: token, - Transport: httpcache.NewMemoryCacheTransport(), - }, - } - } else { - // Fallback to basic auth if token is not provided - transport := github.BasicAuthTransport{ - Username: os.Getenv("GITHUB_USERNAME"), - Password: os.Getenv("GITHUB_PASSWORD"), - OTP: os.Getenv("GITHUB_OTP"), - Transport: httpcache.NewMemoryCacheTransport(), - } - client = transport.Client() + pkgsiteHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: httpcache.NewMemoryCacheTransport(), } - gitHub = github.NewClient(client) args := os.Args[1:] diff --git a/metadata.go b/metadata.go index 80ca8aa..c5abb6b 100644 --- a/metadata.go +++ b/metadata.go @@ -3,128 +3,175 @@ package main import ( "context" "fmt" - "net/http" + "html" + "path" "regexp" + "sort" "strings" - - "golang.org/x/net/html" ) -// To update, use: -// curl -s https://api.github.com/licenses | jq '.[].key' -var githubLicenseToSPDXLicense = map[string]string{ - //"agpl-3.0" - "apache-2.0": "Apache-2.0", - "artistic-2.0": "Artistic-2.0", - "bsd-2-clause": "BSD-2-Clause", - "bsd-3-clause": "BSD-3-Clause", - "cc0-1.0": "CC0-1.0", - //"epl-1.0" (eclipse public license) - "gpl-2.0": "GPL-2.0-only", - "gpl-3.0": "GPL-3.0-only", - "isc": "ISC", - "lgpl-2.1": "LGPL-2.1-only", - "lgpl-3.0": "LGPL-3.0-only", - "mit": "MIT", - "mpl-2.0": "MPL-2.0", - //"unlicense" +func getRepoURLForGopkg(gopkg string) (string, error) { + info, err := getPkgsiteInfo(context.TODO(), gopkg) + if err != nil { + return "", err + } + repoURL := strings.TrimSpace(info.Module.RepoURL) + if repoURL == "" { + return "", fmt.Errorf("pkgsite module %q has no repository URL", info.Module.Path) + } + return gitCloneURLFromRepoURL(repoURL), nil } -var githubRegexp = regexp.MustCompile(`github\.com/([^/]+/[^/]+)`) +var ( + htmlTagRegexp = regexp.MustCompile(`<[^>]*>`) + markdownImageRegex = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`) + markdownLinkRegex = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`) + packagePrefixRegex = regexp.MustCompile(`^Package\s+\S+\s+`) +) -func findGitHubOwnerRepo(gopkg string) (string, error) { - if strings.HasPrefix(gopkg, "github.com/") { - return strings.TrimPrefix(gopkg, "github.com/"), nil +// cleanSummaryCandidate turns a godoc synopsis or README line into an +// RPM-style Summary by stripping markup, keeping the first sentence, dropping +// the leading "Package foo" convention, and capitalizing the result. +func cleanSummaryCandidate(summary string) string { + summary = html.UnescapeString(strings.TrimSpace(summary)) + summary = markdownImageRegex.ReplaceAllString(summary, "") + summary = markdownLinkRegex.ReplaceAllString(summary, "$1") + summary = htmlTagRegexp.ReplaceAllString(summary, " ") + summary = strings.ReplaceAll(summary, "`", "") + summary = strings.Join(strings.Fields(summary), " ") + summary = strings.Trim(summary, " \t\n\r#*-_") + if end := strings.Index(summary, ". "); end >= 0 { + summary = summary[:end] } - resp, err := http.Get("https://" + gopkg + "?go-get=1") - if err != nil { - return "", fmt.Errorf("HTTP get: %w", err) + summary = packagePrefixRegex.ReplaceAllString(summary, "") + if summary != "" && summary[0] >= 'a' && summary[0] <= 'z' { + summary = string(summary[0]-('a'-'A')) + summary[1:] } - defer resp.Body.Close() - z := html.NewTokenizer(resp.Body) - for { - tt := z.Next() - if tt == html.ErrorToken { - return "", fmt.Errorf("%q is not on GitHub", gopkg) - } - token := z.Token() - if token.Data != "meta" { + if summary == "" || + strings.HasPrefix(summary, "[!") || + strings.HasPrefix(summary, "![") || + strings.HasPrefix(summary, "