commit 2ace98388dcb446adaf5a65fd4ee75c016f40dd2 Author: misaka00251 Date: Mon Dec 1 14:27:48 2025 +0800 Init project 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 +}