forked from coop-cloud/abra
refactor: the grand recipe release refactor
This commit is contained in:
parent
f57222d6aa
commit
fa45264ea0
|
@ -235,7 +235,8 @@ A new catalogue copy can be published to the recipes repository by passing the
|
|||
}
|
||||
}
|
||||
|
||||
if err := gitPkg.Commit("**.json", internal.CommitMessage, internal.Dry, internal.Push); err != nil {
|
||||
cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
|
||||
if err := gitPkg.Commit(cataloguePath, "**.json", internal.CommitMessage, internal.Dry, internal.Push); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,6 @@ import (
|
|||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/docker/distribution/reference"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
@ -66,234 +65,70 @@ You may invoke this command in "wizard" mode and be prompted for input:
|
|||
BashComplete: autocomplete.RecipeNameComplete,
|
||||
Action: func(c *cli.Context) error {
|
||||
recipe := internal.ValidateRecipeWithPrompt(c)
|
||||
directory := path.Join(config.APPS_DIR, recipe.Name)
|
||||
tagString := c.Args().Get(1)
|
||||
mainApp := internal.GetMainApp(recipe)
|
||||
|
||||
imagesTmp, err := getImageVersions(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
mainApp := internal.GetMainApp(recipe)
|
||||
mainAppVersion := imagesTmp[mainApp]
|
||||
|
||||
if err := recipePkg.EnsureExists(recipe.Name); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if mainAppVersion == "" {
|
||||
logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
|
||||
}
|
||||
|
||||
tagString := c.Args().Get(1)
|
||||
if tagString != "" {
|
||||
if _, err := tagcmp.Parse(tagString); err != nil {
|
||||
logrus.Fatal("invalid tag specified")
|
||||
logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString)
|
||||
}
|
||||
}
|
||||
|
||||
if (!internal.Major && !internal.Minor && !internal.Patch) && tagString == "" {
|
||||
logrus.Fatal("please specify <version> or bump type (--major/--minor/--patch)")
|
||||
}
|
||||
|
||||
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
|
||||
logrus.Fatal("cannot specify tag and bump type at the same time")
|
||||
}
|
||||
|
||||
// bumpType is used to decide what part of the tag should be incremented
|
||||
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
|
||||
if bumpType != 0 {
|
||||
// a bitwise check if the number is a power of 2
|
||||
if (bumpType & (bumpType - 1)) != 0 {
|
||||
logrus.Fatal("you can only use one of: --major, --minor, --patch.")
|
||||
}
|
||||
}
|
||||
|
||||
if err := internal.PromptBumpType(tagString); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if internal.TagMessage == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "tag message",
|
||||
Default: "chore: publish new release",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var createTagOptions git.CreateTagOptions
|
||||
createTagOptions.Message = internal.TagMessage
|
||||
|
||||
if !internal.Commit && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "git commit changes also?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.Commit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !internal.Push && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "git push changes also?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.Push); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Commit || internal.CommitMessage != "" {
|
||||
if internal.CommitMessage == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "commit message",
|
||||
Default: "chore: publish new %s version",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if internal.CommitMessage == "" {
|
||||
logrus.Fatal("no commit message specified?")
|
||||
}
|
||||
|
||||
if err := gitPkg.Commit("compose.**yml", internal.CommitMessage, internal.Dry, false); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := git.PlainOpen(directory)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if tagString != "" {
|
||||
tag, err := tagcmp.Parse(tagString)
|
||||
if err != nil {
|
||||
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if tag.MissingMinor {
|
||||
tag.Minor = "0"
|
||||
tag.MissingMinor = false
|
||||
}
|
||||
|
||||
if tag.MissingPatch {
|
||||
tag.Patch = "0"
|
||||
tag.MissingPatch = false
|
||||
}
|
||||
|
||||
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
|
||||
if internal.Dry {
|
||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
||||
logrus.Info(fmt.Sprintf("dry run: not creating tag %s at %s", tagString, hash))
|
||||
return nil
|
||||
}
|
||||
|
||||
repo.CreateTag(tagString, head.Hash(), &createTagOptions)
|
||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
||||
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
||||
if internal.Push && !internal.Dry {
|
||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
|
||||
} else {
|
||||
logrus.Info("dry run: no changes pushed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// get the latest tag with its hash, name etc
|
||||
var lastGitTag tagcmp.Tag
|
||||
iter, err := repo.Tags()
|
||||
tags, err := recipe.Tags()
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
obj, err := repo.TagObject(ref.Hash())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tagcmpTag, err := tagcmp.Parse(obj.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (lastGitTag == tagcmp.Tag{}) {
|
||||
lastGitTag = tagcmpTag
|
||||
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
|
||||
lastGitTag = tagcmpTag
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if lastGitTag.String() == "" || lastGitTag.String() == ".." {
|
||||
logrus.Warn("no previous git tags found, this is the initial release?")
|
||||
}
|
||||
|
||||
newTag := lastGitTag
|
||||
var newtagString string
|
||||
if bumpType > 0 {
|
||||
if internal.Patch {
|
||||
now, err := strconv.Atoi(newTag.Patch)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Patch = strconv.Itoa(now + 1)
|
||||
} else if internal.Minor {
|
||||
now, err := strconv.Atoi(newTag.Minor)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Patch = "0"
|
||||
newTag.Minor = strconv.Itoa(now + 1)
|
||||
} else if internal.Major {
|
||||
now, err := strconv.Atoi(newTag.Major)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Patch = "0"
|
||||
newTag.Minor = "0"
|
||||
newTag.Major = strconv.Itoa(now + 1)
|
||||
}
|
||||
}
|
||||
|
||||
newTag.Metadata = mainAppVersion
|
||||
newtagString = newTag.String()
|
||||
if internal.Dry {
|
||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
||||
logrus.Info(fmt.Sprintf("dry run: not creating tag %s at %s", newtagString, hash))
|
||||
return nil
|
||||
}
|
||||
|
||||
repo.CreateTag(newtagString, head.Hash(), &createTagOptions)
|
||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
||||
logrus.Info(fmt.Sprintf("created tag %s at %s", newtagString, hash))
|
||||
if internal.Push && !internal.Dry {
|
||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
||||
if len(tags) > 0 {
|
||||
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString))
|
||||
} else {
|
||||
logrus.Info("dry run: no changes pushed")
|
||||
logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
|
||||
|
||||
initTag, err := recipePkg.GetVersionLabelLocal(recipe)
|
||||
if err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
logrus.Warnf("discovered %s as currently synced recipe label", initTag)
|
||||
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("use %s as the initial release?", initTag),
|
||||
}
|
||||
|
||||
var response bool
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
logrus.Fatalf("please fix your synced label for %s and re-run this command", recipe.Name)
|
||||
}
|
||||
|
||||
if err := createReleaseFromTag(recipe, initTag, mainAppVersion); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -333,6 +168,48 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
|
|||
return services, nil
|
||||
}
|
||||
|
||||
// createReleaseFromTag creates a new release based on a supplied recipe version string
|
||||
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
|
||||
var err error
|
||||
|
||||
directory := path.Join(config.APPS_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := tagcmp.Parse(tagString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tag.MissingMinor {
|
||||
tag.Minor = "0"
|
||||
tag.MissingMinor = false
|
||||
}
|
||||
|
||||
if tag.MissingPatch {
|
||||
tag.Patch = "0"
|
||||
tag.MissingPatch = false
|
||||
}
|
||||
|
||||
if err := commitRelease(recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
|
||||
|
||||
if err := tagRelease(tagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pushRelease(tagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// btoi converts a boolean value into an integer
|
||||
func btoi(b bool) int {
|
||||
if b {
|
||||
|
@ -341,3 +218,187 @@ func btoi(b bool) int {
|
|||
|
||||
return 0
|
||||
}
|
||||
|
||||
// getTagCreateOptions constructs git tag create options
|
||||
func getTagCreateOptions() (git.CreateTagOptions, error) {
|
||||
if internal.TagMessage == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "git tag message",
|
||||
Default: "chore: publish new release",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.TagMessage); err != nil {
|
||||
return git.CreateTagOptions{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return git.CreateTagOptions{Message: internal.TagMessage}, nil
|
||||
}
|
||||
|
||||
func commitRelease(recipe recipe.Recipe) error {
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no changed committed")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !internal.Commit && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "git commit changes?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.Commit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.CommitMessage == "" && !internal.NoInput {
|
||||
prompt := &survey.Input{
|
||||
Message: "commit message",
|
||||
Default: "chore: publish new %s version",
|
||||
}
|
||||
if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Commit {
|
||||
repoPath := path.Join(config.APPS_DIR, recipe.Name)
|
||||
if err := gitPkg.Commit(repoPath, "compose.**yml", internal.CommitMessage, internal.Dry, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tagRelease(tagString string, repo *git.Repository) error {
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no git tag created")
|
||||
return nil
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createTagOptions, err := getTagCreateOptions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := abraFormatter.SmallSHA(head.Hash().String())
|
||||
logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushRelease(tagString string, repo *git.Repository) error {
|
||||
if internal.Dry {
|
||||
logrus.Info("dry run: no changes pushed")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !internal.Push && !internal.NoInput {
|
||||
prompt := &survey.Confirm{
|
||||
Message: "git push changes?",
|
||||
}
|
||||
|
||||
if err := survey.AskOne(prompt, &internal.Push); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if internal.Push {
|
||||
if err := repo.Push(&git.PushOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
|
||||
directory := path.Join(config.APPS_DIR, recipe.Name)
|
||||
repo, err := git.PlainOpen(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
|
||||
if bumpType != 0 {
|
||||
if (bumpType & (bumpType - 1)) != 0 {
|
||||
fmt.Errorf("you can only use one of: --major, --minor, --patch.")
|
||||
}
|
||||
}
|
||||
|
||||
if err := internal.PromptBumpType(tagString); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lastGitTag tagcmp.Tag
|
||||
for _, tag := range tags {
|
||||
parsed, err := tagcmp.Parse(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (lastGitTag == tagcmp.Tag{}) {
|
||||
lastGitTag = parsed
|
||||
} else if parsed.IsGreaterThan(lastGitTag) {
|
||||
lastGitTag = parsed
|
||||
}
|
||||
}
|
||||
|
||||
newTag := lastGitTag
|
||||
if bumpType > 0 {
|
||||
if internal.Patch {
|
||||
now, err := strconv.Atoi(newTag.Patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTag.Patch = strconv.Itoa(now + 1)
|
||||
} else if internal.Minor {
|
||||
now, err := strconv.Atoi(newTag.Minor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTag.Patch = "0"
|
||||
newTag.Minor = strconv.Itoa(now + 1)
|
||||
} else if internal.Major {
|
||||
now, err := strconv.Atoi(newTag.Major)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newTag.Patch = "0"
|
||||
newTag.Minor = "0"
|
||||
newTag.Major = strconv.Itoa(now + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := commitRelease(recipe); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
newTag.Metadata = mainAppVersion
|
||||
newTagString := newTag.String()
|
||||
|
||||
if err := tagRelease(newTagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pushRelease(newTagString, repo); err != nil {
|
||||
logrus.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -2,20 +2,17 @@ package git
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Commit runs a git commit
|
||||
func Commit(glob, commitMessage string, dryRun, push bool) error {
|
||||
func Commit(repoPath, glob, commitMessage string, dryRun, push bool) error {
|
||||
if commitMessage == "" {
|
||||
return fmt.Errorf("no commit message specified?")
|
||||
}
|
||||
|
||||
repoPath := path.Join(config.ABRA_DIR, "catalogue")
|
||||
commitRepo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -158,7 +158,7 @@ func EnsureVersion(recipeName, version string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("read '%s' as tags for recipe '%s'", strings.Join(parsedTags, ", "), recipeName)
|
||||
logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName)
|
||||
|
||||
if tagRef.String() == "" {
|
||||
logrus.Warnf("%s recipe has no local tag: %s? this recipe version is not released?", recipeName, version)
|
||||
|
@ -179,7 +179,7 @@ func EnsureVersion(recipeName, version string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("successfully checked '%s' out to '%s' in '%s'", recipeName, tagRef.Short(), recipeDir)
|
||||
logrus.Debugf("successfully checked %s out to %s in %s", recipeName, tagRef.Short(), recipeDir)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -194,14 +194,14 @@ func EnsureLatest(recipeName string) error {
|
|||
}
|
||||
|
||||
if !isClean {
|
||||
return fmt.Errorf("'%s' has locally unstaged changes", recipeName)
|
||||
return fmt.Errorf("%s has locally unstaged changes", recipeName)
|
||||
}
|
||||
|
||||
if err := gitPkg.EnsureGitRepo(recipeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debugf("attempting to open git repository in '%s'", recipeDir)
|
||||
logrus.Debugf("attempting to open git repository in %s", recipeDir)
|
||||
|
||||
repo, err := git.PlainOpen(recipeDir)
|
||||
if err != nil {
|
||||
|
@ -216,7 +216,7 @@ func EnsureLatest(recipeName string) error {
|
|||
branch := "master"
|
||||
if _, err := repo.Branch("master"); err != nil {
|
||||
if _, err := repo.Branch("main"); err != nil {
|
||||
logrus.Debugf("failed to select branch in '%s'", path.Join(config.APPS_DIR, recipeName))
|
||||
logrus.Debugf("failed to select branch in %s", path.Join(config.APPS_DIR, recipeName))
|
||||
return err
|
||||
}
|
||||
branch = "main"
|
||||
|
@ -230,7 +230,7 @@ func EnsureLatest(recipeName string) error {
|
|||
}
|
||||
|
||||
if err := worktree.Checkout(checkOutOpts); err != nil {
|
||||
logrus.Debugf("failed to check out '%s' in '%s'", branch, recipeDir)
|
||||
logrus.Debugf("failed to check out %s in %s", branch, recipeDir)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -271,3 +271,22 @@ func GetRecipesLocal() ([]string, error) {
|
|||
|
||||
return recipes, nil
|
||||
}
|
||||
|
||||
// GetVersionLabelLocal retrieves the version label on the local recipe config
|
||||
func GetVersionLabelLocal(recipe Recipe) (string, error) {
|
||||
var label string
|
||||
|
||||
for _, service := range recipe.Config.Services {
|
||||
for label, value := range service.Deploy.Labels {
|
||||
if strings.HasPrefix(label, "coop-cloud") {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if label == "" {
|
||||
return label, fmt.Errorf("unable to retrieve synced version label for %s", recipe.Name)
|
||||
}
|
||||
|
||||
return label, nil
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue