Give delivery facade ownership of builds
This commit is contained in:
490
delivery_test.go
Normal file
490
delivery_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
)
|
||||
|
||||
func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
var progress strings.Builder
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
Output: &progress,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Deliver() error = %v; result = %#v", err, result)
|
||||
}
|
||||
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit {
|
||||
t.Fatalf("delivery result = %#v", result)
|
||||
}
|
||||
if len(result.Uploaded) != 2 || len(remote.assets) != 2 {
|
||||
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets))
|
||||
}
|
||||
if !strings.Contains(progress.String(), "checking fixture version output") {
|
||||
t.Fatalf("build progress = %q", progress.String())
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("managed build output remains: entries=%v error=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
outputDir := filepath.Join(t.TempDir(), "caller-owned-assets")
|
||||
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Spec: releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
OutputDir: outputDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
server, _ := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &prebuilt,
|
||||
})
|
||||
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||
t.Fatalf("Deliver() = %#v, %v", result, err)
|
||||
}
|
||||
for _, artifact := range prebuilt.Artifacts {
|
||||
if _, err := os.Stat(artifact.Path); err != nil {
|
||||
t.Errorf("caller-owned artifact %q was removed: %v", artifact.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
build *releasekit.BuildSpec
|
||||
prebuilt *releasekit.BuildResult
|
||||
}{
|
||||
{name: "no build"},
|
||||
{name: "empty target set", build: &releasekit.BuildSpec{}},
|
||||
{name: "empty prebuilt result", prebuilt: &releasekit.BuildResult{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
var progress strings.Builder
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: test.build,
|
||||
Prebuilt: test.prebuilt,
|
||||
Output: &progress,
|
||||
})
|
||||
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||
t.Fatalf("Deliver() = %#v, %v", result, err)
|
||||
}
|
||||
if len(remote.assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 {
|
||||
t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.assets))
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("page-only delivery emitted build progress: %q", progress.String())
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("page-only delivery allocated temporary output: entries=%v error=%v", entries, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
writeBuildFixture(t, repository, "local-change.txt", "dirty source")
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
var progress strings.Builder
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
Output: &progress,
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err)
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("build started before source eligibility passed: %q", progress.String())
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
writeBuildFixture(t, repository, "later-change.txt", "new commit")
|
||||
buildGit(t, repository, "add", "--all")
|
||||
buildGit(t, repository, "commit", "-m", "advance checkout after release tag")
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
var progress strings.Builder
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
Output: &progress,
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == remote.commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err)
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("build started before tag/HEAD agreement: %q", progress.String())
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsInvalidCommittedNoteBeforeBuildOrHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumerWithNote(t, "# Different Product v1.2.3\n\nWrong product.\n")
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
var progress strings.Builder
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
Output: &progress,
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err)
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
t.Fatalf("build started before committed-note validation: %q", progress.String())
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
writer := &sourceChangeWriter{change: func() {
|
||||
source, err := os.ReadFile(filepath.Join(repository, "cmd/app/main.go"))
|
||||
if err != nil {
|
||||
t.Errorf("read application source: %v", err)
|
||||
return
|
||||
}
|
||||
writeBuildFixture(t, repository, "cmd/app/main.go", string(source)+"\n// changed during delivery build\n")
|
||||
}}
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
Output: writer,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/missing",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit {
|
||||
t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
temporaryRoot := t.TempDir()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
writer := deliveryCancelWriter{cancel: cancel}
|
||||
|
||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
||||
},
|
||||
Output: writer,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
|
||||
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverRejectsBothBuildSourcesBeforeHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
|
||||
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository,
|
||||
Product: "Fixture",
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: &releasekit.BuildSpec{},
|
||||
Prebuilt: &releasekit.BuildResult{},
|
||||
})
|
||||
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") {
|
||||
t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests = %d, want none", remote.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func newTaggedDeliveryConsumer(t *testing.T) string {
|
||||
return newTaggedDeliveryConsumerWithNote(t, "# Fixture v1.2.3\n\nDelivery fixture.\n")
|
||||
}
|
||||
|
||||
func newTaggedDeliveryConsumerWithNote(t *testing.T, note string) string {
|
||||
t.Helper()
|
||||
repository := newBuildConsumer(t, `package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
)
|
||||
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
|
||||
`)
|
||||
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", note)
|
||||
buildGit(t, repository, "add", "--all")
|
||||
buildGit(t, repository, "commit", "-m", "add release note")
|
||||
buildGit(t, repository, "tag", "v1.2.3")
|
||||
return repository
|
||||
}
|
||||
|
||||
func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination {
|
||||
return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
|
||||
}
|
||||
|
||||
type deliveryCancelWriter struct{ cancel context.CancelFunc }
|
||||
|
||||
func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
|
||||
writer.cancel()
|
||||
return len(content), nil
|
||||
}
|
||||
|
||||
type facadeDeliveryServer struct {
|
||||
t *testing.T
|
||||
commit string
|
||||
page bool
|
||||
requests int
|
||||
assets map[string][]byte
|
||||
url string
|
||||
}
|
||||
|
||||
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
||||
t.Helper()
|
||||
state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), assets: make(map[string][]byte)}
|
||||
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
|
||||
state.url = server.URL
|
||||
return server, state
|
||||
}
|
||||
|
||||
func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
state.requests++
|
||||
if r.Header.Get("Authorization") != "token fixture-token" {
|
||||
state.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
||||
if !state.page {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases":
|
||||
var payload struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Target string `json:"target_commitish"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
state.t.Errorf("decode release payload: %v", err)
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Tag != "v1.2.3" || payload.Target != state.commit || payload.Name != "Fixture v1.2.3" {
|
||||
state.t.Errorf("release payload = %#v", payload)
|
||||
}
|
||||
state.page = true
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||
if r.Method == http.MethodGet {
|
||||
list := make([]map[string]any, 0, len(state.assets))
|
||||
for name, content := range state.assets {
|
||||
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": state.url + "/files/" + name})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(list)
|
||||
return
|
||||
}
|
||||
name := r.URL.Query().Get("name")
|
||||
file, _, err := r.FormFile("attachment")
|
||||
if err != nil {
|
||||
state.t.Errorf("read upload %q: %v", name, err)
|
||||
http.Error(w, "invalid multipart upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
state.t.Errorf("read upload %q: %v", name, err)
|
||||
}
|
||||
state.assets[name] = content
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": len(state.assets), "name": name, "size": len(content)})
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
content, ok := state.assets[name]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(content)
|
||||
return
|
||||
}
|
||||
state.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3",
|
||||
"target_commitish": state.commit, "name": "Fixture v1.2.3",
|
||||
"body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false,
|
||||
})
|
||||
}
|
||||
|
||||
func mapKeys(values map[string][]byte) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user