Add local candidate validation
This commit is contained in:
256
internal/candidate/check.go
Normal file
256
internal/candidate/check.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Package candidate captures and validates a committed local release candidate.
|
||||
package candidate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
)
|
||||
|
||||
var ErrInvalidVersion = errors.New("invalid stable version")
|
||||
|
||||
type Candidate struct {
|
||||
RepositoryDir string
|
||||
Commit string
|
||||
Version string
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
Product string
|
||||
Version string
|
||||
Validate func(context.Context, Candidate) error
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
func Check(ctx context.Context, request Request) (Candidate, error) {
|
||||
if !validStableVersion(request.Version) {
|
||||
return Candidate{}, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
|
||||
}
|
||||
if strings.TrimSpace(request.Product) == "" {
|
||||
return Candidate{}, errors.New("product is required")
|
||||
}
|
||||
|
||||
repositoryDir, err := filepath.Abs(request.RepositoryDir)
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("resolve repository directory: %w", err)
|
||||
}
|
||||
state, err := inspectLocalState(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return Candidate{}, err
|
||||
}
|
||||
if state.branch != "main" {
|
||||
return Candidate{}, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
|
||||
}
|
||||
if state.status != "" {
|
||||
return Candidate{}, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
|
||||
}
|
||||
|
||||
path := "docs/releases/" + request.Version + ".md"
|
||||
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path)
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("read committed release note %q: %w", path, err)
|
||||
}
|
||||
if err := validateNote(note, request.Product, request.Version); err != nil {
|
||||
return Candidate{}, fmt.Errorf("release note %q: %w", path, err)
|
||||
}
|
||||
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
|
||||
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, err)
|
||||
} else if exists {
|
||||
return Candidate{}, fmt.Errorf("local tag %q already exists", request.Version)
|
||||
}
|
||||
|
||||
captured := Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version}
|
||||
if request.Validate == nil {
|
||||
err = runDefaultValidation(ctx, captured.RepositoryDir, request.Output)
|
||||
} else {
|
||||
err = request.Validate(ctx, captured)
|
||||
}
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
|
||||
}
|
||||
|
||||
current, err := inspectLocalState(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return Candidate{}, fmt.Errorf("recheck local candidate state: %w", err)
|
||||
}
|
||||
if current.branch != "main" {
|
||||
return Candidate{}, fmt.Errorf("release candidate branch changed during validation to %q", current.branch)
|
||||
}
|
||||
if current.commit != captured.Commit {
|
||||
return Candidate{}, fmt.Errorf("HEAD changed during validation from %s to %s", captured.Commit, current.commit)
|
||||
}
|
||||
if current.status != "" {
|
||||
return Candidate{}, fmt.Errorf("worktree or index changed during validation:\n%s", current.status)
|
||||
}
|
||||
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
|
||||
return Candidate{}, fmt.Errorf("recheck local tag %q: %w", request.Version, err)
|
||||
} else if exists {
|
||||
return Candidate{}, fmt.Errorf("local tag %q appeared during validation", request.Version)
|
||||
}
|
||||
|
||||
return captured, nil
|
||||
}
|
||||
|
||||
type localState struct {
|
||||
branch string
|
||||
commit string
|
||||
status string
|
||||
}
|
||||
|
||||
func inspectLocalState(ctx context.Context, repositoryDir string) (localState, error) {
|
||||
branchOutput, err := gitOutput(ctx, repositoryDir, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return localState{}, fmt.Errorf("inspect current branch: %w", err)
|
||||
}
|
||||
commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
|
||||
}
|
||||
statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all")
|
||||
if err != nil {
|
||||
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
|
||||
}
|
||||
return localState{
|
||||
branch: strings.TrimSpace(string(branchOutput)),
|
||||
commit: strings.TrimSpace(string(commitOutput)),
|
||||
status: strings.TrimSpace(string(statusOutput)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byte, error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
commandArgs := append([]string{"git"}, args...)
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: commandArgs,
|
||||
Dir: repositoryDir,
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return nil, fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), message, err)
|
||||
}
|
||||
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, error) {
|
||||
var stderr bytes.Buffer
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + version},
|
||||
Dir: repositoryDir,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return false, fmt.Errorf("%s: %w", message, err)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func runDefaultValidation(ctx context.Context, repositoryDir string, output io.Writer) error {
|
||||
environment := map[string]string{
|
||||
"GOWORK": "off",
|
||||
"GOPROXY": "off",
|
||||
"GONOPROXY": "none",
|
||||
"GOSUMDB": "off",
|
||||
"GOTOOLCHAIN": "local",
|
||||
}
|
||||
var writer io.Writer = output
|
||||
if output != nil {
|
||||
writer = &synchronizedWriter{writer: output}
|
||||
}
|
||||
return process.Run(ctx, process.Invocation{
|
||||
Args: []string{"go", "test", "-mod=readonly", "-count=1", "./..."},
|
||||
Dir: repositoryDir,
|
||||
Env: environment,
|
||||
Stdout: writer,
|
||||
Stderr: writer,
|
||||
})
|
||||
}
|
||||
|
||||
type synchronizedWriter struct {
|
||||
mu sync.Mutex
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func (w *synchronizedWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.writer.Write(p)
|
||||
}
|
||||
|
||||
func validStableVersion(version string) bool {
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
return false
|
||||
}
|
||||
components := strings.Split(version[1:], ".")
|
||||
if len(components) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, component := range components {
|
||||
if component == "" || len(component) > 1 && component[0] == '0' {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(component); i++ {
|
||||
if component[i] < '0' || component[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateNote(note []byte, product, version string) error {
|
||||
line := string(note)
|
||||
if firstLine, _, ok := strings.Cut(line, "\n"); ok {
|
||||
line = firstLine
|
||||
}
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
if !strings.HasPrefix(line, "# ") {
|
||||
return errors.New("first line must be a level-one Markdown heading")
|
||||
}
|
||||
elements := strings.Fields(strings.TrimPrefix(line, "# "))
|
||||
productElements := strings.Fields(product)
|
||||
if !containsSequence(elements, productElements) || !containsSequence(elements, []string{version}) {
|
||||
return fmt.Errorf("first heading must contain product %q and exact version %q as title elements", product, version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsSequence(elements, sequence []string) bool {
|
||||
if len(sequence) == 0 || len(sequence) > len(elements) {
|
||||
return false
|
||||
}
|
||||
for start := 0; start+len(sequence) <= len(elements); start++ {
|
||||
match := true
|
||||
for i, element := range sequence {
|
||||
if elements[start+i] != element {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
321
internal/candidate/check_test.go
Normal file
321
internal/candidate/check_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package candidate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testVersion = "v1.2.3"
|
||||
|
||||
func TestValidStableVersion(t *testing.T) {
|
||||
for _, version := range []string{
|
||||
"v0.0.0",
|
||||
"v1.2.3",
|
||||
"v123456789012345678901234567890.0.9",
|
||||
} {
|
||||
if !validStableVersion(version) {
|
||||
t.Errorf("validStableVersion(%q) = false", version)
|
||||
}
|
||||
}
|
||||
for _, version := range []string{
|
||||
"",
|
||||
"1.2.3",
|
||||
"V1.2.3",
|
||||
"v01.2.3",
|
||||
"v1.02.3",
|
||||
"v1.2.03",
|
||||
"v1.2",
|
||||
"v1.2.3-alpha",
|
||||
"v1..3",
|
||||
"v+1.2.3",
|
||||
} {
|
||||
if validStableVersion(version) {
|
||||
t.Errorf("validStableVersion(%q) = true", version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCapturesCandidateAndCallsValidationOnce(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||
calls := 0
|
||||
var seen Candidate
|
||||
candidate, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
Validate: func(_ context.Context, captured Candidate) error {
|
||||
calls++
|
||||
seen = captured
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commit := gitOutputForTest(t, repository, "rev-parse", "HEAD")
|
||||
if calls != 1 {
|
||||
t.Fatalf("validation callback called %d times, want 1", calls)
|
||||
}
|
||||
if seen != candidate {
|
||||
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
|
||||
}
|
||||
if candidate.RepositoryDir != repository || candidate.Commit != commit || candidate.Version != testVersion {
|
||||
t.Fatalf("Check() candidate = %#v", candidate)
|
||||
}
|
||||
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
|
||||
t.Fatalf("Check created local tags: %q", tags)
|
||||
}
|
||||
if refs := gitOutputForTest(t, repository, "for-each-ref", "--format=%(refname)", "refs/remotes"); refs != "" {
|
||||
t.Fatalf("Check created remote-tracking refs: %q", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
|
||||
var output bytes.Buffer
|
||||
_, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
Output: &output,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check() with default validation: %v\n%s", err, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRejectsInvalidVersionsBeforeGitAccess(t *testing.T) {
|
||||
for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
_, err := Check(context.Background(), Request{Version: version})
|
||||
if !errors.Is(err, ErrInvalidVersion) {
|
||||
t.Fatalf("Check() error = %v, want ErrInvalidVersion", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRequiresMatchingCommittedNote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
note string
|
||||
}{
|
||||
{name: "missing note"},
|
||||
{name: "wrong product", note: "# Other Product v1.2.3\n"},
|
||||
{name: "wrong version", note: "# Example Product v1.2.4\n"},
|
||||
{name: "embedded product text", note: "# Example Products v1.2.3\n"},
|
||||
{name: "not a level one heading", note: "## Example Product v1.2.3\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := newRepository(t, test.note)
|
||||
called := false
|
||||
_, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
Validate: func(context.Context, Candidate) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Check() succeeded with an invalid committed note")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("validation ran before the committed note passed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRejectsDirtyTrackedAndUntrackedState(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
value string
|
||||
}{
|
||||
{name: "tracked", path: "tracked.txt", value: "changed\n"},
|
||||
{name: "untracked", path: "new file.txt", value: "untracked\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
if err := os.WriteFile(filepath.Join(repository, test.path), []byte(test.value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Check(context.Background(), validRequest(repository))
|
||||
if err == nil || !strings.Contains(err.Error(), "must be clean") {
|
||||
t.Fatalf("Check() error = %v, want dirty state error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRequiresMainBranchAndUnusedTag(t *testing.T) {
|
||||
t.Run("detached HEAD", func(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
gitRun(t, repository, "checkout", "--detach", "HEAD")
|
||||
_, err := Check(context.Background(), validRequest(repository))
|
||||
if err == nil || !strings.Contains(err.Error(), "must be on main") {
|
||||
t.Fatalf("Check() error = %v, want branch error", err)
|
||||
}
|
||||
})
|
||||
t.Run("existing local tag", func(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
gitRun(t, repository, "tag", testVersion)
|
||||
_, err := Check(context.Background(), validRequest(repository))
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("Check() error = %v, want existing-tag error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckStopsOnCallbackFailure(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
want := errors.New("selected check failed")
|
||||
calls := 0
|
||||
_, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
Validate: func(context.Context, Candidate) error {
|
||||
calls++
|
||||
return want
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("Check() error = %v, want wrapped callback error", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("validation callback called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
validate func(*testing.T, string)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "HEAD changes",
|
||||
validate: func(t *testing.T, repository string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitRun(t, repository, "add", "later.txt")
|
||||
gitRun(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "advance HEAD")
|
||||
},
|
||||
want: "HEAD changed during validation",
|
||||
},
|
||||
{
|
||||
name: "worktree changes",
|
||||
validate: func(t *testing.T, repository string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
want: "changed during validation",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
_, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
Validate: func(context.Context, Candidate) error {
|
||||
test.validate(t, repository)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Check() error = %v, want text %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
_, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
Validate: func(context.Context, Candidate) error {
|
||||
gitRun(t, repository, "tag", testVersion)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
|
||||
t.Fatalf("Check() error = %v, want tag recheck error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validRequest(repository string) Request {
|
||||
return Request{RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validate: func(context.Context, Candidate) error { return nil }}
|
||||
}
|
||||
|
||||
func newRepository(t *testing.T, note string) string {
|
||||
t.Helper()
|
||||
repository := filepath.Join(t.TempDir(), "repository with spaces")
|
||||
if err := os.Mkdir(repository, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitRun(t, repository, "init", "--initial-branch=main")
|
||||
gitRun(t, repository, "config", "user.name", "ReleaseKit Test")
|
||||
gitRun(t, repository, "config", "user.email", "releasekit@example.test")
|
||||
gitRun(t, repository, "config", "commit.gpgsign", "false")
|
||||
writeTestFile(t, repository, "go.mod", "module example.com/candidate\n\ngo 1.25.0\n")
|
||||
writeTestFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
|
||||
writeTestFile(t, repository, "tracked.txt", "tracked\n")
|
||||
if note != "" {
|
||||
writeTestFile(t, repository, "docs/releases/"+testVersion+".md", note)
|
||||
}
|
||||
gitRun(t, repository, "add", "--all")
|
||||
gitRun(t, repository, "commit", "-m", "candidate")
|
||||
return repository
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, repository, name, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitRun(t *testing.T, repository string, args ...string) {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{"git"}, args...)
|
||||
command := exec.Command(commandArgs[0], commandArgs[1:]...)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v\n%s", commandArgs, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOutputForTest(t *testing.T, repository string, args ...string) string {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{"git"}, args...)
|
||||
command := exec.Command(commandArgs[0], commandArgs[1:]...)
|
||||
command.Dir = repository
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", commandArgs, err)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
Reference in New Issue
Block a user