diff --git a/cmd/llpkgstore/internal/verification.go b/cmd/llpkgstore/internal/verification.go index 10b86c3..53f233c 100644 --- a/cmd/llpkgstore/internal/verification.go +++ b/cmd/llpkgstore/internal/verification.go @@ -9,6 +9,7 @@ import ( "github.com/goplus/llpkgstore/config" "github.com/goplus/llpkgstore/internal/actions" + "github.com/goplus/llpkgstore/internal/actions/env" "github.com/goplus/llpkgstore/internal/actions/generator/llcppg" "github.com/spf13/cobra" ) @@ -61,7 +62,7 @@ func runLLCppgVerification(_ *cobra.Command, _ []string) { } // output parsed path to Github Env for demotest b, _ := json.Marshal(&paths) - actions.Setenv(map[string]string{ + env.Setenv(env.Env{ "LLPKG_PATH": string(b), }) } diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 8f0deaf..2c3981d 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "os/exec" "path/filepath" "runtime" "slices" @@ -14,33 +13,20 @@ import ( "sync" "github.com/goplus/llpkgstore/config" - "github.com/goplus/llpkgstore/internal/actions/versions" + "github.com/goplus/llpkgstore/internal/actions/mappingtable" + "github.com/goplus/llpkgstore/internal/actions/parser/prefix" + "github.com/goplus/llpkgstore/internal/actions/version" "golang.org/x/mod/semver" ) -// GitHubEvent caches parsed GitHub event data from GITHUB_EVENT_PATH -var GitHubEvent = sync.OnceValue(parseGitHubEvent) +var ( + // GitHubEvent caches parsed GitHub event data from GITHUB_EVENT_PATH + GitHubEvent = sync.OnceValue(parseGitHubEvent) -// In our previous design, each platform should generate *_{OS}_{Arch}.go file -// Feb 12th, this design revoked, still keep the code. -var currentSuffix = runtime.GOOS + "_" + runtime.GOARCH - -// must panics if the error is non-nil, halting execution -func must(err error) { - if err != nil { - panic(err) - } -} - -// envToString converts environment variables map to newline-separated key=value pairs for GitHub Actions -func envToString(envm map[string]string) string { - var env []string - - for name, value := range envm { - env = append(env, fmt.Sprintf("%s=%s", name, value)) - } - return strings.Join(env, "\n") -} + // In our previous design, each platform should generate *_{OS}_{Arch}.go file + // Feb 12th, this design revoked, still keep the code. + _currentSuffix = runtime.GOOS + "_" + runtime.GOARCH +) // parseGitHubEvent parses the GitHub event payload from GITHUB_EVENT_PATH into a map func parseGitHubEvent() map[string]any { @@ -49,9 +35,8 @@ func parseGitHubEvent() map[string]any { panic("cannot get GITHUB_EVENT_PATH") } event, err := os.ReadFile(eventFileName) - if err != nil { - panic(err) - } + must(err) + var m map[string]any json.Unmarshal([]byte(event), &m) @@ -61,27 +46,15 @@ func parseGitHubEvent() map[string]any { return m } -// PullRequestEvent extracts pull request details from the parsed GitHub event data -func PullRequestEvent() map[string]any { - pullRequest, ok := GitHubEvent()["pull_request"].(map[string]any) - if !ok { - panic("cannot parse GITHUB_EVENT_PATH pull_request") - } - return pullRequest -} - -// IssueEvent retrieves issue-related information from the GitHub event payload -func IssueEvent() map[string]any { - issue, ok := GitHubEvent()["issue"].(map[string]any) - if !ok { - panic("cannot parse GITHUB_EVENT_PATH pull_request") +// must panics if the error is non-nil, halting execution +func must(err error) { + if err != nil { + panic(err) } - return issue } -// tagRef constructs full Git tag reference string (e.g. "refs/tags/v1.0.0") -func tagRef(tag string) string { - return "refs/tags/" + strings.TrimSpace(tag) +func binaryZip(packageName string) string { + return fmt.Sprintf("%s_%s.zip", packageName, _currentSuffix) } // branchRef generates full Git branch reference string (e.g. "refs/heads/main") @@ -89,36 +62,8 @@ func branchRef(branchName string) string { return "refs/heads/" + strings.TrimSpace(branchName) } -// hasTag checks if specified Git tag exists in repository -func hasTag(tag string) bool { - _, err := exec.Command("git", "rev-parse", tagRef(tag)).CombinedOutput() - return err == nil -} - -// shaFromTag retrieves commit SHA for given Git tag -// Panics if tag doesn't exist -func shaFromTag(tag string) string { - ret, err := exec.Command("git", "rev-list", "-n", "1", tag).CombinedOutput() - if err != nil { - log.Fatalf("cannot find a tag: %s %s", tag, string(ret)) - } - return strings.TrimSpace(string(ret)) -} - -// parseMappedVersion splits the mapped version string into library name and version. -// Input format: "clib/semver" where semver starts with 'v' -// Panics if input format is invalid or version isn't valid semantic version -func parseMappedVersion(version string) (clib, mappedVersion string) { - arr := strings.Split(version, "/") - if len(arr) != 2 { - panic("invalid mapped version format") - } - clib, mappedVersion = arr[0], arr[1] - - if !semver.IsValid(mappedVersion) { - panic("invalid mapped version format: mappedVersion is not a semver") - } - return +func isLegacyBranch(branchName string) bool { + return strings.HasPrefix(branchName, prefix.BranchPrefix) } // isValidLLPkg checks if directory contains both llpkg.cfg and llcppg.cfg @@ -133,21 +78,39 @@ func isValidLLPkg(files []os.DirEntry) bool { return hasLLCppg && hasLLPkg } +// PullRequestEvent extracts pull request details from the parsed GitHub event data +func PullRequestEvent() map[string]any { + pullRequest, ok := GitHubEvent()["pull_request"].(map[string]any) + if !ok { + panic("cannot parse GITHUB_EVENT_PATH pull_request") + } + return pullRequest +} + +// IssueEvent retrieves issue-related information from the GitHub event payload +func IssueEvent() map[string]any { + issue, ok := GitHubEvent()["issue"].(map[string]any) + if !ok { + panic("cannot parse GITHUB_EVENT_PATH pull_request") + } + return issue +} + // checkLegacyVersion validates versioning strategy for legacy package submissions // Ensures semantic versioning compliance and proper branch maintenance strategy -func checkLegacyVersion(ver *versions.Versions, cfg config.LLPkgConfig, mappedVersion string, isLegacy bool) { +func checkLegacyVersion(ver *mappingtable.Versions, cfg config.LLPkgConfig, mappedVersion string, isLegacy bool) { if slices.Contains(ver.GoVersions(cfg.Upstream.Package.Name), mappedVersion) { - panic("repeat semver") + log.Panicf("repeat semver: %s", mappedVersion) } vers := ver.CVersions(cfg.Upstream.Package.Name) - currentVersion := versions.ToSemVer(cfg.Upstream.Package.Version) + currentVersion := version.ToSemVer(cfg.Upstream.Package.Version) // skip when we're the only latest version or C version doesn't follow semver. if len(vers) == 0 || !semver.IsValid(currentVersion) { return } - sort.Sort(versions.ByVersionDescending(vers)) + sort.Sort(version.ByVersionDescending(vers)) latestVersion := vers[0] @@ -207,68 +170,3 @@ func checkLegacyVersion(ver *versions.Versions, cfg config.LLPkgConfig, mappedVe panic("mapped version should not less than the legacy one.") } } - -// Setenv writes environment variables to GITHUB_ENV for GitHub Actions consumption -func Setenv(envm map[string]string) { - env, err := os.OpenFile(os.Getenv("GITHUB_ENV"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) - // should never happen, - // it means current runtime is not Github actions if there's any errors - must(err) - - env.WriteString(envToString(envm)) - - // make sure we write it to the GITHUB_ENV - env.Close() -} - -// SetOutput writes workflow outputs to GITHUB_OUTPUT for GitHub Actions -func SetOutput(envm map[string]string) { - env, err := os.OpenFile(os.Getenv("GITHUB_OUTPUT"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) - must(err) - - env.WriteString(envToString(envm)) - - env.Close() -} - -// Changes returns the changed files in current PR, -// which depends on ALL_CHANGED_FILES generated by tj-actions/changed-files action, -// if there's no content in ALL_CHANGED_FILES, it panic. -func Changes() []string { - changes := os.Getenv("ALL_CHANGED_FILES") - if changes == "" { - panic("cannot find changes file!") - } - return strings.Fields(changes) -} - -// Repository returns owner and repository name for the current repository -// -// Example: goplus/llpkg, owner: goplus, repo: llpkg -// Repository extracts GitHub repository owner and name from GITHUB_REPOSITORY -func Repository() (owner, repo string) { - thisRepo := os.Getenv("GITHUB_REPOSITORY") - if thisRepo == "" { - panic("no github repo") - } - current := strings.Split(thisRepo, "/") - return current[0], current[1] -} - -// Token returns Github Token for current runner -func Token() string { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - panic("no GITHUB_TOKEN") - } - return token -} - -// LatestCommitSHA returns the current commit SHA from GITHUB_SHA environment variable -func LatestCommitSHA() string { - sha := os.Getenv("GITHUB_SHA") - if sha == "" { - panic("no GITHUB_SHA found") - } - return sha -} diff --git a/internal/actions/api.go b/internal/actions/api.go index 7905dcd..5f4aaa5 100644 --- a/internal/actions/api.go +++ b/internal/actions/api.go @@ -1,49 +1,31 @@ -// Package actions contains GitHub Actions helper functions for version management and repository operations. package actions import ( "context" "fmt" + "io" + "mime" "net/http" "os" "path/filepath" "regexp" "strings" + "sync" "time" "github.com/google/go-github/v69/github" "github.com/goplus/llpkgstore/config" - "github.com/goplus/llpkgstore/internal/actions/versions" + "github.com/goplus/llpkgstore/internal/actions/env" + "github.com/goplus/llpkgstore/internal/actions/mappingtable" + "github.com/goplus/llpkgstore/internal/actions/parser/mappedversion" + "github.com/goplus/llpkgstore/internal/actions/parser/prefix" + "github.com/goplus/llpkgstore/internal/actions/tag" + "github.com/goplus/llpkgstore/internal/actions/version" "github.com/goplus/llpkgstore/internal/file" "github.com/goplus/llpkgstore/internal/pc" ) -const ( - LabelPrefix = "branch:" - BranchPrefix = "release-branch." - MappedVersionPrefix = "Release-as: " - - defaultReleaseBranch = "main" - regexString = `Release-as:\s%s/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-]+)*))?` -) - -// regex compiles a regular expression pattern to detect "Release-as" directives in commit messages -// Parameters: -// -// packageName: Name of the package to format into the regex pattern -// -// Returns: -// -// *regexp.Regexp: Compiled regular expression for version parsing -func regex(packageName string) *regexp.Regexp { - // format: Release-as: clib/semver(with v prefix) - // Must have one space in the end of Release-as: - return regexp.MustCompile(fmt.Sprintf(regexString, packageName)) -} - -func binaryZip(packageName string) string { - return fmt.Sprintf("%s_%s.zip", packageName, currentSuffix) -} +const _defaultReleaseBranch = "main" // DefaultClient provides GitHub API client capabilities with authentication for Actions workflows type DefaultClient struct { @@ -56,29 +38,250 @@ type DefaultClient struct { } // NewDefaultClient initializes a new GitHub API client with authentication and repository configuration -// Uses: -// - GitHub token from environment -// - Repository info from GITHUB_REPOSITORY context -// -// Returns: -// -// *DefaultClient: Configured client instance func NewDefaultClient() *DefaultClient { dc := &DefaultClient{ - client: github.NewClient(nil).WithAuthToken(Token()), + client: github.NewClient(nil).WithAuthToken(env.Token()), } - dc.owner, dc.repo = Repository() + dc.owner, dc.repo = env.Repository() return dc } +// CheckPR validates PR changes and returns affected packages +func (d *DefaultClient) CheckPR() []string { + // build a file path map + pathMap := map[string][]string{} + for _, path := range env.Changes() { + dir := filepath.Dir(path) + // initialize the dir + pathMap[dir] = nil + } + + var allPaths []string + + ver := mappingtable.Read("llpkgstore.json") + + for path := range pathMap { + // don't retrieve files from pr changes, consider about maintenance case + files, _ := os.ReadDir(path) + + if !isValidLLPkg(files) { + delete(pathMap, path) + continue + } + // 3. Check directory name + llpkgFile := filepath.Join(path, "llpkg.cfg") + cfg, err := config.ParseLLPkgConfig(llpkgFile) + must(err) + // in our design, directory name should equal to the package name, + // which means it's not required to be equal. + // + // However, at the current stage, if this is not equal, conan may panic, + // to aovid unexpected behavior, we assert it's equal temporarily. + // this logic may be changed in the future. + packageName := strings.TrimSpace(cfg.Upstream.Package.Name) + if packageName != path { + panic("directory name is not equal to package name in llpkg.cfg") + } + d.checkVersion(ver, cfg) + + allPaths = append(allPaths, path) + } + + // 1. Check there's only one directory in PR + if len(pathMap) > 1 { + panic("too many to-be-converted directory") + } + + // 2. Check config files(llpkg.cfg and llcppg.cfg) + if len(pathMap) == 0 { + panic("no valid config files, llpkg.cfg and llcppg.cfg must exist") + } + + return allPaths +} + +// Postprocessing handles version tagging and record updates after PR merge. This MUST be called after the Release function. +// Functions include creating Git tags, updating version records in llpkgstore.json, and cleaning up legacy branches. +func (d *DefaultClient) Postprocessing() { + // https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#push + sha := env.LatestCommitSHA() + // check it's associated with a pr + if !d.isAssociatedWithPullRequest(sha) { + // not a merge commit, skip it. + panic("not a merge request commit") + } + + rawMappedVersion := d.findRawMappedVersion() + // skip it when no mapped version is found + if rawMappedVersion == "" { + panic("no mapped version found in the commit message") + } + + clib, mappedVersion := mappedversion.From(rawMappedVersion).MustParse() + + // the pr has merged, so we can read it. + cfg, err := config.ParseLLPkgConfig(filepath.Join(clib, "llpkg.cfg")) + must(err) + + // write it to llpkgstore.json + ver := mappingtable.Read("llpkgstore.json") + ver.Write(clib, cfg.Upstream.Package.Version, mappedVersion) + + versionTag := tag.From(rawMappedVersion) + + if versionTag.Exist() { + panic("tag has already existed") + } + + if err := d.createTag(versionTag, sha); err != nil { + panic(err) + } + + // create a release + release := d.createReleaseByTag(rawMappedVersion) + + d.uploadArtifactsToRelease(release) + + // we have finished tagging the commit, safe to remove the branch + if branchName, isLegacy := d.isLegacyVersion(); isLegacy { + d.removeBranch(branchName) + } + // move to website in Github Action... +} + +// Release prepares and uploads package artifacts for distribution. THIS FUNCTION MUST BE CALLED BEFORE Postprocessing. +// It generates package configuration files, creates a ZIP artifact, and sets environment variables for subsequent steps. +func (d *DefaultClient) Release() { + rawMappedVersion := d.findRawMappedVersion() + // skip it when no mapped version is found + if rawMappedVersion == "" { + panic("no mapped version found in the commit message") + } + + clib, _ := mappedversion.From(rawMappedVersion).MustParse() + // the pr has merged, so we can read it. + cfg, err := config.ParseLLPkgConfig(filepath.Join(clib, "llpkg.cfg")) + must(err) + + uc, err := config.NewUpstreamFromConfig(cfg.Upstream) + must(err) + + tempDir, _ := os.MkdirTemp("", "llpkg-tool") + + pkgConfigNames, err := uc.Installer.Install(uc.Pkg, tempDir) + must(err) + + pkgConfigDir := filepath.Join(tempDir, "lib", "pkgconfig") + // clear exist .pc + os.RemoveAll(pkgConfigDir) + + err = os.Mkdir(pkgConfigDir, 0777) + must(err) + + for _, pcName := range pkgConfigNames { + pcFile := filepath.Join(tempDir, pcName+".pc") + // generate pc template to lib/pkgconfig + err = pc.GenerateTemplateFromPC(pcFile, pkgConfigDir, pkgConfigNames) + must(err) + } + + // okay, safe to remove old pc + file.RemovePattern(filepath.Join(tempDir, "*.pc")) + file.RemovePattern(filepath.Join(tempDir, "*.sh")) + + zipFilename := binaryZip(uc.Pkg.Name) + zipFilePath, _ := filepath.Abs(zipFilename) + + err = file.Zip(tempDir, zipFilePath) + must(err) + + // upload to artifacts in GitHub Action + env.Setenv(env.Env{ + "BIN_PATH": zipFilePath, + "BIN_FILENAME": strings.TrimSuffix(zipFilename, ".zip"), + }) +} + +// CreateBranchFromLabel creates release branch based on label format +// Follows naming convention: release-branch./ +func (d *DefaultClient) CreateBranchFromLabel(labelName string) { + // design: branch:release-branch.{CLibraryName}/{MappedVersion} + branchName := prefix.NewLabelParser(labelName).MustParse() + + // fast-path: branch exists, can skip. + if d.hasBranch(branchName) { + return + } + rawMappedVersion := prefix.NewBranchParser(branchName).MustParse() + + clib, _ := mappedversion.From(rawMappedVersion).MustParse() + // slow-path: check the condition if we can create a branch + // + // create a branch only when this version is legacy. + // according to branch maintenance strategy + + // get latest version of the clib + ver := mappingtable.Read("llpkgstore.json") + + cversions := ver.CVersions(clib) + if len(cversions) == 0 { + panic("no clib found") + } + + if !version.IsSemver(cversions) { + panic("c version dones't follow semver, skip maintaining.") + } + + err := d.createBranch(branchName, tag.From(rawMappedVersion).SHA()) + must(err) +} + +// CleanResource removes labels and resources after issue resolution +// Verifies issue closure via PR merge before deletion +func (d *DefaultClient) CleanResource() { + issueEvent := IssueEvent() + + issueNumber := int(issueEvent["number"].(float64)) + regex := regexp.MustCompile(fmt.Sprintf(`(f|F)ix.*#%d`, issueNumber)) + + // 1. check this issue is closed by a PR + // In Github, close a issue with a commit whose message follows this format + // fix/Fix* #{IssueNumber} + found := false + for _, commit := range d.allCommits() { + message := commit.Commit.GetMessage() + + if regex.MatchString(message) && + d.isAssociatedWithPullRequest(commit.GetSHA()) { + found = true + break + } + } + + if !found { + panic("current issue isn't closed by merged PR.") + } + + var labelName string + + // 2. find out the branch name from the label + for _, labels := range issueEvent["labels"].([]map[string]any) { + label := labels["name"].(string) + + if strings.HasPrefix(label, prefix.BranchPrefix) { + labelName = label + break + } + } + + if labelName == "" { + panic("current issue hasn't labelled, this should not happen") + } + + d.removeLabel(labelName) +} + // hasBranch checks existence of a specific branch in the repository -// Parameters: -// -// branchName: Name of the branch to check -// -// Returns: -// -// bool: True if branch exists func (d *DefaultClient) hasBranch(branchName string) bool { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() @@ -92,13 +295,6 @@ func (d *DefaultClient) hasBranch(branchName string) bool { } // associatedWithPullRequest finds all pull requests containing the specified commit -// Parameters: -// -// sha: Commit hash to search for -// -// Returns: -// -// []*github.PullRequest: List of associated pull requests func (d *DefaultClient) associatedWithPullRequest(sha string) []*github.PullRequest { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() @@ -111,13 +307,6 @@ func (d *DefaultClient) associatedWithPullRequest(sha string) []*github.PullRequ } // isAssociatedWithPullRequest checks if commit belongs to a closed pull request -// Parameters: -// -// sha: Commit hash to check -// -// Returns: -// -// bool: True if part of closed PR func (d *DefaultClient) isAssociatedWithPullRequest(sha string) bool { pulls := d.associatedWithPullRequest(sha) // don't use GetMerge, because GetMerge may be a mistake. @@ -128,35 +317,26 @@ func (d *DefaultClient) isAssociatedWithPullRequest(sha string) bool { } // isLegacyVersion determines if PR targets a legacy branch -// Returns: -// -// branchName: Base branch name -// legacy: True if branch starts with "release-branch." func (d *DefaultClient) isLegacyVersion() (branchName string, legacy bool) { pullRequest, ok := GitHubEvent()["pull_request"].(map[string]any) - var refName string if !ok { // if this actions is not triggered by pull request, fallback to call API. - pulls := d.associatedWithPullRequest(LatestCommitSHA()) + pulls := d.associatedWithPullRequest(env.LatestCommitSHA()) if len(pulls) == 0 { panic("this commit is not associated with a pull request, this should not happen") } - refName = pulls[0].GetBase().GetRef() + branchName = pulls[0].GetBase().GetRef() } else { // unnecessary to check type, because currentPRCommit has been checked. base := pullRequest["base"].(map[string]any) - refName = base["ref"].(string) + branchName = base["ref"].(string) } - legacy = strings.HasPrefix(refName, BranchPrefix) - branchName = refName + legacy = isLegacyBranch(branchName) return } // currentPRCommit retrieves all commits in the current pull request -// Returns: -// -// []*github.RepositoryCommit: List of PR commits func (d *DefaultClient) currentPRCommit() []*github.RepositoryCommit { pullRequest := PullRequestEvent() prNumber := int(pullRequest["number"].(float64)) @@ -173,9 +353,6 @@ func (d *DefaultClient) currentPRCommit() []*github.RepositoryCommit { } // allCommits retrieves all repository commits -// Returns: -// -// []*github.RepositoryCommit: List of all commits func (d *DefaultClient) allCommits() []*github.RepositoryCommit { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() @@ -189,9 +366,6 @@ func (d *DefaultClient) allCommits() []*github.RepositoryCommit { } // removeLabel deletes a label from the repository -// Parameters: -// -// labelName: Name of the label to remove func (d *DefaultClient) removeLabel(labelName string) { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() @@ -203,43 +377,28 @@ func (d *DefaultClient) removeLabel(labelName string) { } // checkMappedVersion validates PR contains valid "Release-as" version declaration -// Parameters: -// -// packageName: Target package name for version mapping -// -// Returns: -// -// string: Validated mapped version string -// -// Panics: -// -// If no valid version found in PR commits -func (d *DefaultClient) checkMappedVersion(packageName string) (mappedVersion string) { - matchMappedVersion := regex(packageName) +func (d *DefaultClient) checkMappedVersion(packageName string) mappedversion.MappedVersion { + mappedVersionRegex := compileCommitVersionRegexByName(packageName) + + var rawMappedVersion string for _, commit := range d.currentPRCommit() { message := commit.GetCommit().GetMessage() - if mappedVersion = matchMappedVersion.FindString(message); mappedVersion != "" { + if rawMappedVersion = mappedVersionRegex.FindString(message); rawMappedVersion != "" { // remove space, of course - mappedVersion = strings.TrimSpace(mappedVersion) + rawMappedVersion = strings.TrimSpace(rawMappedVersion) break } } - if mappedVersion == "" { + if rawMappedVersion == "" { panic("no MappedVersion found in the PR") } - return + + return mappedversion.From(rawMappedVersion) } // commitMessage retrieves commit details by SHA -// Parameters: -// -// sha: Commit hash to retrieve -// -// Returns: -// -// *github.RepositoryCommit: Commit details object func (d *DefaultClient) commitMessage(sha string) *github.RepositoryCommit { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() @@ -249,46 +408,29 @@ func (d *DefaultClient) commitMessage(sha string) *github.RepositoryCommit { return commit } -// mappedVersion parses the latest commit's mapped version from "Release-as" directive -// Returns: -// -// string: Parsed version string or empty if not found -// -// Panics: -// -// If version format is invalid -func (d *DefaultClient) mappedVersion() string { +// mappedVersion finds raw mapped version from the latest commit +func (d *DefaultClient) findRawMappedVersion() string { // get message - message := d.commitMessage(LatestCommitSHA()).GetCommit().GetMessage() + message := d.commitMessage(env.LatestCommitSHA()).GetCommit().GetMessage() // parse the mapped version - mappedVersion := regex(".*").FindString(message) + commitVersion := compileCommitVersionRegexByName(".*").FindString(message) // mapped version not found, a normal commit? - if mappedVersion == "" { + if commitVersion == "" { return "" } - version := strings.TrimPrefix(mappedVersion, MappedVersionPrefix) - if version == mappedVersion { - panic("invalid format") - } + version := prefix.NewCommitVersionParser(commitVersion).MustParse() + return strings.TrimSpace(version) } // createTag creates a new Git tag pointing to specific commit -// Parameters: -// -// tag: Tag name (e.g. "v1.2.3") -// sha: Target commit hash -// -// Returns: -// -// error: Error during tag creation -func (d *DefaultClient) createTag(tag, sha string) error { +func (d *DefaultClient) createTag(versionTag tag.Tag, sha string) error { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() // tag the commit - tagRefName := tagRef(tag) + tagRefName := versionTag.Ref() _, _, err := d.client.Git.CreateRef(ctx, d.owner, d.repo, &github.Reference{ Ref: &tagRefName, Object: &github.GitObject{ @@ -300,14 +442,6 @@ func (d *DefaultClient) createTag(tag, sha string) error { } // createBranch creates a new branch pointing to specific commit -// Parameters: -// -// branchName: New branch name -// sha: Target commit hash -// -// Returns: -// -// error: Error during branch creation func (d *DefaultClient) createBranch(branchName, sha string) error { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() @@ -323,11 +457,14 @@ func (d *DefaultClient) createBranch(branchName, sha string) error { return err } +// createReleaseByTag creates a GitHub release using the specified tag. +// It uses the default branch (_defaultReleaseBranch) as the target commitish. +// The 'makeLatest' flag is set to "true" for non-legacy versions and "legacy" otherwise. func (d *DefaultClient) createReleaseByTag(tag string) *github.RepositoryRelease { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() - branch := defaultReleaseBranch + branch := _defaultReleaseBranch makeLatest := "true" if _, isLegacy := d.isLegacyVersion(); isLegacy { @@ -347,299 +484,91 @@ func (d *DefaultClient) createReleaseByTag(tag string) *github.RepositoryRelease return release } -func (d *DefaultClient) getReleaseByTag(tag string) *github.RepositoryRelease { +// uploadToRelease uploads a file to a GitHub release. +func (d *DefaultClient) uploadToRelease(fileName string, size int64, reader io.Reader, release *github.RepositoryRelease) { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) defer cancel() - release, _, err := d.client.Repositories.GetReleaseByTag(ctx, d.owner, d.repo, tag) - must(err) - // ok we get the relase entry - return release -} - -func (d *DefaultClient) uploadFileToRelease(fileName string, release *github.RepositoryRelease) error { - ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) - defer cancel() + url := fmt.Sprintf("repos/%s/%s/releases/%d/assets?name=%s", d.owner, d.repo, release.GetID(), fileName) - fs, err := os.Open(fileName) + req, err := d.client.NewUploadRequest(url, reader, size, "application/zip") must(err) - defer fs.Close() - - _, _, err = d.client.Repositories.UploadReleaseAsset( - ctx, d.owner, d.repo, release.GetID(), - &github.UploadOptions{ - Name: filepath.Base(fs.Name()), - }, fs) - return err + asset := new(github.ReleaseAsset) + _, err = d.client.Do(ctx, req, asset) + must(err) } -// removeBranch deletes a branch from the repository -// Parameters: -// -// branchName: Name of the branch to delete -// -// Returns: -// -// error: Error during branch deletion -func (d *DefaultClient) removeBranch(branchName string) error { +// uploadArtifactToRelease uploads a single artifact to a GitHub release in a goroutine. +func (d *DefaultClient) uploadArtifactToRelease(wg *sync.WaitGroup, artifactID int64, release *github.RepositoryRelease) { ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) + defer wg.Done() defer cancel() - _, err := d.client.Git.DeleteRef(ctx, d.owner, d.repo, branchRef(branchName)) - - return err -} - -// checkVersion performs version validation and configuration checks -// Parameters: -// -// ver: Version store object -// cfg: Package configuration -func (d *DefaultClient) checkVersion(ver *versions.Versions, cfg config.LLPkgConfig) { - // 4. Check MappedVersion - version := d.checkMappedVersion(cfg.Upstream.Package.Name) - _, mappedVersion := parseMappedVersion(version) - - // 5. Check version is valid - _, isLegacy := d.isLegacyVersion() - checkLegacyVersion(ver, cfg, mappedVersion, isLegacy) -} - -// CheckPR validates PR changes and returns affected packages -// Returns: -// -// []string: List of affected package paths -func (d *DefaultClient) CheckPR() []string { - // build a file path map - pathMap := map[string][]string{} - for _, path := range Changes() { - dir := filepath.Dir(path) - // initialize the dir - pathMap[dir] = nil - } - - var allPaths []string - - ver := versions.Read("llpkgstore.json") - - for path := range pathMap { - // don't retrieve files from pr changes, consider about maintenance case - files, _ := os.ReadDir(path) - - if !isValidLLPkg(files) { - delete(pathMap, path) - continue - } - // 3. Check directory name - llpkgFile := filepath.Join(path, "llpkg.cfg") - cfg, err := config.ParseLLPkgConfig(llpkgFile) - if err != nil { - panic(err) - } - // in our design, directory name should equal to the package name, - // which means it's not required to be equal. - // - // However, at the current stage, if this is not equal, conan may panic, - // to aovid unexpected behavior, we assert it's equal temporarily. - // this logic may be changed in the future. - packageName := strings.TrimSpace(cfg.Upstream.Package.Name) - if packageName != path { - panic("directory name is not equal to package name in llpkg.cfg") - } - d.checkVersion(ver, cfg) - - allPaths = append(allPaths, path) - } - - // 1. Check there's only one directory in PR - if len(pathMap) > 1 { - panic("too many to-be-converted directory") - } - - // 2. Check config files(llpkg.cfg and llcppg.cfg) - if len(pathMap) == 0 { - panic("no valid config files, llpkg.cfg and llcppg.cfg must exist") - } - - return allPaths -} - -// Postprocessing handles version tagging and record updates after PR merge -// Creates Git tags, updates version records, and cleans up legacy branches -func (d *DefaultClient) Postprocessing() { - // https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#push - sha := LatestCommitSHA() - // check it's associated with a pr - if !d.isAssociatedWithPullRequest(sha) { - // not a merge commit, skip it. - panic("not a merge request commit") - } - - version := d.mappedVersion() - // skip it when no mapped version is found - if version == "" { - panic("no mapped version found in the commit message") - } - - clib, mappedVersion := parseMappedVersion(version) - - // the pr has merged, so we can read it. - cfg, err := config.ParseLLPkgConfig(filepath.Join(clib, "llpkg.cfg")) - must(err) - - // write it to llpkgstore.json - ver := versions.Read("llpkgstore.json") - ver.Write(clib, cfg.Upstream.Package.Version, mappedVersion) - - if hasTag(version) { - panic("tag has already existed") - } - - if err := d.createTag(version, sha); err != nil { - panic(err) - } - - // create a release - d.createReleaseByTag(version) - - // we have finished tagging the commit, safe to remove the branch - if branchName, isLegacy := d.isLegacyVersion(); isLegacy { - d.removeBranch(branchName) - } - // move to website in Github Action... -} - -func (d *DefaultClient) Release() { - version := d.mappedVersion() - // skip it when no mapped version is found - if version == "" { - panic("no mapped version found in the commit message") - } - - clib, _ := parseMappedVersion(version) - // the pr has merged, so we can read it. - cfg, err := config.ParseLLPkgConfig(filepath.Join(clib, "llpkg.cfg")) - must(err) + url, _, err := d.client.Actions.DownloadArtifact(ctx, d.owner, d.repo, + artifactID, 0) - uc, err := config.NewUpstreamFromConfig(cfg.Upstream) must(err) - tempDir, _ := os.MkdirTemp("", "llpkg-tool") + httpClient := &http.Client{Timeout: 30 * time.Second} - deps, err := uc.Installer.Install(uc.Pkg, tempDir) + resp, err := httpClient.Get(url.String()) must(err) + defer resp.Body.Close() - pkgConfigDir := filepath.Join(tempDir, "lib", "pkgconfig") - // clear exist .pc - os.RemoveAll(pkgConfigDir) - - err = os.Mkdir(pkgConfigDir, 0777) + disposition := resp.Header.Get("Content-Disposition") + _, params, err := mime.ParseMediaType(disposition) must(err) - for _, pcName := range deps { - pcFile := filepath.Join(tempDir, pcName+".pc") - // generate pc template to lib/pkgconfig - err = pc.GenerateTemplateFromPC(pcFile, pkgConfigDir, deps) - must(err) + fileName, ok := params["filename"] + if !ok { + panic("no filename found in Content-Disposition") } - // okay, safe to remove old pc - file.RemovePattern(filepath.Join(tempDir, "*.pc")) - file.RemovePattern(filepath.Join(tempDir, "*.sh")) - - zipFilePath, _ := filepath.Abs(binaryZip(uc.Pkg.Name)) - - err = file.Zip(tempDir, zipFilePath) - must(err) - release := d.getReleaseByTag(version) - - // upload file to release - err = d.uploadFileToRelease(zipFilePath, release) - must(err) + fmt.Printf("Upload %s to %s\n", fileName, release.GetName()) + d.uploadToRelease(fileName, resp.ContentLength, resp.Body, release) } -// CreateBranchFromLabel creates release branch based on label format -// Follows naming convention: release-branch./ -func (d *DefaultClient) CreateBranchFromLabel(labelName string) { - // design: branch:release-branch.{CLibraryName}/{MappedVersion} - branchName := strings.TrimPrefix(strings.TrimSpace(labelName), LabelPrefix) - if branchName == labelName { - panic("invalid label name format") - } - - // fast-path: branch exists, can skip. - if d.hasBranch(branchName) { - return - } - version := strings.TrimPrefix(branchName, BranchPrefix) - if version == branchName { - panic("invalid label name format") - } - clib, _ := parseMappedVersion(version) - // slow-path: check the condition if we can create a branch - // - // create a branch only when this version is legacy. - // according to branch maintenance strategy - - // get latest version of the clib - ver := versions.Read("llpkgstore.json") +// uploadArtifactsToRelease uploads all available workflow artifacts to the specified GitHub release. +func (d *DefaultClient) uploadArtifactsToRelease(release *github.RepositoryRelease) (files []*os.File) { + ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) + defer cancel() - cversions := ver.CVersions(clib) - if len(cversions) == 0 { - panic("no clib found") - } + artifacts, _, err := d.client.Actions.ListWorkflowRunArtifacts(ctx, d.owner, d.repo, + env.WorkflowID(), &github.ListOptions{}) - if !versions.IsSemver(cversions) { - panic("c version dones't follow semver, skip maintaining.") - } - - err := d.createBranch(branchName, shaFromTag(version)) must(err) -} - -// CleanResource removes labels and resources after issue resolution -// Verifies issue closure via PR merge before deletion -func (d *DefaultClient) CleanResource() { - issueEvent := IssueEvent() - - issueNumber := int(issueEvent["number"].(float64)) - regex := regexp.MustCompile(fmt.Sprintf(`(f|F)ix.*#%d`, issueNumber)) - // 1. check this issue is closed by a PR - // In Github, close a issue with a commit whose message follows this format - // fix/Fix* #{IssueNumber} - found := false - for _, commit := range d.allCommits() { - message := commit.Commit.GetMessage() - - if regex.MatchString(message) && - d.isAssociatedWithPullRequest(commit.GetSHA()) { - found = true - break - } + if artifacts.GetTotalCount() == 0 { + panic("no artifact found") } - if !found { - panic("current issue isn't closed by merged PR.") + var wg sync.WaitGroup + wg.Add(len(artifacts.Artifacts)) + for _, artifact := range artifacts.Artifacts { + go d.uploadArtifactToRelease(&wg, artifact.GetID(), release) } + wg.Wait() + return +} - var labelName string - - // 2. find out the branch name from the label - for _, labels := range issueEvent["labels"].([]map[string]any) { - label := labels["name"].(string) +// removeBranch deletes a branch from the repository +func (d *DefaultClient) removeBranch(branchName string) error { + ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) + defer cancel() - if strings.HasPrefix(label, BranchPrefix) { - labelName = label - break - } - } + _, err := d.client.Git.DeleteRef(ctx, d.owner, d.repo, branchRef(branchName)) - if labelName == "" { - panic("current issue hasn't labelled, this should not happen") - } + return err +} - d.removeLabel(labelName) +// checkVersion performs version validation and configuration checks +func (d *DefaultClient) checkVersion(ver *mappingtable.Versions, cfg config.LLPkgConfig) { + // 4. Check MappedVersion + version := d.checkMappedVersion(cfg.Upstream.Package.Name) + _, mappedVersion := version.MustParse() + // 5. Check version is valid + _, isLegacy := d.isLegacyVersion() + checkLegacyVersion(ver, cfg, mappedVersion, isLegacy) } diff --git a/internal/actions/api_test.go b/internal/actions/api_test.go index 3ecef0b..c977fc9 100644 --- a/internal/actions/api_test.go +++ b/internal/actions/api_test.go @@ -2,35 +2,17 @@ package actions import ( "os" - "os/exec" - "strings" "testing" "github.com/goplus/llpkgstore/config" - "github.com/goplus/llpkgstore/internal/actions/versions" + "github.com/goplus/llpkgstore/internal/actions/mappingtable" ) -func TestHasTag(t *testing.T) { - if hasTag("aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9") { - t.Error("unexpected tag") - } - exec.Command("git", "tag", "aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9").Run() - if !hasTag("aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9") { - t.Error("tag doesn't exist") - } - ret, _ := exec.Command("git", "tag").CombinedOutput() - t.Log(string(ret)) - exec.Command("git", "tag", "-d", "aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9").Run() - if hasTag("aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9") { - t.Error("unexpected tag") - } -} - func recoverFn(branchName string, fn func(legacy bool)) (ret any) { defer func() { ret = recover() }() - fn(strings.HasPrefix(branchName, BranchPrefix)) + fn(isLegacyBranch(branchName)) return } @@ -60,7 +42,7 @@ func TestLegacyVersion1(t *testing.T) { defer os.Remove(".llpkgstore.json") cfg, _ := config.ParseLLPkgConfig(".llpkg.cfg") - ver := versions.Read(".llpkgstore.json") + ver := mappingtable.Read(".llpkgstore.json") err := recoverFn("main", func(legacy bool) { checkLegacyVersion(ver, cfg, "v0.1.1", legacy) @@ -101,7 +83,7 @@ func TestLegacyVersion2(t *testing.T) { defer os.Remove(".llpkgstore.json") cfg, _ := config.ParseLLPkgConfig(".llpkg.cfg") - ver := versions.Read(".llpkgstore.json") + ver := mappingtable.Read(".llpkgstore.json") err := recoverFn("release-branch.cjson/v0.1.1", func(legacy bool) { checkLegacyVersion(ver, cfg, "v0.1.2", legacy) @@ -140,7 +122,7 @@ func TestLegacyVersion3(t *testing.T) { defer os.Remove(".llpkgstore.json") cfg, _ := config.ParseLLPkgConfig(".llpkg.cfg") - ver := versions.Read(".llpkgstore.json") + ver := mappingtable.Read(".llpkgstore.json") err := recoverFn("main", func(legacy bool) { checkLegacyVersion(ver, cfg, "v0.3.0", legacy) @@ -179,7 +161,7 @@ func TestLegacyVersion4(t *testing.T) { defer os.Remove(".llpkgstore.json") cfg, _ := config.ParseLLPkgConfig(".llpkg.cfg") - ver := versions.Read(".llpkgstore.json") + ver := mappingtable.Read(".llpkgstore.json") err := recoverFn("main", func(legacy bool) { checkLegacyVersion(ver, cfg, "v0.0.1", legacy) @@ -219,7 +201,7 @@ func TestLegacyVersion5(t *testing.T) { defer os.Remove(".llpkgstore.json") cfg, _ := config.ParseLLPkgConfig(".llpkg.cfg") - ver := versions.Read(".llpkgstore.json") + ver := mappingtable.Read(".llpkgstore.json") err := recoverFn("main", func(legacy bool) { checkLegacyVersion(ver, cfg, "v0.1.1", legacy) diff --git a/internal/actions/env/env.go b/internal/actions/env/env.go new file mode 100644 index 0000000..0ea29a1 --- /dev/null +++ b/internal/actions/env/env.go @@ -0,0 +1,105 @@ +package env + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" +) + +// must panics if the error is non-nil, halting execution +func must(err error) { + if err != nil { + panic(err) + } +} + +type Env map[string]string + +// String converts environment variables map to newline-separated key=value pairs for GitHub Actions +func (e Env) String() string { + var env []string + + for name, value := range e { + env = append(env, fmt.Sprintf("%s=%s", name, value)) + } + + sort.Strings(env) + return strings.Join(env, "\n") +} + +// Setenv writes environment variables to GITHUB_ENV for GitHub Actions consumption +func Setenv(envm Env) { + env, err := os.OpenFile(os.Getenv("GITHUB_ENV"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + // should never happen, + // it means current runtime is not Github actions if there's any errors + must(err) + + env.WriteString(envm.String()) + + // make sure we write it to the GITHUB_ENV + env.Close() +} + +// SetOutput writes workflow outputs to GITHUB_OUTPUT for GitHub Actions +func SetOutput(envm Env) { + env, err := os.OpenFile(os.Getenv("GITHUB_OUTPUT"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + must(err) + + env.WriteString(envm.String()) + + env.Close() +} + +// Changes returns the changed files in current PR, +// which depends on ALL_CHANGED_FILES generated by tj-actions/changed-files action, +// if there's no content in ALL_CHANGED_FILES, it panic. +func Changes() []string { + changes := os.Getenv("ALL_CHANGED_FILES") + if changes == "" { + panic("cannot find changes file!") + } + return strings.Fields(changes) +} + +// Repository returns owner and repository name for the current repository +// +// Example: goplus/llpkg, owner: goplus, repo: llpkg +// Repository extracts GitHub repository owner and name from GITHUB_REPOSITORY +func Repository() (owner, repo string) { + thisRepo := os.Getenv("GITHUB_REPOSITORY") + if thisRepo == "" { + panic("no github repo") + } + current := strings.Split(thisRepo, "/") + return current[0], current[1] +} + +// Token returns Github Token for current runner +func Token() string { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + panic("no GITHUB_TOKEN") + } + return token +} + +// LatestCommitSHA returns the current commit SHA from GITHUB_SHA environment variable +func LatestCommitSHA() string { + sha := os.Getenv("GITHUB_SHA") + if sha == "" { + panic("no GITHUB_SHA found") + } + return sha +} + +func WorkflowID() int64 { + runId := os.Getenv("GITHUB_RUN_ID") + if runId == "" { + panic("no GITHUB_RUN_ID found") + } + id, err := strconv.ParseInt(runId, 10, 64) + must(err) + return id +} diff --git a/internal/actions/env/env_test.go b/internal/actions/env/env_test.go new file mode 100644 index 0000000..f488d70 --- /dev/null +++ b/internal/actions/env/env_test.go @@ -0,0 +1,17 @@ +package env + +import "testing" + +func TestEnv(t *testing.T) { + env := Env{ + "TEST_VAR1": "114514", + "TEST_VAR2": "123", + "TEST_VAR3": "456", + } + + expectedContent := "TEST_VAR1=114514\nTEST_VAR2=123\nTEST_VAR3=456" + + if env.String() != expectedContent { + t.Errorf("unexpected content: want %s got %s", expectedContent, env.String()) + } +} diff --git a/internal/actions/versions/versions.go b/internal/actions/mappingtable/versions.go similarity index 72% rename from internal/actions/versions/versions.go rename to internal/actions/mappingtable/versions.go index 395046f..971e38a 100644 --- a/internal/actions/versions/versions.go +++ b/internal/actions/mappingtable/versions.go @@ -1,4 +1,4 @@ -package versions +package mappingtable import ( "encoding/json" @@ -7,6 +7,7 @@ import ( "os" "slices" + "github.com/goplus/llpkgstore/internal/actions/version" "github.com/goplus/llpkgstore/metadata" "golang.org/x/mod/semver" ) @@ -14,7 +15,7 @@ import ( // Versions is a mapping table implement for Github Action only. // It's recommend to use another implement in llgo for common usage. type Versions struct { - metadata.MetadataMap + m metadata.MetadataMap fileName string } @@ -27,7 +28,7 @@ type Versions struct { // elem: Version to append func appendVersion(arr []string, elem string) []string { if slices.Contains(arr, elem) { - log.Fatalf("version %s has already existed", elem) + log.Panicf("version %s has already existed", elem) } return append(arr, elem) } @@ -57,37 +58,27 @@ func Read(fileName string) *Versions { } return &Versions{ - MetadataMap: m, - fileName: f.Name(), + m: m, + fileName: f.Name(), } } -// cVersions retrieves the version mappings for a specific C library. -// It returns a map where keys are C library versions and values are supported Go versions. -func (v *Versions) cVersions(clib string) map[metadata.CVersion][]metadata.GoVersion { - versions := v.MetadataMap[clib] - if versions == nil { - return nil - } - return versions.Versions -} - // CVersions returns all available versions of the specified C library. // The versions are returned as semantic version strings. func (v *Versions) CVersions(clib string) (ret []string) { - versions := v.MetadataMap[clib] + versions := v.m[clib] if versions == nil { return } - for version := range versions.Versions { - ret = append(ret, ToSemVer(version)) + for cversion := range versions.Versions { + ret = append(ret, version.ToSemVer(cversion)) } return } // GoVersions lists all Go versions associated with the given C library. func (v *Versions) GoVersions(clib string) (ret []string) { - versions := v.MetadataMap[clib] + versions := v.m[clib] if versions == nil { return } @@ -99,11 +90,7 @@ func (v *Versions) GoVersions(clib string) (ret []string) { // LatestGoVersionForCVersion finds the latest Go version compatible with a specific C library version. func (v *Versions) LatestGoVersionForCVersion(clib, cver string) string { - version := v.MetadataMap[clib] - if version == nil { - return "" - } - goVersions := version.Versions[cver] + goVersions := v.goVersion(clib, cver) if len(goVersions) == 0 { return "" } @@ -113,9 +100,9 @@ func (v *Versions) LatestGoVersionForCVersion(clib, cver string) string { // SearchBySemVer looks up a C library version by its semantic version string. func (v *Versions) SearchBySemVer(clib, semver string) string { - for version := range v.cVersions(clib) { - if ToSemVer(version) == semver { - return version + for cversion := range v.cVersions(clib) { + if version.ToSemVer(cversion) == semver { + return cversion } } return "" @@ -141,26 +128,62 @@ func (v *Versions) LatestGoVersion(clib string) string { // // It appends the Go version to the existing list for the C library version and saves the updated metadata. func (v *Versions) Write(clib, clibVersion, mappedVersion string) { - clibVersions := v.MetadataMap[clib] - if clibVersions == nil { - clibVersions = &metadata.Metadata{ - Versions: map[metadata.CVersion][]metadata.GoVersion{}, - } - v.MetadataMap[clib] = clibVersions - } - versions := clibVersions.Versions[clibVersion] + v.initCVersion(clib) + + versions := v.goVersion(clib, clibVersion) - versions = appendVersion(versions, mappedVersion) + // TODO(ghl): rewrite llpkgstore.json + v.m[clib].Versions[clibVersion] = appendVersion(versions, mappedVersion) - clibVersions.Versions[clibVersion] = versions // sync to disk - b, _ := json.Marshal(&v.MetadataMap) + b, _ := json.Marshal(&v.m) os.WriteFile(v.fileName, []byte(b), 0644) } // String returns the JSON representation of the Versions metadata. func (v *Versions) String() string { - b, _ := json.Marshal(&v.MetadataMap) + b, _ := json.Marshal(&v.m) return string(b) } + +// initCVersion initializes the C library version entry in metadata if it doesn't exist +// Parameters: +// +// clib: The name of the C library +func (v *Versions) initCVersion(clib string) { + clibVersions := v.m[clib] + if clibVersions == nil { + clibVersions = &metadata.Metadata{ + Versions: map[metadata.CVersion][]metadata.GoVersion{}, + } + v.m[clib] = clibVersions + } +} + +// goVersion retrieves Go versions associated with specific C library version +// Parameters: +// +// clib: The C library name +// clibVersion: The C library version +// +// Returns: +// +// Slice of Go versions compatible with given C library version +func (v *Versions) goVersion(clib, clibVersion string) []metadata.GoVersion { + clibVersions := v.m[clib] + if clibVersions == nil { + return nil + } + return slices.Clone(clibVersions.Versions[clibVersion]) +} + +// cVersions retrieves the version mappings for a specific C library. +// It returns a map where keys are C library versions and values are supported Go versions. +func (v *Versions) cVersions(clib string) map[metadata.CVersion][]metadata.GoVersion { + versions := v.m[clib] + if versions == nil { + return nil + } + return versions.Versions +} diff --git a/internal/actions/versions/versions_test.go b/internal/actions/mappingtable/versions_test.go similarity index 96% rename from internal/actions/versions/versions_test.go rename to internal/actions/mappingtable/versions_test.go index 43f4ca8..cc0fdcc 100644 --- a/internal/actions/versions/versions_test.go +++ b/internal/actions/mappingtable/versions_test.go @@ -1,4 +1,4 @@ -package versions +package mappingtable import ( "bytes" @@ -97,6 +97,6 @@ func TestAppend(t *testing.T) { b, _ := os.ReadFile("llpkgstore.json") if !bytes.Equal(b, []byte(`{"cjson":{"versions":{"1.7.18":["v1.0.0","v1.0.1"],"1.7.19":["v1.0.2"]}},"libxml":{"versions":{"1.45.1.4":["v1.0.0"],"1.45.1.5":["v1.0.1"]}}}`)) { - t.Error("unexpected append result") + t.Errorf("unexpected append result: got %s", string(b)) } } diff --git a/internal/actions/parser/mappedversion/mappedversion.go b/internal/actions/parser/mappedversion/mappedversion.go new file mode 100644 index 0000000..5e97d16 --- /dev/null +++ b/internal/actions/parser/mappedversion/mappedversion.go @@ -0,0 +1,50 @@ +package mappedversion + +import ( + "errors" + "strings" + + "golang.org/x/mod/semver" +) + +// ErrVersionFormat invalid version format error +var ErrVersionFormat = errors.New("invalid mapped version format") + +// ErrMappedVersionFormat invalid semantic version error +var ErrMappedVersionFormat = errors.New("invalid semantic version format") + +type MappedVersion string + +// From creates a MappedVersion from string +func From(version string) MappedVersion { + return MappedVersion(version) +} + +// Parse splits the mapped version string into library name and version. +// Input format: "clib/semver" where semver starts with 'v' +// Panics if input format is invalid or version isn't valid semantic version +func (m MappedVersion) Parse() (clib, version string, err error) { + parts := strings.Split(string(m), "/") + if len(parts) != 2 { + return "", "", ErrVersionFormat + } + clib, version = parts[0], parts[1] + if !semver.IsValid(version) { + return "", "", ErrMappedVersionFormat + } + return +} + +// MustParse parses version or panics +func (m MappedVersion) MustParse() (string, string) { + clib, ver, err := m.Parse() + if err != nil { + panic(err) + } + return clib, ver +} + +// String returns the version string representation +func (m MappedVersion) String() string { + return string(m) +} diff --git a/internal/actions/parser/mappedversion/mappedversion_test.go b/internal/actions/parser/mappedversion/mappedversion_test.go new file mode 100644 index 0000000..559a4e7 --- /dev/null +++ b/internal/actions/parser/mappedversion/mappedversion_test.go @@ -0,0 +1,38 @@ +package mappedversion + +import "testing" + +func TestMappedVersion(t *testing.T) { + t.Run("invalid-1", func(t *testing.T) { + _, _, err := From("cjson").Parse() + if err == nil { + t.Errorf("unpexted behavior: no error") + } + }) + t.Run("invalid-2", func(t *testing.T) { + _, _, err := From("cjson/").Parse() + if err == nil { + t.Errorf("unpexted behavior: no error") + } + }) + + t.Run("invalid-3", func(t *testing.T) { + _, _, err := From("cjson/1.7.18").Parse() + if err == nil { + t.Errorf("unpexted behavior: no error") + } + }) + + t.Run("valid", func(t *testing.T) { + clib, version, err := From("cjson/v1.0.0").Parse() + if err != nil { + t.Errorf("unpexted error: %v", err) + } + if clib != "cjson" { + t.Errorf("unpexted clib: want %s got %s", "cjson", clib) + } + if version != "v1.0.0" { + t.Errorf("unpexted clib: want %s got %s", "v1.0.0", version) + } + }) +} diff --git a/internal/actions/parser/parser.go b/internal/actions/parser/parser.go new file mode 100644 index 0000000..4826efd --- /dev/null +++ b/internal/actions/parser/parser.go @@ -0,0 +1,11 @@ +package parser + +import "errors" + +var ErrInvalidFormat = errors.New("parse error: invalid format") + +type Parser interface { + Parse() (content string, err error) + MustParse() (content string) + String() string +} diff --git a/internal/actions/parser/prefix/prefix.go b/internal/actions/parser/prefix/prefix.go new file mode 100644 index 0000000..6d6cd39 --- /dev/null +++ b/internal/actions/parser/prefix/prefix.go @@ -0,0 +1,70 @@ +package prefix + +import ( + "strings" + + "github.com/goplus/llpkgstore/internal/actions/parser" +) + +const ( + // LabelPrefix is the prefix identifier for label strings + LabelPrefix = "branch:" + // BranchPrefix denotes the prefix format for release branches + BranchPrefix = "release-branch." + // MappedVersionPrefix indicates the prefix used for commit version mappings + MappedVersionPrefix = "Release-as: " +) + +// Interface guard to ensure prefixParser implements parser.Parser +var _ parser.Parser = (*prefixParser)(nil) + +// prefixParser represents a parser that trims a specific prefix from a string +type prefixParser struct { + s string + prefix string +} + +// newPrefixParser creates a new prefixParser instance with the provided string and prefix +func newPrefixParser(s, prefix string) parser.Parser { + return &prefixParser{s: strings.TrimSpace(s), prefix: prefix} +} + +// Parse trims the configured prefix from the input string and returns the result +func (l *prefixParser) Parse() (content string, err error) { + result := strings.TrimPrefix(l.String(), l.prefix) + if result == l.String() { + err = parser.ErrInvalidFormat + return + } + content = result + return +} + +// MustParse parses the string and panics if the prefix is not found +func (l *prefixParser) MustParse() (content string) { + content, err := l.Parse() + if err != nil { + panic(err) + } + return +} + +// String returns the original input string being parsed +func (l *prefixParser) String() string { + return l.s +} + +// NewLabelParser creates a parser for label strings with the LabelPrefix +func NewLabelParser(content string) parser.Parser { + return newPrefixParser(content, LabelPrefix) +} + +// NewBranchParser creates a parser for branch names with the BranchPrefix +func NewBranchParser(content string) parser.Parser { + return newPrefixParser(content, BranchPrefix) +} + +// NewCommitVersionParser creates a parser for commit version strings with the MappedVersionPrefix +func NewCommitVersionParser(content string) parser.Parser { + return newPrefixParser(content, MappedVersionPrefix) +} diff --git a/internal/actions/parser/prefix/prefix_test.go b/internal/actions/parser/prefix/prefix_test.go new file mode 100644 index 0000000..37e3f80 --- /dev/null +++ b/internal/actions/parser/prefix/prefix_test.go @@ -0,0 +1,48 @@ +package prefix + +import "testing" + +func TestLabelPrefix(t *testing.T) { + t.Run("label-valid", func(t *testing.T) { + p := NewLabelParser("branch:release-branch.").MustParse() + if p != "release-branch." { + t.Errorf("unexpected result: want: %s got %s", "release-branch.", p) + } + }) + t.Run("inabel-valid", func(t *testing.T) { + _, err := NewLabelParser("release-branch.").Parse() + if err == nil { + t.Error("unexpected behavior: no error") + } + }) +} + +func TestBranchPrefix(t *testing.T) { + t.Run("branch-valid", func(t *testing.T) { + p := NewBranchParser("release-branch.cjson/v1.0.0").MustParse() + if p != "cjson/v1.0.0" { + t.Errorf("unexpected result: want: %s got %s", "cjson/v1.0.0", p) + } + }) + t.Run("branch-invalid", func(t *testing.T) { + _, err := NewBranchParser("release-branch").Parse() + if err == nil { + t.Error("unexpected behavior: no error") + } + }) +} + +func TestCommitVersionPrefix(t *testing.T) { + t.Run("commitversion-valid", func(t *testing.T) { + p := NewCommitVersionParser("Release-as: cjson/v1.0.0").MustParse() + if p != "cjson/v1.0.0" { + t.Errorf("unexpected result: want: %s got %s", "cjson/v1.0.0", p) + } + }) + t.Run("commitversion-invalid", func(t *testing.T) { + _, err := NewCommitVersionParser("Release: cjson/v1.0.0").Parse() + if err == nil { + t.Error("unexpected behavior: no error") + } + }) +} diff --git a/internal/actions/tag/tag.go b/internal/actions/tag/tag.go new file mode 100644 index 0000000..aef5336 --- /dev/null +++ b/internal/actions/tag/tag.go @@ -0,0 +1,38 @@ +package tag + +import ( + "log" + "os/exec" + "strings" +) + +type Tag string + +func From(tag string) Tag { + return Tag(strings.TrimSpace(tag)) +} + +// SHA retrieves commit SHA for given Git tag +// Panics if tag doesn't exist +func (t Tag) SHA() string { + ret, err := exec.Command("git", "rev-list", "-n", "1", t.String()).CombinedOutput() + if err != nil { + log.Panicf("cannot find a tag: %s %s", t, string(ret)) + } + return strings.TrimSpace(string(ret)) +} + +// Exist checks if specified Git tag exists in repository +func (t Tag) Exist() bool { + _, err := exec.Command("git", "rev-parse", t.Ref()).CombinedOutput() + return err == nil +} + +// tagRef constructs full Git tag reference string (e.g. "refs/tags/v1.0.0") +func (t Tag) Ref() string { + return "refs/tags/" + t.String() +} + +func (t Tag) String() string { + return string(t) +} diff --git a/internal/actions/tag/tag_test.go b/internal/actions/tag/tag_test.go new file mode 100644 index 0000000..adccf6b --- /dev/null +++ b/internal/actions/tag/tag_test.go @@ -0,0 +1,31 @@ +package tag + +import ( + "os/exec" + "strings" + "testing" +) + +func TestTag(t *testing.T) { + tg := From("aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9") + if tg.Exist() { + t.Error("unexpected tag") + } + if tg.Ref() != "refs/tags/aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9" { + t.Errorf("unexpected tag ref: want %s got %s", "aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9", tg.Ref()) + } + exec.Command("git", "tag", "aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9").Run() + if !tg.Exist() { + t.Error("tag doesn't exist") + } + ret, _ := exec.Command("git", "rev-parse", "HEAD").CombinedOutput() + if strings.TrimSpace(string(ret)) != tg.SHA() { + t.Errorf("unexpected tag SHA: want %s got %s", string(ret), tg.SHA()) + } + ret, _ = exec.Command("git", "tag").CombinedOutput() + t.Log(string(ret)) + exec.Command("git", "tag", "-d", "aaaaaaaaaaa1.1.4.5.1.4.1.9.1.9").Run() + if tg.Exist() { + t.Error("unexpected tag") + } +} diff --git a/internal/actions/text.go b/internal/actions/text.go new file mode 100644 index 0000000..c155dd4 --- /dev/null +++ b/internal/actions/text.go @@ -0,0 +1,15 @@ +package actions + +import ( + "fmt" + "regexp" +) + +const _unspecificMappedVersionRegex = `Release-as:\s%s/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-]+)*))?` + +// regex compiles a regular expression pattern to detect "Release-as" directives in commit messages +func compileCommitVersionRegexByName(packageName string) *regexp.Regexp { + // format: Release-as: clib/semver(with v prefix) + // Must have one space in the end of Release-as: + return regexp.MustCompile(fmt.Sprintf(_unspecificMappedVersionRegex, packageName)) +} diff --git a/internal/actions/versions/semver.go b/internal/actions/version/semver.go similarity index 98% rename from internal/actions/versions/semver.go rename to internal/actions/version/semver.go index e22f725..3052017 100644 --- a/internal/actions/versions/semver.go +++ b/internal/actions/version/semver.go @@ -1,4 +1,4 @@ -package versions +package version import "golang.org/x/mod/semver" diff --git a/upstream/installer/conan/conan.go b/upstream/installer/conan/conan.go index 47b5296..009fc70 100644 --- a/upstream/installer/conan/conan.go +++ b/upstream/installer/conan/conan.go @@ -103,7 +103,7 @@ func (c *conanInstaller) findBinaryPathFromPC( output []byte, ) ( binaryDir string, - pcName []string, + pcNames []string, err error, ) { var m installOutput @@ -120,7 +120,7 @@ func (c *conanInstaller) findBinaryPathFromPC( // default to package name, // first element is the real pkg-config name of this package // use append here to avoid resizing slice again. - pcName = append(pcName, pkg.Name) + pcNames = append(pcNames, pkg.Name) for _, packageInfo := range m.Graph.Nodes { if packageInfo.Name != pkg.Name { @@ -134,12 +134,12 @@ func (c *conanInstaller) findBinaryPathFromPC( } if root.Properties.PkgName != "" { // root is the real pkg config name, replace instead. - pcName[0] = root.Properties.PkgName + pcNames[0] = root.Properties.PkgName } - pcName = append(pcName, retrievePC(packageInfo.CppInfo)...) + pcNames = append(pcNames, retrievePC(packageInfo.CppInfo)...) } - pcFile, err := os.ReadFile(filepath.Join(dir, pcName[0]+".pc")) + pcFile, err := os.ReadFile(filepath.Join(dir, pcNames[0]+".pc")) if err != nil { return } @@ -216,7 +216,7 @@ func (c *conanInstaller) Install(pkg upstream.Package, outputDir string) ([]stri // fmt.Println(string(out)) return nil, err } - binaryDir, pkgConfigName, err := c.findBinaryPathFromPC(pkg, outputDir, ret) + binaryDir, pkgConfigNames, err := c.findBinaryPathFromPC(pkg, outputDir, ret) if err != nil { return nil, err } @@ -226,7 +226,7 @@ func (c *conanInstaller) Install(pkg upstream.Package, outputDir string) ([]stri return nil, err } - return pkgConfigName, nil + return pkgConfigNames, nil } // Search checks Conan remote repository for the specified package availability.