From 2ace98388dcb446adaf5a65fd4ee75c016f40dd2 Mon Sep 17 00:00:00 2001 From: misaka00251 Date: Mon, 1 Dec 2025 14:27:48 +0800 Subject: [PATCH] Init project --- check_version.go | 169 +++++++++++ description.go | 99 +++++++ description.json | 96 ++++++ go.mod | 39 +++ go.sum | 81 ++++++ main.go | 61 ++++ metadata.go | 130 +++++++++ pack.go | 742 +++++++++++++++++++++++++++++++++++++++++++++++ progress.go | 58 ++++ spec.go | 340 ++++++++++++++++++++++ 10 files changed, 1815 insertions(+) create mode 100644 check_version.go create mode 100644 description.go create mode 100644 description.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 metadata.go create mode 100644 pack.go create mode 100644 progress.go create mode 100644 spec.go diff --git a/check_version.go b/check_version.go new file mode 100644 index 0000000..f4a1d24 --- /dev/null +++ b/check_version.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "regexp" + "slices" + "strconv" + "strings" + "time" + "unicode" +) + +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. + semverRegexp = regexp.MustCompile(`^v(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + + // 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*)$`) +) + +// pkgVersionFromGit determines the actual version to be packaged +// from the git repository status and user preference. +// Besides returning the upstream version, the "upstream" struct +// struct fields u.version, u.commitIsh, u.hasRelease and u.isRelease +// are also set. +// `preferredRev` should be empty if there are no user preferences. +// TODO: also support other VCS +func pkgVersionFromGit(gitdir string, u *upstream, preferredRev string, forcePrerelease bool) (string, error) { + var latestTag string + var commitsAhead int + + 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) { + latestTag = preferredRev + } + } + + // Find @latest version tag (whether annotated or not) when the user + // (1) does not specify a version tag, or + // (2) specifies an invalid version tag. + if len(latestTag) == 0 { + cmd = exec.Command("git", "describe", "--abbrev=0", "--tags", "--exclude", "*/v*") + cmd.Dir = gitdir + if out, err := cmd.Output(); err == nil { + latestTag = strings.TrimSpace(string(out)) + } + } + + if len(latestTag) > 0 { + u.hasRelease = true + u.tag = latestTag + log.Printf("Found latest tag %q", latestTag) + + if !semverRegexp.MatchString(latestTag) { + log.Printf("WARNING: Latest tag %q is not a valid SemVer version\n", latestTag) + // TODO: Enforce strict sementic versioning with leading "v"? + } + + // Count number of commits since @latest version + cmd = exec.Command("git", "rev-list", "--count", latestTag+"..HEAD") + cmd.Dir = gitdir + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git rev-list: %w", err) + } + commitsAhead, err = strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return "", fmt.Errorf("parse commits ahead: %w", err) + } + + if commitsAhead == 0 { + // Equivalent to "git describe --exact-match --tags" + log.Printf("Latest tag %q matches master", latestTag) + } else { + log.Printf("INFO: master is ahead of %q by %v commits", latestTag, commitsAhead) + } + + u.commitIsh = latestTag + + // Mangle latestTag into upstream_version + // TODO: Move to function and write unit test? + u.version = strings.TrimLeftFunc( + uversionPrereleaseRegexp.ReplaceAllString(latestTag, "$1~$2$3"), + func(r rune) bool { + return !unicode.IsNumber(r) + }, + ) + + if forcePrerelease { + log.Printf("INFO: Force packaging master (prerelease) as requested by user") + // Fallthrough to package @master (prerelease) + } else { + u.isRelease = true + return u.version, nil + } + } + + // 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)) + } + 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: 20250101+git9f107c8 + u.version = fmt.Sprintf("%s+git%s", dateStr, lastCommitHash) + } + return u.version, nil +} diff --git a/description.go b/description.go new file mode 100644 index 0000000..b6a9069 --- /dev/null +++ b/description.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/glamour" +) + +//go:embed description.json +var descriptionJSONBytes []byte + +// reformatForControl reformats the wrapped description +// to conform to Debian’s control format. +func reformatForControl(raw string) string { + output := "" + next_prefix := "" + re := regexp.MustCompile(`^ \d+\. `) + + for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { + // Remove paddings that Glamour currently add to the end of each line + line = strings.TrimRight(line, " ") + + // Try to add hanging indent for list items that span over one line + prefix := next_prefix + if strings.HasPrefix(line, " * ") { + // unordered list + prefix = "" + next_prefix = " " + } + if re.MatchString(line) { + // ordered list + prefix = "" + next_prefix = " " + } + if line == "" { + // blank line, implying end of list + line = "." + prefix = "" + next_prefix = "" + } + output += " " + prefix + line + "\n" + } + return output +} + +// markdownToLongDescription converts Markdown to plain text +// and reformat it for expanded description in debian/control. +func markdownToLongDescription(markdown string) (string, error) { + r, _ := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(descriptionJSONBytes), + glamour.WithWordWrap(72), + ) + out, err := r.Render(markdown) + if err != nil { + return "", fmt.Errorf("fail to render Markdown: %w", err) + } + //fmt.Println(out) + //fmt.Println(reformatForControl(out)) + return reformatForControl(out), nil +} + +// getDescriptionForGopkg reads from README.md (or equivalent) from GitHub, +// intended for extended description in debian/control. +func getLongDescriptionForGopkg(gopkg string) (string, error) { + owner, repo, err := findGitHubRepo(gopkg) + if err != nil { + return "", fmt.Errorf("find github repo: %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) + } + + // Supported filename suffixes are from + // https://github.com/github/markup/blob/master/README.md + // NOTE(stapelberg): Ideally, we’d use https://github.com/github/markup + // itself to render to HTML, then convert HTML to plaintext. That sounds + // 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") { + return reformatForControl(content), nil + } + + return markdownToLongDescription(content) +} diff --git a/description.json b/description.json new file mode 100644 index 0000000..06e9328 --- /dev/null +++ b/description.json @@ -0,0 +1,96 @@ +{ + "document": { + "block_prefix": "\n", + "block_suffix": "\n", + "margin": 0 + }, + "block_quote": { + "indent": 1, + "indent_token": " | " + }, + "paragraph": {}, + "list": { + "indent": 1, + "level_indent": 4 + }, + "heading": { + "block_suffix": "\n" + }, + "h1": { + "prefix": "" + }, + "h2": { + "prefix": "" + }, + "h3": { + "prefix": "" + }, + "h4": { + "prefix": "" + }, + "h5": { + "prefix": "" + }, + "h6": { + "prefix": "" + }, + "text": {}, + "strikethrough": { + "block_prefix": "~~", + "block_suffix": "~~" + }, + "emph": { + "block_prefix": "*", + "block_suffix": "*" + }, + "strong": { + "block_prefix": "**", + "block_suffix": "**" + }, + "hr": { + "format": "\n------------------------------------------------------------------------\n" + }, + "item": { + "block_prefix": "* ", + "indent": 2 + }, + "enumeration": { + "block_prefix": ". ", + "indent": 2 + }, + "task": { + "ticked": "[x] ", + "unticked": "[ ] " + }, + "link": { + "prefix": "(", + "suffix": ")" + }, + "link_text": {}, + "image": { + "prefix": "(", + "suffix": ")" + }, + "image_text": { + "format": "[Image: {{.text}}]" + }, + "code": { + "block_prefix": "", + "block_suffix": "" + }, + "code_block": { + "margin": 2 + }, + "table": { + "center_separator": "+", + "column_separator": "|", + "row_separator": "-" + }, + "definition_list": {}, + "definition_term": {}, + "definition_description": { + "block_prefix": "\n* " + }, + "html_block": {}, + "html_span": {} +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6297e56 --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module go2spec + +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 ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + 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 + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c517765 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +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= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0= +github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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 new file mode 100644 index 0000000..f0c058a --- /dev/null +++ b/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "os" + + "github.com/google/go-github/v60/github" + "github.com/sandrolain/httpcache" +) + +var ( + gitHub *github.Client +) + +func printHelp() { + helpText := `go2spec - A tool to package Go modules into RPM spec files. + +Usage: + go2spec [command] + +Available Commands: + pack Package the Go modules into an RPM spec file + help Show this help message + +Use "go2spec [command] --help" for more information about a command. + +If there are no commands provided, the tool will default to executing the 'pack' command. +` + println(helpText) +} + +func main() { + transport := github.BasicAuthTransport{ + Username: os.Getenv("GITHUB_USERNAME"), + Password: os.Getenv("GITHUB_PASSWORD"), + OTP: os.Getenv("GITHUB_OTP"), + Transport: httpcache.NewMemoryCacheTransport(), + } + gitHub = github.NewClient(transport.Client()) + + args := os.Args[1:] + + cmd := "" + if len(args) > 0 { + cmd = args[0] + } + + switch cmd { + case "help": + printHelp() + case "pack": + // Placeholder for the pack command implementation + println("Executing 'pack' command...") + // Actual packing logic would go here + mainPack(args[1:], nil) + default: + // Default to 'pack' command if no command is provided + println("No command provided. Defaulting to 'pack' command...") + // Actual packing logic would go here + mainPack(args, usage) + } +} diff --git a/metadata.go b/metadata.go new file mode 100644 index 0000000..80ca8aa --- /dev/null +++ b/metadata.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "regexp" + "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" +} + +var githubRegexp = regexp.MustCompile(`github\.com/([^/]+/[^/]+)`) + +func findGitHubOwnerRepo(gopkg string) (string, error) { + if strings.HasPrefix(gopkg, "github.com/") { + return strings.TrimPrefix(gopkg, "github.com/"), nil + } + resp, err := http.Get("https://" + gopkg + "?go-get=1") + if err != nil { + return "", fmt.Errorf("HTTP get: %w", err) + } + 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" { + continue + } + var meta struct { + name, content string + } + for _, attr := range token.Attr { + if attr.Key == "name" { + meta.name = attr.Val + } + if attr.Key == "content" { + meta.content = attr.Val + } + } + + match := func(name string, length int) string { + if f := strings.Fields(meta.content); meta.name == name && len(f) == length { + if f[0] != gopkg { + return "" + } + if repoMatch := githubRegexp.FindStringSubmatch(f[2]); repoMatch != nil { + return strings.TrimSuffix(repoMatch[1], ".git") + } + } + return "" + } + if repo := match("go-import", 3); repo != "" { + return repo, nil + } + if repo := match("go-source", 4); repo != "" { + return repo, nil + } + } +} + +func findGitHubRepo(gopkg string) (owner string, repo string, _ error) { + ownerrepo, err := findGitHubOwnerRepo(gopkg) + if err != nil { + return "", "", fmt.Errorf("find GitHub owner repo: %w", err) + } + parts := strings.Split(ownerrepo, "/") + if got, want := len(parts), 2; got != want { + return "", "", fmt.Errorf("invalid GitHub repo: %q does not follow owner/repo", repo) + } + return parts[0], parts[1], nil +} + +func getLicenseForGopkg(gopkg string) (string, error) { + owner, repo, err := findGitHubRepo(gopkg) + if err != nil { + return "", fmt.Errorf("find GitHub repo: %w", err) + } + + rl, _, err := gitHub.Repositories.License(context.TODO(), owner, repo) + if err != nil { + return "", fmt.Errorf("get license for Go package: %w", err) + } + + if license, ok := githubLicenseToSPDXLicense[rl.GetLicense().GetKey()]; ok { + return license, nil + } + + return "TODO", nil +} + +// getDescriptionForGopkg gets the package description from GitHub, +// intended for the synopsis or the short description in debian/control. +func getDescriptionForGopkg(gopkg string) (string, error) { + owner, repo, err := findGitHubRepo(gopkg) + if err != nil { + return "", fmt.Errorf("find GitHub repo: %w", err) + } + + rr, _, err := gitHub.Repositories.Get(context.TODO(), owner, repo) + if err != nil { + return "", err + } + + return strings.TrimSpace(rr.GetDescription()), nil +} diff --git a/pack.go b/pack.go new file mode 100644 index 0000000..38d9626 --- /dev/null +++ b/pack.go @@ -0,0 +1,742 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/net/publicsuffix" + "golang.org/x/tools/go/vcs" +) + +type packageType int + +const ( + typeGuess packageType = iota + typeLibrary + typeProgram + typeLibraryProgram + typeProgramLibrary +) + +// upstream describes the upstream repo we are about to package. +type upstream struct { + rr *vcs.RepoRoot + tarPath string // path to the downloaded or generated orig tarball tempfile + compression string // compression method, either "gz" or "xz" + version string // upstream version number, e.g. 0.0~git20180204.1d24609 + tag string // Latest upstream tag, if any + commitIsh string // commit-ish corresponding to upstream version to be packaged + remote string // git remote, set to short hostname if upstream git history is included + firstMain string // import path of the first main package within repo, if any + vendorDirs []string // all vendor sub directories, relative to the repo directory + repoDeps []string // the repository paths of all dependencies (e.g. github.com/zyedidia/glob) + hasGodeps bool // whether the Godeps/_workspace directory exists + hasRelease bool // whether any release tags exist, for debian/watch + isRelease bool // whether what we end up packaging is a tagged release +} + +var errUnsupportedHoster = errors.New("unsupported hoster") + +func passthroughEnv() []string { + var relevantVariables = []string{ + "HOME", + "PATH", + "HTTP_PROXY", "http_proxy", + "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", + "GIT_PROXY_COMMAND", + "GIT_HTTP_PROXY_AUTHMETHOD", + } + var result []string + for _, variable := range relevantVariables { + if value, ok := os.LookupEnv(variable); ok { + result = append(result, fmt.Sprintf("%s=%s", variable, value)) + } + } + return result +} + +// findVendorDirs walks the directory tree rooted at dir and returns +// all vendor/ directories found, relative to dir. +func findVendorDirs(dir string) ([]string, error) { + var vendorDirs []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info != nil && !info.IsDir() { + return nil // nothing to do for anything but directories + } + if info.Name() == ".git" || + info.Name() == ".hg" || + info.Name() == ".bzr" { + return filepath.SkipDir + } + if info.Name() == "vendor" { + rel, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("filepath.Rel: %w", err) + } + vendorDirs = append(vendorDirs, rel) + } + return nil + }) + return vendorDirs, err +} + +func downloadFile(filename, url string) error { + dst, err := os.Create(filename) + if err != nil { + return fmt.Errorf("create: %w", err) + } + defer dst.Close() + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("http get: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("response: %s", resp.Status) + } + + _, err = io.Copy(dst, resp.Body) + if err != nil { + return fmt.Errorf("copy: %w", err) + } + + return nil +} + +// get downloads the specified Go package into the provided GOPATH, +// checking out the specified revision if non-empty. +func (u *upstream) get(gopath, repo, rev string) error { + done := make(chan struct{}) + defer close(done) + go progressSize("go get", filepath.Join(gopath, "src"), done) + + rr, err := vcs.RepoRootForImportPath(repo, false) + if err != nil { + return fmt.Errorf("get repo root: %w", err) + } + u.rr = rr + dir := filepath.Join(gopath, "src", rr.Root) + if rev != "" { + // Run "git clone {repo} {dir}" and "git checkout {tag}" + return rr.VCS.CreateAtRev(dir, rr.Repo, rev) + } + // Run "git clone {repo} {dir}" (or the equivalent command for hg, svn, bzr) + return rr.VCS.Create(dir, rr.Repo) +} + +func (u *upstream) tarballUrl() (string, error) { + repo := strings.TrimSuffix(u.rr.Repo, ".git") + repoU, err := url.Parse(repo) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + + switch repoU.Host { + case "github.com": + return fmt.Sprintf("%s/archive/%s.tar.%s", + repo, u.tag, u.compression), nil + case "gitlab.com", "salsa.debian.org": + parts := strings.Split(repoU.Path, "/") + if len(parts) < 3 { + return "", fmt.Errorf("incomplete repo URL: %s", u.rr.Repo) + } + project := parts[2] + return fmt.Sprintf("%s/-/archive/%s/%s-%s.tar.%s", + repo, u.tag, project, u.tag, u.compression), nil + case "git.sr.ht": + return fmt.Sprintf("%s/archive/%s.tar.%s", + repo, u.tag, u.compression), nil + case "codeberg.org": + return fmt.Sprintf("%s/archive/%s.tar.%s", + repo, u.tag, u.compression), nil + default: + return "", errUnsupportedHoster + } +} + +func (u *upstream) tarballFromHoster() error { + tarURL, err := u.tarballUrl() + if err != nil { + return err + } + + done := make(chan struct{}) + go progressSize("Download", u.tarPath, done) + + log.Printf("Downloading %s", tarURL) + err = downloadFile(u.tarPath, tarURL) + + close(done) + + return err +} + +func (u *upstream) tar(gopath, repo string) error { + f, err := os.CreateTemp("", "pack-tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + u.tarPath = f.Name() + f.Close() + + if u.isRelease { + if u.hasGodeps { + log.Printf("Godeps/_workspace exists, not downloading tarball from hoster.") + } else { + u.compression = "gz" + if err := u.tarballFromHoster(); err == nil { + return nil + } else if err == errUnsupportedHoster { + log.Printf("INFO: Hoster does not provide release tarball\n") + } else { + return fmt.Errorf("tarball from hoster: %w", err) + } + } + } + + u.compression = "xz" + base := filepath.Base(repo) + log.Printf("Generating temp tarball as %q\n", u.tarPath) + dir := filepath.Dir(repo) + cmd := exec.Command( + "tar", + "cJf", + u.tarPath, + "--exclude=.git", + "--exclude=Godeps/_workspace", + base) + cmd.Dir = filepath.Join(gopath, "src", dir) + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// findMains finds main packages within the repo (useful to auto-detect the +// package type). +func (u *upstream) findMains(gopath, repo string) error { + cmd := exec.Command("go", "list", "-e", "-f", "{{.ImportPath}} {{.Name}}", repo+"/...") + cmd.Dir = filepath.Join(gopath, "src", repo) + cmd.Env = passthroughEnv() + cmd.Stderr = os.Stderr + log.Println("findMains: Running", cmd, "in", cmd.Dir) + out, err := cmd.Output() + if err != nil { + log.Println("WARNING: In findMains:", fmt.Errorf("%q: %w", cmd.Args, err)) + log.Printf("Retrying without appending \"/...\" to repo") + cmd = exec.Command("go", "list", "-e", "-f", "{{.ImportPath}} {{.Name}}", repo) + cmd.Dir = filepath.Join(gopath, "src", repo) + cmd.Env = passthroughEnv() + cmd.Stderr = os.Stderr + log.Println("findMains: Running", cmd, "in", cmd.Dir) + out, err = cmd.Output() + if err != nil { + log.Println("WARNING: In findMains:", fmt.Errorf("%q: %w", cmd.Args, err)) + } + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.Contains(line, "/vendor/") || + strings.Contains(line, "/Godeps/") || + strings.Contains(line, "/samples/") || + strings.Contains(line, "/examples/") || + strings.Contains(line, "/example/") { + continue + } + if strings.HasSuffix(line, " main") { + u.firstMain = strings.TrimSuffix(line, " main") + break + } + } + return nil +} + +func (u *upstream) findDependencies(gopath, repo string) error { + log.Printf("Determining dependencies\n") + + cmd := exec.Command("go", "list", "-e", "-f", "{{join .Imports \"\\n\"}}\n{{join .TestImports \"\\n\"}}\n{{join .XTestImports \"\\n\"}}", repo+"/...") + cmd.Dir = filepath.Join(gopath, "src", repo) + cmd.Env = passthroughEnv() + cmd.Stderr = os.Stderr + + out, err := cmd.Output() + if err != nil { + log.Println("WARNING: In findDependencies:", fmt.Errorf("%q: %w", cmd.Args, err)) + // See https://bugs.debian.org/992610 + log.Printf("Retrying without appending \"/...\" to repo") + cmd = exec.Command("go", "list", "-e", "-f", "{{join .Imports \"\\n\"}}\n{{join .TestImports \"\\n\"}}\n{{join .XTestImports \"\\n\"}}", repo) + cmd.Dir = filepath.Join(gopath, "src", repo) + cmd.Env = passthroughEnv() + cmd.Stderr = os.Stderr + out, err = cmd.Output() + if err != nil { + log.Println("WARNING: In findDependencies:", fmt.Errorf("%q: %w", cmd.Args, err)) + } + } + + godependencies := make(map[string]bool) + for _, p := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if p == "" { + continue // skip separators between import types + } + // Strip packages that are included in the repository we are packaging. + if strings.HasPrefix(p, repo+"/") || p == repo { + continue + } + if p == "C" { + // TODO: maybe parse the comments to figure out C deps from pkg-config files? + } else { + godependencies[p] = true + } + } + + if len(godependencies) == 0 { + return nil + } + + // Remove all packages which are in the standard lib. + cmd = exec.Command("go", "list", "std") + cmd.Stderr = os.Stderr + cmd.Env = passthroughEnv() + + out, err = cmd.Output() + if err != nil { + return fmt.Errorf("go list std: (args: %v): %w", cmd.Args, err) + } + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + delete(godependencies, line) + } + + // Resolve all packages to the root of their repository. + roots := make(map[string]bool) + for dep := range godependencies { + rr, err := vcs.RepoRootForImportPath(dep, false) + if err != nil { + log.Printf("Could not determine repo path for import path %q: %v\n", dep, err) + continue + } + + roots[rr.Root] = true + } + + u.repoDeps = make([]string, 0, len(godependencies)) + for root := range roots { + u.repoDeps = append(u.repoDeps, root) + } + + return nil +} + +// makeUpstreamSourceTarball downloads the specified Go package from the Internet, +// checks out the specified revision, determines the version number, removes +// vendored dependencies, and creates a tarball of the upstream source code. +// It returns an upstream struct describing the downloaded package. +func makeUpstreamSourceTarball(repo, revision string, forcePrerelease bool) (*upstream, error) { + gopath, err := os.MkdirTemp("", "pack-tmp") + if err != nil { + return nil, fmt.Errorf("create tmp dir: %w", err) + } + defer os.RemoveAll(gopath) + repoDir := filepath.Join(gopath, "src", repo) + + var u upstream + + log.Printf("Downloading %q\n", repo+"/...") + if err := u.get(gopath, repo, revision); err != nil { + return nil, fmt.Errorf("go get: %w", err) + } + + // Verify early this repository uses git (we call pkgVersionFromGit later): + if _, err := os.Stat(filepath.Join(repoDir, ".git")); os.IsNotExist(err) { + return nil, fmt.Errorf("not a git repository; This program currently only supports git") + } + + if _, err := os.Stat(filepath.Join(repoDir, "debian")); err == nil { + log.Printf("WARNING: ignoring debian/ directory that came with the upstream sources\n") + } + + u.vendorDirs, err = findVendorDirs(repoDir) + if err != nil { + return nil, fmt.Errorf("find vendor dirs: %w", err) + } + if len(u.vendorDirs) > 0 { + log.Printf("Deleting upstream vendor/ directories") + for _, dir := range u.vendorDirs { + if err := os.RemoveAll(filepath.Join(repoDir, dir)); err != nil { + return nil, fmt.Errorf("remove all: %w", err) + } + } + } + + if _, err := os.Stat(filepath.Join(repoDir, "Godeps", "_workspace")); !os.IsNotExist(err) { + log.Println("Godeps/_workspace detected") + u.hasGodeps = true + } + + log.Printf("Determining upstream version number\n") + + u.version, err = pkgVersionFromGit(repoDir, &u, revision, forcePrerelease) + if err != nil { + return nil, fmt.Errorf("get package version from Git: %w", err) + } + + log.Printf("Package version is %q\n", u.version) + + if err := u.findMains(gopath, repo); err != nil { + return nil, fmt.Errorf("find mains: %w", err) + } + + if err := u.findDependencies(gopath, repo); err != nil { + return nil, fmt.Errorf("find dependencies: %w", err) + } + + if err := u.tar(gopath, repo); err != nil { + return nil, fmt.Errorf("tar: %w", err) + } + + return &u, nil +} + +func createDirectory(openRuyiSrc string) (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get cwd: %w", err) + } + dir := filepath.Join(wd, openRuyiSrc) + if err := os.Mkdir(dir, 0755); err != nil { + return "", fmt.Errorf("mkdir: %w", err) + } + return dir, nil +} + +// Package names (both source and binary, see Package, Section 5.6.7) must +// consist only of lower case letters (a-z), digits (0-9), plus (+) and minus +// (-) signs, and periods (.). They must be at least two characters long and +// must start with an alphanumeric character. +func normalizePackageName(str string) string { + lowerDigitPlusMinusDot := func(r rune) rune { + switch { + case r >= 'a' && r <= 'z' || '0' <= r && r <= '9': + return r + case r >= 'A' && r <= 'Z': + return r + ('a' - 'A') + case r == '.' || r == '+' || r == '-': + return r + case r == '_': + return '-' + } + return -1 + } + + safe := strings.Trim(strings.Map(lowerDigitPlusMinusDot, str), "-") + if len(safe) < 2 { + return "TODO" + } + + return safe +} + +// shortHostName maps a Go package import path to a canonical short hostname +func shortHostName(gopkg string, allowUnknownHoster bool) (host string, err error) { + knownHosts := map[string]string{ + // keep the list in alphabetical order + "bazil.org": "bazil", + "bitbucket.org": "bitbucket", + "blitiri.com.ar": "blitiri", + "cloud.google.com": "googlecloud", + "code.google.com": "googlecode", + "codeberg.org": "codeberg", + "filippo.io": "filippo", + "fortio.org": "fortio", + "fyne.io": "fyne", + "git.sr.ht": "sourcehut", + "github.com": "github", + "gitlab.com": "gitlab", + "go.bug.st": "bugst", + "go.cypherpunks.ru": "cypherpunks", + "go.mongodb.org": "mongodb", + "go.opentelemetry.io": "opentelemetry", + "go.step.sm": "step", + "go.uber.org": "uber", + "go4.org": "go4", + "gocloud.dev": "gocloud", + "golang.org": "golang", + "google.golang.org": "google", + "gopkg.in": "gopkg", + "honnef.co": "honnef", + "howett.net": "howett", + "k8s.io": "k8s", + "modernc.org": "modernc", + "pault.ag": "pault", + "rsc.io": "rsc", + "salsa.debian.org": "debian", + "sigs.k8s.io": "k8s-sigs", + "software.sslmate.com": "sslmate", + "zgo.at": "zgoat", + } + parts := strings.Split(gopkg, "/") + fqdn := parts[0] + if host, ok := knownHosts[fqdn]; ok { + return host, nil + } + if !allowUnknownHoster { + return "", fmt.Errorf("unknown hoster %q", fqdn) + } + suffix, _ := publicsuffix.PublicSuffix(fqdn) + host = fqdn[:len(fqdn)-len(suffix)-len(".")] + log.Printf("WARNING: Using %q as canonical hostname for %q. If that is not okay, please file a bug against %s.\n", host, fqdn, os.Args[0]) + return host, nil +} + +// nameFromGopkg maps a Go package import path to a openRuyi package name. +// e.g. "golang.org/x/text" → "go-golang-x-text". +// This follows https://fedoraproject.org/wiki/PackagingDrafts/Go#Package_Names +func nameFromGopkg(gopkg string, t packageType, customProgPkgName string, allowUnknownHoster bool) string { + parts := strings.Split(gopkg, "/") + + if t == typeProgram || t == typeProgramLibrary { + if customProgPkgName != "" { + return normalizePackageName(customProgPkgName) + } + return normalizePackageName(parts[len(parts)-1]) + } + + host, err := shortHostName(gopkg, allowUnknownHoster) + if err != nil { + log.Fatalf("Cannot derive package name: %v. See -help output for -allow_unknown_hoster\n", err) + } + parts[0] = host + + return normalizePackageName("go-" + strings.Join(parts, "-")) +} + +func copyFile(src, dest string) error { + input, err := os.Open(src) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer input.Close() + + output, err := os.Create(dest) + if err != nil { + return fmt.Errorf("create: %w", err) + } + if _, err := io.Copy(output, input); err != nil { + return fmt.Errorf("copy: %w", err) + } + return output.Close() +} + +// mainPack is the entry point for the "pack" command. +func mainPack(args []string, usage func()) { + flagSet := flag.NewFlagSet("pack", flag.ExitOnError) + if usage != nil { + flagSet.Usage = usage + } else { + flagSet.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [pack] [FLAG]... \n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Example: %s pack golang.org/x/oauth2\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "\"%s pack\" downloads the specified Go package from the Internet,\nand creates new files and directories in the current working directory.\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flagSet.PrintDefaults() + } + } + + var gitRevision string + flagSet.StringVar(&gitRevision, + "git_revision", + "", + "git revision (see gitrevisions(7)) of the specified Go package\n"+ + "to check out, defaulting to the default behavior of git clone.\n"+ + "Useful in case you do not want to package e.g. current HEAD.") + + var forcePrerelease bool + flagSet.BoolVar(&forcePrerelease, + "force_prerelease", + false, + "Package @master or @tip instead of the latest tagged version") + + var pkgTypeString string + flagSet.StringVar(&pkgTypeString, + "type", + "", + "Set package type, one of:\n"+ + ` * "library" (aliases: "lib", "l", "dev")`+"\n"+ + ` * "program" (aliases: "prog", "p")`+"\n"+ + ` * "library+program" (aliases: "lib+prog", "l+p", "both")`+"\n"+ + ` * "program+library" (aliases: "prog+lib", "p+l", "combined")`) + + var customProgPkgName string + flagSet.StringVar(&customProgPkgName, + "program_package_name", + "", + "Override the program package name, and the source package name too\n"+ + "when appropriate, e.g. to name github.com/cli/cli as \"gh\"") + + var allowUnknownHoster bool + flagSet.BoolVar(&allowUnknownHoster, + "allow_unknown_hoster", + false, + "The pkg-go naming conventions use a canonical identifier for\n"+ + "the hostname (see https://go-team.pages.debian.net/packaging.html),\n"+ + "and the mapping is hardcoded into dh-make-golang.\n"+ + "In case you want to package a Go package living on an unknown hoster,\n"+ + "you may set this flag to true and double-check that the resulting\n"+ + "package name is sane. Contact pkg-go if unsure.") + + // Actual pack starts here + + // Parse flags + err := flagSet.Parse(args) + if err != nil { + log.Fatalf("parse args: %v", err) + } + + // Check for required positional argument + if flagSet.NArg() < 1 { + flagSet.Usage() + os.Exit(1) + } + + gitRevision = strings.TrimSpace(gitRevision) + gopkg := flagSet.Arg(0) + + // Verify that the provided argument is a valid Go package import path + rr, err := vcs.RepoRootForImportPath(gopkg, false) + if err != nil { + log.Fatalf("Verifying arguments: %v — did you specify a Go package import path?", err) + } + if gopkg != rr.Root { + log.Printf("Continuing with repository root %q instead of specified import path %q", rr.Root, gopkg) + gopkg = rr.Root + } + + // Set default source and binary package names. + openRuyiSrc := nameFromGopkg(gopkg, typeLibrary, customProgPkgName, allowUnknownHoster) + openRuyiLib := openRuyiSrc + "-devel" + openRuyiProgram := nameFromGopkg(gopkg, typeProgram, customProgPkgName, allowUnknownHoster) + + var pkgType packageType + + switch strings.TrimSpace(pkgTypeString) { + case "", "guess": + pkgType = typeGuess + case "library", "lib", "l", "dev": + pkgType = typeLibrary + case "program", "prog", "p": + pkgType = typeProgram + case "library+program", "lib+prog", "l+p", "both": + // Example packages: go-github-alecthomas-chroma, + // go-github-tdewolff-minify, go-github-spf13-viper + pkgType = typeLibraryProgram + case "program+library", "prog+lib", "p+l", "combined": + // Example package: hugo + pkgType = typeProgramLibrary + default: + log.Fatalf("-type=%q not recognized, aborting\n", pkgTypeString) + } + + if pkgType != typeGuess { + openRuyiSrc = nameFromGopkg(gopkg, pkgType, customProgPkgName, allowUnknownHoster) + if _, err := os.Stat(openRuyiSrc); err == nil { + log.Fatalf("Output directory %q already exists, aborting\n", openRuyiSrc) + } + } + + if strings.ToLower(gopkg) != gopkg { + // Without -git_revision, specifying the package name in the wrong case + // will lead to two checkouts, i.e. wasting bandwidth. With + // -git_revision, packaging might fail. + // + // In case it turns out that Go package names should never contain any + // uppercase letters, we can just auto-convert the argument. + log.Printf("WARNING: Go package names are case-sensitive. Did you really mean %q instead of %q?\n", + gopkg, strings.ToLower(gopkg)) + } + + // Create a tarball of the upstream source + u, err := makeUpstreamSourceTarball(gopkg, gitRevision, forcePrerelease) + if err != nil { + log.Fatalf("Could not create a tarball of the upstream source: %v\n", err) + } + + if pkgType == typeGuess { + if u.firstMain != "" { + log.Printf("Assuming you are packaging a program (because %q defines a main package), use -type to override\n", u.firstMain) + pkgType = typeProgram + openRuyiSrc = nameFromGopkg(gopkg, pkgType, customProgPkgName, allowUnknownHoster) + } else { + pkgType = typeLibrary + } + } + + if _, err := os.Stat(openRuyiSrc); err == nil { + log.Fatalf("Output directory %q already exists, aborting\n", openRuyiSrc) + } + + orig := fmt.Sprintf("%s_%s.orig.tar.%s", openRuyiSrc, u.version, u.compression) + log.Printf("Moving tempfile to %q\n", orig) + // We need to copy the file, merely renaming is not enough since the file + // might be on a different filesystem (/tmp often is a tmpfs). + if err := copyFile(u.tarPath, orig); err != nil { + log.Fatalf("Could not rename orig tarball from %q to %q: %v\n", u.tarPath, orig, err) + } + if err := os.Remove(u.tarPath); err != nil { + log.Printf("Could not remove tempfile %q: %v\n", u.tarPath, err) + } + + // Now we create our rpm spec file from the gathered information. + dir, err := createDirectory(openRuyiSrc) + if err != nil { + log.Fatalf("Could not create repository: %v\n", err) + } + + seen := make(map[string]bool) + pkgdependencies := make([]string, 0, len(u.repoDeps)) + for _, dep := range u.repoDeps { + pkgname := nameFromGopkg(dep, typeLibrary, "", allowUnknownHoster) + "-dev" + if !seen[pkgname] { + seen[pkgname] = true + pkgdependencies = append(pkgdependencies, pkgname) + } + } + // optional: sort.Strings(debdependencies) + + if err := writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, u.version, + pkgType, pkgdependencies, u); err != nil { + log.Fatalf("Could not create spec file: %v\n", err) + } + + log.Println("Done!") + + fmt.Printf("\n") + fmt.Printf("Packaging successfully created in %s\n", dir) + switch pkgType { + case typeLibrary: + fmt.Printf(" Binary: %s\n", openRuyiLib) + case typeProgram: + fmt.Printf(" Binary: %s\n", openRuyiProgram) + case typeLibraryProgram: + fmt.Printf(" Binary: %s\n", openRuyiLib) + fmt.Printf(" Binary: %s\n", openRuyiProgram) + case typeProgramLibrary: + fmt.Printf(" Binary: %s\n", openRuyiProgram) + fmt.Printf(" Binary: %s\n", openRuyiLib) + } + fmt.Printf("\n") +} diff --git a/progress.go b/progress.go new file mode 100644 index 0000000..e385bce --- /dev/null +++ b/progress.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "github.com/mattn/go-isatty" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + _ = 1 << (10 * iota) + Kibi + Mebi + Gibi + Tebi +) + +func humanizeBytes(b int64) string { + if b > Tebi { + return fmt.Sprintf("%.2f TiB", float64(b)/float64(Tebi)) + } else if b > Gibi { + return fmt.Sprintf("%.2f GiB", float64(b)/float64(Gibi)) + } else if b > Mebi { + return fmt.Sprintf("%.2f MiB", float64(b)/float64(Mebi)) + } else { + return fmt.Sprintf("%.2f KiB", float64(b)/float64(Kibi)) + } +} + +func progressSize(prefix, path string, done chan struct{}) { + // previous holds how many bytes the previous line contained + // so that we can clear it in its entirety. + var previous int + tty := isatty.IsTerminal(os.Stdout.Fd()) + for { + if tty { + var usage int64 + filepath.Walk(path, func(path string, info os.FileInfo, err error) error { + if err == nil && info.Mode().IsRegular() { + usage += info.Size() + } + return nil + }) + fmt.Printf("\r%s", strings.Repeat(" ", previous)) + previous, _ = fmt.Printf("\r%s: %s", prefix, humanizeBytes(usage)) + } + + select { + case <-done: + fmt.Printf("\r") + return + case <-time.After(250 * time.Millisecond): + break + } + } +} diff --git a/spec.go b/spec.go new file mode 100644 index 0000000..fa6dacc --- /dev/null +++ b/spec.go @@ -0,0 +1,340 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +func writeSpec(dir, gopkg, openRuyiSrc, openRuyiLib, openRuyiProgram, version string, + pkgType packageType, dependencies []string, u *upstream) error { + + f, err := os.Create(filepath.Join(dir, "", openRuyiSrc+".spec")) + if err != nil { + return err + } + defer f.Close() + + description, err := getDescriptionForGopkg(gopkg) + if err != nil { + log.Printf("Could not determine description for %q: %v\n", gopkg, err) + description = "TODO: short description" + } + longdescription, err := getLongDescriptionForGopkg(gopkg) + if err != nil { + log.Printf("Could not determine long description for %q: %v\n", gopkg, err) + longdescription = "TODO: long description" + } + longdescription = convertLongDescriptionForRPM(longdescription) + license, err := getLicenseForGopkg(gopkg) + if err != nil { + log.Printf("Could not determine license for %q: %v\n", gopkg, err) + license = "TODO" + } + upstreamName := filepath.Base(gopkg) + if regexp.MustCompile(`^v\d+$`).MatchString(upstreamName) { + upstreamName = filepath.Base(filepath.Dir(gopkg)) + } + owner, repo, err := findGitHubRepo(gopkg) + if err != nil { + owner = "TODO" + repo = "TODO" + } + + // Write the spec file content + + // Macros + fmt.Fprintf(f, "%%define _name %s\n", upstreamName) + fmt.Fprintf(f, "%%define go_import_path %s\n", gopkg) + fmt.Fprintf(f, "\n") + + // Header + fmt.Fprintf(f, "Name: %s\n", openRuyiSrc) + fmt.Fprintf(f, "Version: %s\n", version) + fmt.Fprintf(f, "Release: %autorelease\n") + fmt.Fprintf(f, "Summary: %s\n", description) + fmt.Fprintf(f, "License: %s\n", license) + fmt.Fprintf(f, "URL: https://github.com/%s/%s\n", owner, repo) + fmt.Fprintf(f, "Source0: https://github.com/%s/%s/archive/v%%{version}.tar.gz#/%%{_name}-%%{version}.tar. gz\n", owner, repo) + + fmt.Fprintf(f, "BuildRequires: go\n") + // And other BuildRequires from dependencies + rpmDeps := convertDependenciesToRPM(dependencies) + sort.Strings(rpmDeps) + for _, dep := range rpmDeps { + fmt.Fprintf(f, "BuildRequires: %s\n", dep) + } + fmt.Fprintf(f, "\n") + + // For different package types, write different sections + switch pkgType { + case typeLibrary: + writeRPMLibraryPackage(f, gopkg, openRuyiLib, longdescription, rpmDeps) + //case typeProgram: + // TODO: what can this be used for? ExclusiveArch %%{go_arches}? + // writeRPMProgramPackage(f, gopkg, openRuyiProgram, longdescription) + case typeLibraryProgram: + writeRPMLibraryPackage(f, gopkg, openRuyiLib, longdescription, rpmDeps) + writeRPMProgramSubpackage(f, gopkg, openRuyiProgram, openRuyiSrc, description) + case typeProgramLibrary: + //writeRPMProgramPackage(f, gopkg, openRuyiProgram, longdescription) + writeRPMLibrarySubpackage(f, gopkg, openRuyiLib, openRuyiSrc, longdescription, rpmDeps) + default: + log.Fatalf("Invalid pkgType %d in writeRPMSpec(), aborting", pkgType) + } + + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "%%description\n") + fmt.Fprintf(f, "%s\n", longdescription) + fmt.Fprintf(f, "\n") + + // %prep + fmt.Fprintf(f, "%%prep\n") + fmt.Fprintf(f, "%%autosetup -n %%{_name}-%%{version}\n") + if len(u.vendorDirs) > 0 { + fmt.Fprintf(f, "# Remove bundled dependencies\n") + for _, vdir := range u.vendorDirs { + fmt.Fprintf(f, "rm -rf %s\n", vdir) + } + } + if u.hasGodeps { + fmt.Fprintf(f, "rm -rf Godeps/_workspace\n") + } + fmt.Fprintf(f, "\n") + + // %build + fmt.Fprintf(f, "%%build\n") + switch pkgType { + case typeLibrary: + fmt.Fprintf(f, "# Library package - no build needed\n") + case typeProgram, typeLibraryProgram, typeProgramLibrary: + fmt.Fprintf(f, "export GOPATH=%%{_builddir}/go\n") + fmt.Fprintf(f, "export GOFLAGS=\"-buildmode=pie -trimpath -mod=readonly -modcacherw\"\n") + fmt.Fprintf(f, "export CGO_CFLAGS=\"${CFLAGS:-%%optflags}\"\n") + fmt.Fprintf(f, "export CGO_CXXFLAGS=\"${CXXFLAGS:-%%optflags}\"\n") + fmt.Fprintf(f, "export CGO_LDFLAGS=\"${LDFLAGS:-%%__global_ldflags}\"\n") + fmt.Fprintf(f, "go build -v -o %%{_name} .\n") + } + fmt.Fprintf(f, "\n") + + // %install + fmt.Fprintf(f, "%%install\n") + switch pkgType { + case typeLibrary: + fmt.Fprintf(f, "# Install source code for library package\n") + fmt.Fprintf(f, "install -d %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}\n") + fmt.Fprintf(f, "cp -pav *. go go. mod go.sum %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}/ || :\n") + fmt.Fprintf(f, "find . -mindepth 1 -type d -not -path './.*' -exec install -d %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}/{} \\;\n") + fmt.Fprintf(f, "find . -type f -name '*.go' -exec install -m 0644 {} %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}/{} \\;\n") + case typeProgram: + fmt.Fprintf(f, "install -D -m 0755 %%{_name} %%{buildroot}%%{_bindir}/%%{_name}\n") + case typeLibraryProgram, typeProgramLibrary: + fmt.Fprintf(f, "# Install binary\n") + fmt.Fprintf(f, "install -D -m 0755 %%{_name} %%{buildroot}%%{_bindir}/%%{_name}\n") + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "# Install source code for library package\n") + fmt.Fprintf(f, "install -d %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}\n") + fmt.Fprintf(f, "cp -pav *.go go.mod go.sum %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}/ || :\n") + fmt.Fprintf(f, "find . -mindepth 1 -type d -not -path './.*' -exec install -d %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}/{} \\;\n") + fmt.Fprintf(f, "find . -type f -name '*.go' -exec install -m 0644 {} %%{buildroot}%%{_datadir}/gocode/src/%%{go_import_path}/{} \\;\n") + } + fmt.Fprintf(f, "\n") + + // %check + fmt.Fprintf(f, "%%check\n") + fmt.Fprintf(f, "export GOPATH=%%{_builddir}/go\n") + fmt.Fprintf(f, "go test -v ./...\n") + fmt.Fprintf(f, "\n") + + // %files + writeRPMFilesSection(f, openRuyiSrc, openRuyiLib, openRuyiProgram, pkgType) + + // %changelog + fmt.Fprintf(f, "%%changelog\n") + fmt.Fprintf(f, "%%{?autochangelog}\n") + + return nil +} + +// For library package +func writeRPMLibraryPackage(f *os.File, gopkg, openRuyiLib, longdesc string, deps []string) { + fmt.Fprintf(f, "BuildArch: noarch\n") + fmt.Fprintf(f, "\n") + + // 库包的运行时依赖 + if len(deps) > 0 { + for _, dep := range deps { + fmt.Fprintf(f, "Requires: %s\n", dep) + } + fmt.Fprintf(f, "\n") + } +} + +// For library subpackage +func writeRPMLibrarySubpackage(f *os.File, gopkg, openRuyiLib, openRuyiSrc, longdesc string, deps []string) { + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "%%package -n %s\n", openRuyiLib) + fmt.Fprintf(f, "Summary: Development files of %s\n", filepath.Base(gopkg)) + fmt.Fprintf(f, "BuildArch: noarch\n") + for _, dep := range deps { + fmt.Fprintf(f, "Requires: %s\n", dep) + } + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "%%description -n %s\n", openRuyiLib) + fmt.Fprintf(f, "%s\n", longdesc) + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "This package provides the Go source files of %s for development.\n") +} + +// For program subpackage +func writeRPMProgramSubpackage(f *os.File, gopkg, openRuyiProgram, openRuyiSrc, description string) { + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "%%package -n %s\n", openRuyiProgram) + fmt.Fprintf(f, "Summary: Executable of %s\n", filepath.Base(gopkg)) + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "%%description -n %s\n", openRuyiProgram) + fmt.Fprintf(f, "%s\n", description) + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "This package contains the %s executable.\n", filepath.Base(gopkg)) +} + +func writeRPMFilesSection(f *os.File, openRuyiSrc, openRuyiLib, openRuyiProgram string, pkgType packageType) { + switch pkgType { + case typeLibrary: + fmt.Fprintf(f, "%%files\n") + fmt.Fprintf(f, "%%license LICENSE* COPYING*\n") + fmt.Fprintf(f, "%%doc README* CHANGELOG* HISTORY*\n") + fmt.Fprintf(f, "%%{_datadir}/gocode/src/%%{go_import_path}\n") + fmt.Fprintf(f, "\n") + + case typeProgram: + fmt.Fprintf(f, "%%files\n") + fmt.Fprintf(f, "%%license LICENSE* COPYING*\n") + fmt.Fprintf(f, "%%doc README* CHANGELOG* HISTORY*\n") + fmt.Fprintf(f, "%%{_bindir}/%%{_name}\n") + fmt.Fprintf(f, "\n") + + case typeLibraryProgram: + // 库包文件(主包) + fmt.Fprintf(f, "%%files\n") + fmt.Fprintf(f, "%%license LICENSE* COPYING*\n") + fmt.Fprintf(f, "%%doc README* CHANGELOG* HISTORY*\n") + fmt.Fprintf(f, "%%{_datadir}/gocode/src/%%{go_import_path}\n") + fmt.Fprintf(f, "\n") + // 程序子包文件 + fmt.Fprintf(f, "%%files -n %s\n", openRuyiProgram) + fmt.Fprintf(f, "%%license LICENSE* COPYING*\n") + fmt.Fprintf(f, "%%{_bindir}/%%{_name}\n") + fmt.Fprintf(f, "\n") + + case typeProgramLibrary: + // 程序主包文件 + fmt.Fprintf(f, "%%files\n") + fmt.Fprintf(f, "%%license LICENSE* COPYING*\n") + fmt.Fprintf(f, "%%doc README* CHANGELOG* HISTORY*\n") + fmt.Fprintf(f, "%%{_bindir}/%%{_name}\n") + fmt.Fprintf(f, "\n") + // 库子包文件 + fmt.Fprintf(f, "%%files -n %s\n", openRuyiLib) + fmt.Fprintf(f, "%%license LICENSE* COPYING*\n") + fmt.Fprintf(f, "%%{_datadir}/gocode/src/%%{go_import_path}\n") + fmt.Fprintf(f, "\n") + } +} + +func convertLongDescriptionForRPM(openRuyiSrc string) string { + // 移除 Debian 特有的格式 + lines := strings.Split(openRuyiSrc, "\n") + var result []string + for _, line := range lines { + // 移除行首空格 + line = strings.TrimPrefix(line, " ") + // 将 "." 单独行转换为空行 + if line == "." { + line = "" + } + result = append(result, line) + } + return strings.Join(result, "\n") +} + +// Sync with shortHostName() from pack.go +func getKnownHostsReverse() map[string]string { + return map[string]string{ + "bazil": "bazil.org", + "bitbucket": "bitbucket.org", + "blitiri": "blitiri. com. ar", + "googlecloud": "cloud.google.com", + "googlecode": "code.google.com", + "codeberg": "codeberg.org", + "filippo": "filippo.io", + "fortio": "fortio.org", + "fyne": "fyne.io", + "sourcehut": "git.sr.ht", + "github": "github.com", + "gitlab": "gitlab.com", + "bugst": "go.bug.st", + "cypherpunks": "go.cypherpunks.ru", + "mongodb": "go.mongodb.org", + "opentelemetry": "go.opentelemetry.io", + "step": "go.step.sm", + "uber": "go.uber.org", + "go4": "go4.org", + "gocloud": "gocloud.dev", + "golang": "golang.org", + "google": "google.golang.org", + "gopkg": "gopkg.in", + "honnef": "honnef. co", + "howett": "howett.net", + "k8s": "k8s.io", + "modernc": "modernc.org", + "pault": "pault.ag", + "rsc": "rsc.io", + "debian": "salsa.debian.org", + "k8s-sigs": "sigs.k8s.io", + "sslmate": "software.sslmate.com", + "zgoat": "zgo.at", + } +} + +func convertDependenciesToRPM(debDeps []string) []string { + knownHostsReverse := getKnownHostsReverse() + var rpmDeps []string + + for _, dep := range debDeps { + // 转换 golang-xxx-dev 为 golang(xxx) + if strings.HasPrefix(dep, "golang-") && strings.HasSuffix(dep, "-dev") { + // golang-github-foo-bar-dev -> golang(github.com/foo/bar) + trimmed := strings.TrimPrefix(dep, "golang-") + trimmed = strings.TrimSuffix(trimmed, "-dev") + + // 尝试还原 Go 包路径 + parts := strings.Split(trimmed, "-") + if len(parts) >= 2 { + shortHost := parts[0] + + // 使用 knownHosts 反向映射查找完整主机名 + var fullHost string + if fqdn, ok := knownHostsReverse[shortHost]; ok { + fullHost = fqdn + } else { + // 未知主机,保持原样(可能是自定义域名) + fullHost = shortHost + log.Printf("WARNING: Unknown host shortname %q in dependency %q\n", shortHost, dep) + } + + importPath := fullHost + "/" + strings.Join(parts[1:], "/") + rpmDeps = append(rpmDeps, fmt.Sprintf("golang(%s)", importPath)) + } else { + rpmDeps = append(rpmDeps, fmt.Sprintf("golang(%s)", trimmed)) + } + } else { + rpmDeps = append(rpmDeps, dep) + } + } + return rpmDeps +}