added
This commit is contained in:
@@ -9,13 +9,136 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
build-debian:
|
||||
name: Build Debian 13
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: debian:13-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y git golang-go ca-certificates
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: go build -v -o gh-repo-bootstrap-linux-debian13-amd64 main.go
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: cli/gh-extension-precompile@v2
|
||||
name: gh-repo-bootstrap-linux-debian13-amd64
|
||||
path: gh-repo-bootstrap-linux-debian13-amd64
|
||||
|
||||
build-arch:
|
||||
name: Build Arch Linux
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm git go ca-certificates
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: go build -v -o gh-repo-bootstrap-linux-arch-amd64 main.go
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
go_version: stable
|
||||
name: gh-repo-bootstrap-linux-arch-amd64
|
||||
path: gh-repo-bootstrap-linux-arch-amd64
|
||||
|
||||
build-fedora:
|
||||
name: Build Fedora 44
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: fedora:44
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
dnf install -y git golang ca-certificates
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: go build -v -o gh-repo-bootstrap-linux-fedora44-amd64 main.go
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gh-repo-bootstrap-linux-fedora44-amd64
|
||||
path: gh-repo-bootstrap-linux-fedora44-amd64
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.3'
|
||||
- name: Build
|
||||
run: go build -v -o gh-repo-bootstrap-windows-amd64.exe main.go
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gh-repo-bootstrap-windows-amd64.exe
|
||||
path: gh-repo-bootstrap-windows-amd64.exe
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.3'
|
||||
- name: Build
|
||||
run: go build -v -o gh-repo-bootstrap-darwin-arm64 main.go
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gh-repo-bootstrap-darwin-arm64
|
||||
path: gh-repo-bootstrap-darwin-arm64
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: [build-debian, build-arch, build-fedora, build-windows, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: bin-artifacts
|
||||
|
||||
- name: Prepare Release Assets and Checksums
|
||||
run: |
|
||||
mkdir release-assets
|
||||
find bin-artifacts -type f -exec cp {} release-assets/ \;
|
||||
|
||||
cd release-assets
|
||||
echo "## SHA256 Checksums" > ../release_notes.txt
|
||||
echo "" >> ../release_notes.txt
|
||||
echo "| Filename | SHA256 Checksum |" >> ../release_notes.txt
|
||||
echo "| --- | --- |" >> ../release_notes.txt
|
||||
for file in *; do
|
||||
sha=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "| \`$file\` | \`$sha\` |" >> ../release_notes.txt
|
||||
done
|
||||
|
||||
cat ../release_notes.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--title "${{ github.ref_name }}" \
|
||||
--notes-file release_notes.txt \
|
||||
release-assets/*
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.3'
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
go test -v -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
+1
-7
@@ -1,7 +1 @@
|
||||
# Pulumi
|
||||
gh-repo-bootstrap
|
||||
|
||||
# Editors / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
coverage.out .gitignore
|
||||
|
||||
@@ -78,9 +78,11 @@ func (r *Resolver) ResolveTeam(s string) (int, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
var ExecCommand = exec.Command
|
||||
|
||||
// ghAPIID runs `gh api <path>` and returns the `.id` field of the response.
|
||||
func ghAPIID(path string) (int, error) {
|
||||
cmd := exec.Command("gh", "api", path)
|
||||
cmd := ExecCommand("gh", "api", path)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package githubapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
defer os.Exit(0)
|
||||
|
||||
args := os.Args
|
||||
for i, arg := range args {
|
||||
if arg == "--" {
|
||||
args = args[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "invalid args: %v\n", args)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
command := args[0]
|
||||
subCmd := args[1]
|
||||
path := args[2]
|
||||
|
||||
if command != "gh" || subCmd != "api" {
|
||||
fmt.Fprintf(os.Stderr, "expected command 'gh api', got: %s %s\n", command, subCmd)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "users/octocat":
|
||||
fmt.Print(`{"id":583234}`)
|
||||
case path == "users/error-user":
|
||||
fmt.Fprint(os.Stderr, "http error 404")
|
||||
os.Exit(1)
|
||||
case path == "users/no-id-user":
|
||||
fmt.Print(`{"login":"no-id-user"}`)
|
||||
case path == "users/bad-json-user":
|
||||
fmt.Print(`{invalid}`)
|
||||
case path == "orgs/JMR-dev/teams/release-managers":
|
||||
fmt.Print(`{"id":98765}`)
|
||||
case path == "orgs/JMR-dev/teams/error-team":
|
||||
fmt.Fprint(os.Stderr, "http error 404")
|
||||
os.Exit(1)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown path: %s\n", path)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func mockExec(command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--", command}
|
||||
cs = append(cs, args...)
|
||||
cmd := exec.Command(os.Args[0], cs...)
|
||||
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestResolveUser(t *testing.T) {
|
||||
oldExec := ExecCommand
|
||||
ExecCommand = mockExec
|
||||
defer func() { ExecCommand = oldExec }()
|
||||
|
||||
r := New()
|
||||
|
||||
// 1. Numeric ID passes through
|
||||
id, err := r.ResolveUser("12345")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for numeric user ID: %v", err)
|
||||
}
|
||||
if id != 12345 {
|
||||
t.Errorf("expected 12345, got %d", id)
|
||||
}
|
||||
|
||||
// 2. Resolve login (with leading @)
|
||||
id, err = r.ResolveUser("@octocat")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for user @octocat: %v", err)
|
||||
}
|
||||
if id != 583234 {
|
||||
t.Errorf("expected 583234, got %d", id)
|
||||
}
|
||||
|
||||
// 3. Cached lookup
|
||||
id, err = r.ResolveUser("octocat")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for user octocat (cached): %v", err)
|
||||
}
|
||||
if id != 583234 {
|
||||
t.Errorf("expected 583234, got %d", id)
|
||||
}
|
||||
|
||||
// 4. API Error
|
||||
_, err = r.ResolveUser("error-user")
|
||||
if err == nil || !strings.Contains(err.Error(), "http error 404") {
|
||||
t.Errorf("expected http error 404, got %v", err)
|
||||
}
|
||||
|
||||
// 5. No ID field in response
|
||||
_, err = r.ResolveUser("no-id-user")
|
||||
if err == nil || !strings.Contains(err.Error(), "returned no id") {
|
||||
t.Errorf("expected 'returned no id' error, got %v", err)
|
||||
}
|
||||
|
||||
// 6. Bad JSON response
|
||||
_, err = r.ResolveUser("bad-json-user")
|
||||
if err == nil || !strings.Contains(err.Error(), "decoding gh api") {
|
||||
t.Errorf("expected decoding error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTeam(t *testing.T) {
|
||||
oldExec := ExecCommand
|
||||
ExecCommand = mockExec
|
||||
defer func() { ExecCommand = oldExec }()
|
||||
|
||||
r := New()
|
||||
|
||||
// 1. Numeric ID passes through
|
||||
id, err := r.ResolveTeam("54321")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for numeric team ID: %v", err)
|
||||
}
|
||||
if id != 54321 {
|
||||
t.Errorf("expected 54321, got %d", id)
|
||||
}
|
||||
|
||||
// 2. Bare slug rejected
|
||||
_, err = r.ResolveTeam("release-managers")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be in the form org/team-slug") {
|
||||
t.Errorf("expected format error, got %v", err)
|
||||
}
|
||||
|
||||
// 3. Invalid formats
|
||||
for _, invalid := range []string{"org/", "/team"} {
|
||||
_, err = r.ResolveTeam(invalid)
|
||||
if err == nil || !strings.Contains(err.Error(), "must be in the form org/team-slug") {
|
||||
t.Errorf("expected format error for %q, got %v", invalid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve slug
|
||||
id, err = r.ResolveTeam("JMR-dev/release-managers")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for team: %v", err)
|
||||
}
|
||||
if id != 98765 {
|
||||
t.Errorf("expected 98765, got %d", id)
|
||||
}
|
||||
|
||||
// 5. Cached slug
|
||||
id, err = r.ResolveTeam("JMR-dev/release-managers")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for team (cached): %v", err)
|
||||
}
|
||||
if id != 98765 {
|
||||
t.Errorf("expected 98765, got %d", id)
|
||||
}
|
||||
|
||||
// 6. API Error
|
||||
_, err = r.ResolveTeam("JMR-dev/error-team")
|
||||
if err == nil || !strings.Contains(err.Error(), "http error 404") {
|
||||
t.Errorf("expected http error 404, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var IsTerminal = term.IsTerminal
|
||||
|
||||
// IsInteractive reports whether stdin is a terminal.
|
||||
func IsInteractive() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
return IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// Reader reads prompts from in and writes them to out. Use New() for the
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsInteractive(t *testing.T) {
|
||||
oldIsTerminal := IsTerminal
|
||||
defer func() { IsTerminal = oldIsTerminal }()
|
||||
|
||||
// Test interactive mode
|
||||
IsTerminal = func(fd int) bool {
|
||||
return true
|
||||
}
|
||||
if !IsInteractive() {
|
||||
t.Error("expected IsInteractive to be true")
|
||||
}
|
||||
|
||||
// Test non-interactive mode
|
||||
IsTerminal = func(fd int) bool {
|
||||
return false
|
||||
}
|
||||
if IsInteractive() {
|
||||
t.Error("expected IsInteractive to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
reader := New()
|
||||
if reader == nil {
|
||||
t.Fatal("expected reader to not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read(p []byte) (n int, err error) {
|
||||
return 0, errors.New("read error")
|
||||
}
|
||||
|
||||
func TestReader_Line(t *testing.T) {
|
||||
// 1. Success path
|
||||
in := bytes.NewBufferString("hello\n")
|
||||
out := &bytes.Buffer{}
|
||||
p := NewFromReader(in, out)
|
||||
val, err := p.Line("Enter something: ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", val)
|
||||
}
|
||||
if out.String() != "Enter something: " {
|
||||
t.Errorf("expected prompt output, got %q", out.String())
|
||||
}
|
||||
|
||||
// 2. EOF with input
|
||||
in = bytes.NewBufferString("hello")
|
||||
out = &bytes.Buffer{}
|
||||
p = NewFromReader(in, out)
|
||||
val, err = p.Line("Enter: ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on EOF with content: %v", err)
|
||||
}
|
||||
if val != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", val)
|
||||
}
|
||||
|
||||
// 3. Reader error
|
||||
out = &bytes.Buffer{}
|
||||
p = NewFromReader(errReader{}, out)
|
||||
_, err = p.Line("Enter: ")
|
||||
if err == nil || !strings.Contains(err.Error(), "read error") {
|
||||
t.Errorf("expected read error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReader_Choice(t *testing.T) {
|
||||
// 1. Valid choice first attempt
|
||||
in := bytes.NewBufferString("public\n")
|
||||
out := &bytes.Buffer{}
|
||||
p := NewFromReader(in, out)
|
||||
val, err := p.Choice("Visibility [public/private]: ", "private", []string{"public", "private"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != "public" {
|
||||
t.Errorf("expected 'public', got %q", val)
|
||||
}
|
||||
|
||||
// 2. Use default choice on empty line
|
||||
in = bytes.NewBufferString("\n")
|
||||
out = &bytes.Buffer{}
|
||||
p = NewFromReader(in, out)
|
||||
val, err = p.Choice("Visibility [public/private]: ", "private", []string{"public", "private"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != "private" {
|
||||
t.Errorf("expected 'private', got %q", val)
|
||||
}
|
||||
|
||||
// 3. Invalid choice followed by valid choice
|
||||
in = bytes.NewBufferString("invalid\nprivate\n")
|
||||
out = &bytes.Buffer{}
|
||||
p = NewFromReader(in, out)
|
||||
val, err = p.Choice("Visibility: ", "private", []string{"public", "private"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != "private" {
|
||||
t.Errorf("expected 'private', got %q", val)
|
||||
}
|
||||
if !strings.Contains(out.String(), "invalid value \"invalid\"") {
|
||||
t.Errorf("expected warning in output, got %q", out.String())
|
||||
}
|
||||
|
||||
// 4. Exceed maximum attempts
|
||||
in = bytes.NewBufferString("invalid1\ninvalid2\ninvalid3\n")
|
||||
out = &bytes.Buffer{}
|
||||
p = NewFromReader(in, out)
|
||||
_, err = p.Choice("Visibility: ", "private", []string{"public", "private"})
|
||||
if err == nil || !strings.Contains(err.Error(), "no valid answer after 3 attempts") {
|
||||
t.Errorf("expected error, got %v", err)
|
||||
}
|
||||
|
||||
// 5. Line read error inside Choice
|
||||
out = &bytes.Buffer{}
|
||||
p = NewFromReader(errReader{}, out)
|
||||
_, err = p.Choice("Visibility: ", "private", []string{"public", "private"})
|
||||
if err == nil || !strings.Contains(err.Error(), "read error") {
|
||||
t.Errorf("expected read error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pulumiprog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
|
||||
"github.com/JMR-dev/gh-repo-bootstrap/internal/secrets"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
||||
)
|
||||
|
||||
type mockResources struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (m mockResources) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) {
|
||||
outputs := args.Inputs.Mappable()
|
||||
return args.Name + "_id", resource.NewPropertyMapFromMap(outputs), nil
|
||||
}
|
||||
|
||||
func (m mockResources) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) {
|
||||
return args.Args, nil
|
||||
}
|
||||
|
||||
func TestBuild_CreateMode(t *testing.T) {
|
||||
inputs := Inputs{
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
Reviews: 2,
|
||||
Signed: true,
|
||||
RulesetName: "test-ruleset",
|
||||
Environments: []ResolvedEnv{
|
||||
{
|
||||
Name: "production",
|
||||
WaitTimer: intPtr(10),
|
||||
PreventSelfReview: boolPtr(true),
|
||||
CanAdminsBypass: boolPtr(false),
|
||||
ReviewerUserIDs: []int{123},
|
||||
ReviewerTeamIDs: []int{456},
|
||||
BranchPolicy: "custom",
|
||||
BranchPatterns: []string{"release/*"},
|
||||
},
|
||||
{
|
||||
Name: "staging",
|
||||
BranchPolicy: "protected",
|
||||
},
|
||||
{
|
||||
Name: "dev",
|
||||
BranchPolicy: "none",
|
||||
},
|
||||
},
|
||||
Bypass: []cli.BypassActor{
|
||||
{ActorID: 99, ActorType: "Team", BypassMode: "pull_request"},
|
||||
{ActorID: 100, ActorType: "RepositoryRole", BypassMode: "always"},
|
||||
},
|
||||
RepoSecrets: []secrets.Pair{
|
||||
{Name: "REPO_SECRET", Value: "secret-val"},
|
||||
},
|
||||
EnvSecrets: []secrets.EnvFile{
|
||||
{
|
||||
Env: "production",
|
||||
Secrets: []secrets.Pair{
|
||||
{Name: "ENV_SECRET", Value: "env-secret-val"},
|
||||
},
|
||||
},
|
||||
},
|
||||
RepoMode: cli.RepoModeCreate,
|
||||
RepoSettings: cli.RepoSettings{
|
||||
Visibility: "private",
|
||||
Description: strPtr("hello description"),
|
||||
DefaultBranch: "main",
|
||||
Topics: []string{"go", "api"},
|
||||
AllowMergeCommit: boolPtr(false),
|
||||
AllowSquashMerge: boolPtr(true),
|
||||
AllowRebaseMerge: boolPtr(false),
|
||||
DeleteBranchOnMerge: boolPtr(true),
|
||||
AutoInit: boolPtr(true),
|
||||
LicenseTemplate: "mit",
|
||||
GitignoreTemplate: "Go",
|
||||
},
|
||||
}
|
||||
|
||||
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
|
||||
if err != nil {
|
||||
t.Fatalf("Pulumi program failed in Create mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_ManageMode(t *testing.T) {
|
||||
inputs := Inputs{
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
RepoMode: cli.RepoModeManage,
|
||||
RepoSettings: cli.RepoSettings{},
|
||||
}
|
||||
|
||||
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
|
||||
if err != nil {
|
||||
t.Fatalf("Pulumi program failed in Manage mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_DataMode(t *testing.T) {
|
||||
inputs := Inputs{
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
RepoMode: cli.RepoModeData,
|
||||
}
|
||||
|
||||
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
|
||||
if err != nil {
|
||||
t.Fatalf("Pulumi program failed in Data mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_EnvSecretsTargetMismatch(t *testing.T) {
|
||||
inputs := Inputs{
|
||||
Owner: "test-owner",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
EnvSecrets: []secrets.EnvFile{
|
||||
{
|
||||
Env: "non-existent-env",
|
||||
Secrets: []secrets.Pair{
|
||||
{Name: "ENV_SECRET", Value: "val"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := pulumi.RunErr(Build(inputs), pulumi.WithMocks("project", "stack", mockResources{t: t}))
|
||||
if err == nil || !strings.Contains(err.Error(), "has no matching environment") {
|
||||
t.Fatalf("expected target mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,6 +34,23 @@ const (
|
||||
stackName = "bootstrap"
|
||||
)
|
||||
|
||||
var execCommand = exec.Command
|
||||
|
||||
type stackInterface interface {
|
||||
SetConfig(ctx context.Context, key string, val auto.ConfigValue) error
|
||||
Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error)
|
||||
Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error)
|
||||
Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error)
|
||||
}
|
||||
|
||||
var upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
|
||||
s, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Run executes the requested action against the GitHub repo described by opts.
|
||||
func Run(ctx context.Context, opts *cli.Options) error {
|
||||
// --- TOML config takes over the Options struct if --config was set ---
|
||||
@@ -67,7 +85,7 @@ func Run(ctx context.Context, opts *cli.Options) error {
|
||||
|
||||
// --- Auth: prefer caller-supplied GITHUB_TOKEN, else borrow from gh ---
|
||||
if os.Getenv("GITHUB_TOKEN") == "" {
|
||||
out, err := exec.Command("gh", "auth", "token").Output()
|
||||
out, err := execCommand("gh", "auth", "token").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no GITHUB_TOKEN set and `gh auth token` failed; run `gh auth login` first")
|
||||
}
|
||||
@@ -158,7 +176,7 @@ func Run(ctx context.Context, opts *cli.Options) error {
|
||||
fmt.Println(">>> Run with --plan first to review the import + any drift reconciliation.")
|
||||
}
|
||||
|
||||
stack, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program,
|
||||
stack, err := upsertStack(ctx, stackName, projectName, program,
|
||||
auto.WorkDir(stateDir),
|
||||
auto.EnvVars(map[string]string{
|
||||
"PULUMI_BACKEND_URL": backendURL,
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
|
||||
"github.com/JMR-dev/gh-repo-bootstrap/internal/githubapi"
|
||||
"github.com/JMR-dev/gh-repo-bootstrap/internal/prompt"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/auto"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
|
||||
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
||||
)
|
||||
|
||||
// Helper process for mocking gh CLI in runner tests.
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
defer os.Exit(0)
|
||||
|
||||
args := os.Args
|
||||
for i, arg := range args {
|
||||
if arg == "--" {
|
||||
args = args[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "invalid args: %v\n", args)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
command := args[0]
|
||||
if command != "gh" {
|
||||
fmt.Fprintf(os.Stderr, "expected 'gh', got: %s\n", command)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
subCmd := args[1]
|
||||
switch subCmd {
|
||||
case "auth":
|
||||
if len(args) >= 3 && args[2] == "token" {
|
||||
fmt.Print("gh_mock_token\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "unknown auth subcommand\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
case "api":
|
||||
if len(args) >= 3 {
|
||||
path := args[2]
|
||||
switch {
|
||||
case path == "users/octocat":
|
||||
fmt.Print(`{"id":583234}`)
|
||||
case path == "orgs/JMR-dev/teams/release":
|
||||
fmt.Print(`{"id":98765}`)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown path: %s\n", path)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", subCmd)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func mockExec(command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--", command}
|
||||
cs = append(cs, args...)
|
||||
cmd := exec.Command(os.Args[0], cs...)
|
||||
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// mockStack implements stackInterface.
|
||||
type mockStack struct {
|
||||
setConfigCalls map[string]string
|
||||
actionCalled string
|
||||
failAction bool
|
||||
}
|
||||
|
||||
func (m *mockStack) SetConfig(ctx context.Context, key string, val auto.ConfigValue) error {
|
||||
m.setConfigCalls[key] = val.Value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStack) Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error) {
|
||||
m.actionCalled = "up"
|
||||
if m.failAction {
|
||||
return auto.UpResult{}, errors.New("up error")
|
||||
}
|
||||
return auto.UpResult{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStack) Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error) {
|
||||
m.actionCalled = "preview"
|
||||
if m.failAction {
|
||||
return auto.PreviewResult{}, errors.New("preview error")
|
||||
}
|
||||
return auto.PreviewResult{}, nil
|
||||
}
|
||||
|
||||
func (m *mockStack) Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error) {
|
||||
m.actionCalled = "destroy"
|
||||
if m.failAction {
|
||||
return auto.DestroyResult{}, errors.New("destroy error")
|
||||
}
|
||||
return auto.DestroyResult{}, nil
|
||||
}
|
||||
|
||||
func TestRun_Apply(t *testing.T) {
|
||||
oldExec := execCommand
|
||||
oldGithubExec := githubapi.ExecCommand
|
||||
oldUpsert := upsertStack
|
||||
execCommand = mockExec
|
||||
githubapi.ExecCommand = mockExec
|
||||
defer func() {
|
||||
execCommand = oldExec
|
||||
githubapi.ExecCommand = oldGithubExec
|
||||
upsertStack = oldUpsert
|
||||
}()
|
||||
|
||||
// Mock upsertStack
|
||||
mStack := &mockStack{setConfigCalls: make(map[string]string)}
|
||||
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
|
||||
return mStack, nil
|
||||
}
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
opts := &cli.Options{
|
||||
Owner: "JMR-dev",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
Action: cli.ActionApply,
|
||||
StateDir: stateDir,
|
||||
Environments: []*cli.EnvSpec{
|
||||
{
|
||||
Name: "production",
|
||||
ReviewerUsers: []string{"octocat"},
|
||||
ReviewerTeams: []string{"JMR-dev/release"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Make sure GITHUB_TOKEN is cleared to exercise fallback.
|
||||
oldToken := os.Getenv("GITHUB_TOKEN")
|
||||
os.Unsetenv("GITHUB_TOKEN")
|
||||
defer os.Setenv("GITHUB_TOKEN", oldToken)
|
||||
|
||||
err := Run(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if mStack.actionCalled != "up" {
|
||||
t.Errorf("expected stack action 'up', got: %s", mStack.actionCalled)
|
||||
}
|
||||
|
||||
if val, ok := mStack.setConfigCalls["github:owner"]; !ok || val != "JMR-dev" {
|
||||
t.Errorf("expected github:owner config to be JMR-dev, got %s", val)
|
||||
}
|
||||
|
||||
// Verify GITHUB_TOKEN is set after Run via fallback command.
|
||||
if os.Getenv("GITHUB_TOKEN") != "gh_mock_token" {
|
||||
t.Errorf("expected GITHUB_TOKEN to be set to 'gh_mock_token', got: %s", os.Getenv("GITHUB_TOKEN"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_PlanAndDestroy(t *testing.T) {
|
||||
oldExec := execCommand
|
||||
oldUpsert := upsertStack
|
||||
execCommand = mockExec
|
||||
defer func() {
|
||||
execCommand = oldExec
|
||||
upsertStack = oldUpsert
|
||||
}()
|
||||
|
||||
mStack := &mockStack{setConfigCalls: make(map[string]string)}
|
||||
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
|
||||
return mStack, nil
|
||||
}
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
// Test Plan
|
||||
optsPlan := &cli.Options{
|
||||
Owner: "JMR-dev",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
Action: cli.ActionPlan,
|
||||
StateDir: stateDir,
|
||||
}
|
||||
err := Run(context.Background(), optsPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected plan error: %v", err)
|
||||
}
|
||||
if mStack.actionCalled != "preview" {
|
||||
t.Errorf("expected preview, got %s", mStack.actionCalled)
|
||||
}
|
||||
|
||||
// Test Destroy
|
||||
optsDestroy := &cli.Options{
|
||||
Owner: "JMR-dev",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
Action: cli.ActionDestroy,
|
||||
StateDir: stateDir,
|
||||
}
|
||||
err = Run(context.Background(), optsDestroy)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected destroy error: %v", err)
|
||||
}
|
||||
if mStack.actionCalled != "destroy" {
|
||||
t.Errorf("expected destroy, got %s", mStack.actionCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_SecretsAndPassphrase(t *testing.T) {
|
||||
oldExec := execCommand
|
||||
oldUpsert := upsertStack
|
||||
execCommand = mockExec
|
||||
defer func() {
|
||||
execCommand = oldExec
|
||||
upsertStack = oldUpsert
|
||||
}()
|
||||
|
||||
mStack := &mockStack{setConfigCalls: make(map[string]string)}
|
||||
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
|
||||
return mStack, nil
|
||||
}
|
||||
|
||||
stateDir := t.TempDir()
|
||||
secretsDir := t.TempDir()
|
||||
|
||||
// Create repo secrets file.
|
||||
repoSecretsFile := filepath.Join(secretsDir, "repo.tfvars")
|
||||
_ = os.WriteFile(repoSecretsFile, []byte("TOKEN = \"1234\"\n"), 0o600)
|
||||
|
||||
// Create env secrets dir and file.
|
||||
envSecretsDir := filepath.Join(secretsDir, "envs")
|
||||
_ = os.MkdirAll(envSecretsDir, 0o700)
|
||||
_ = os.WriteFile(filepath.Join(envSecretsDir, "production.tfvars"), []byte("DB_PW = \"prodpwd\"\n"), 0o600)
|
||||
|
||||
opts := &cli.Options{
|
||||
Owner: "JMR-dev",
|
||||
Repo: "test-repo",
|
||||
Branch: "main",
|
||||
Action: cli.ActionPlan,
|
||||
StateDir: stateDir,
|
||||
RepoSecretsFile: repoSecretsFile,
|
||||
EnvSecretsDir: envSecretsDir,
|
||||
Environments: []*cli.EnvSpec{
|
||||
{Name: "production"},
|
||||
},
|
||||
}
|
||||
|
||||
// Clear passphrase env var
|
||||
oldPassphrase := os.Getenv("PULUMI_CONFIG_PASSPHRASE")
|
||||
os.Unsetenv("PULUMI_CONFIG_PASSPHRASE")
|
||||
defer os.Setenv("PULUMI_CONFIG_PASSPHRASE", oldPassphrase)
|
||||
|
||||
err := Run(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify passphrase file is generated.
|
||||
passphraseFile := filepath.Join(stateDir, ".passphrase")
|
||||
if _, err := os.Stat(passphraseFile); os.IsNotExist(err) {
|
||||
t.Error("expected passphrase file to be created")
|
||||
}
|
||||
|
||||
// Run again to verify it reuses the existing passphrase.
|
||||
err = Run(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on second run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_TOMLConfigFile(t *testing.T) {
|
||||
oldExec := execCommand
|
||||
oldUpsert := upsertStack
|
||||
execCommand = mockExec
|
||||
defer func() {
|
||||
execCommand = oldExec
|
||||
upsertStack = oldUpsert
|
||||
}()
|
||||
|
||||
mStack := &mockStack{setConfigCalls: make(map[string]string)}
|
||||
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
|
||||
return mStack, nil
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.toml")
|
||||
tomlData := `
|
||||
owner = "JMR-dev"
|
||||
name = "config-repo"
|
||||
mode = "data"
|
||||
`
|
||||
_ = os.WriteFile(configFile, []byte(tomlData), 0o600)
|
||||
|
||||
opts := &cli.Options{
|
||||
ConfigFile: configFile,
|
||||
}
|
||||
|
||||
err := Run(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with TOML config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_CreateModeValidation(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
|
||||
// --create requires --visibility (or config file)
|
||||
opts := &cli.Options{
|
||||
Owner: "JMR-dev",
|
||||
Repo: "test-repo",
|
||||
RepoMode: cli.RepoModeCreate,
|
||||
StateDir: stateDir,
|
||||
}
|
||||
|
||||
err := Run(context.Background(), opts)
|
||||
if err == nil || !strings.Contains(err.Error(), "input is not a terminal") {
|
||||
t.Fatalf("expected visibility validation error (non-interactive), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_CreateModeInteractive(t *testing.T) {
|
||||
oldExec := execCommand
|
||||
oldGithubExec := githubapi.ExecCommand
|
||||
oldUpsert := upsertStack
|
||||
oldIsTerminal := prompt.IsTerminal
|
||||
execCommand = mockExec
|
||||
githubapi.ExecCommand = mockExec
|
||||
prompt.IsTerminal = func(fd int) bool { return true }
|
||||
defer func() {
|
||||
execCommand = oldExec
|
||||
githubapi.ExecCommand = oldGithubExec
|
||||
upsertStack = oldUpsert
|
||||
prompt.IsTerminal = oldIsTerminal
|
||||
}()
|
||||
|
||||
mStack := &mockStack{setConfigCalls: make(map[string]string)}
|
||||
upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
|
||||
return mStack, nil
|
||||
}
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
opts := &cli.Options{
|
||||
Owner: "JMR-dev",
|
||||
Repo: "test-repo",
|
||||
RepoMode: cli.RepoModeCreate,
|
||||
StateDir: stateDir,
|
||||
Action: cli.ActionApply,
|
||||
}
|
||||
|
||||
// Create pipe to feed stdin
|
||||
r, w, _ := os.Pipe()
|
||||
oldStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
defer func() {
|
||||
os.Stdin = oldStdin
|
||||
r.Close()
|
||||
w.Close()
|
||||
}()
|
||||
|
||||
// Feed mock input: "public" for visibility, and "my repo description" for description.
|
||||
_, _ = w.Write([]byte("public\nmy repo description\n"))
|
||||
|
||||
err := Run(context.Background(), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if opts.RepoSettings.Visibility != "public" {
|
||||
t.Errorf("expected Visibility to be public, got: %s", opts.RepoSettings.Visibility)
|
||||
}
|
||||
if opts.RepoSettings.Description == nil || *opts.RepoSettings.Description != "my repo description" {
|
||||
t.Errorf("expected Description to be 'my repo description', got: %v", opts.RepoSettings.Description)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user