Skip to content

Commit 64f0bf0

Browse files
committed
refactor: migrate ghsecret to 'gh aw secret set' subcommand
1 parent cc60e30 commit 64f0bf0

File tree

4 files changed

+199
-32
lines changed

4 files changed

+199
-32
lines changed

cmd/gh-aw/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
495495
prCmd := cli.NewPRCommand()
496496
campaignCmd := campaign.NewCommand()
497497
tokensCmd := cli.NewTokensCommand()
498+
secretCmd := cli.NewSecretCommand()
498499

499500
// Assign commands to groups
500501
// Setup Commands
@@ -504,6 +505,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
504505
removeCmd.GroupID = "setup"
505506
updateCmd.GroupID = "setup"
506507
tokensCmd.GroupID = "setup"
508+
secretCmd.GroupID = "setup"
507509

508510
// Development Commands
509511
compileCmd.GroupID = "development"
@@ -549,6 +551,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
549551
rootCmd.AddCommand(versionCmd)
550552
rootCmd.AddCommand(campaignCmd)
551553
rootCmd.AddCommand(tokensCmd)
554+
rootCmd.AddCommand(secretCmd)
552555
}
553556

554557
func main() {
Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
package main
1+
package cli
22

33
import (
44
"bufio"
55
"crypto/rand"
66
"encoding/base64"
77
"encoding/json"
88
"errors"
9-
"flag"
109
"fmt"
1110
"io"
12-
"log"
1311
"os"
1412
"strings"
1513

1614
"github.com/cli/go-gh/v2/pkg/api"
15+
"github.com/githubnext/gh-aw/pkg/console"
16+
"github.com/spf13/cobra"
1717
"golang.org/x/crypto/nacl/box"
1818
)
1919

@@ -27,46 +27,95 @@ type secretPayload struct {
2727
KeyID string `json:"key_id"`
2828
}
2929

30-
func main() {
30+
// NewSecretCommand creates the secret command group
31+
func NewSecretCommand() *cobra.Command {
32+
secretCmd := &cobra.Command{
33+
Use: "secret",
34+
Short: "Manage repository secrets",
35+
Long: `Manage GitHub Actions secrets for repositories`,
36+
}
37+
38+
// Add subcommands
39+
secretCmd.AddCommand(newSecretSetCommand())
40+
41+
return secretCmd
42+
}
43+
44+
func newSecretSetCommand() *cobra.Command {
3145
var (
32-
flagOwner = flag.String("owner", "", "GitHub repository owner or organization")
33-
flagRepo = flag.String("repo", "", "GitHub repository name")
34-
flagSecretName = flag.String("secret", "", "Secret name to create or update")
35-
flagValue = flag.String("value", "", "Secret value (if empty, read from stdin)")
36-
flagValueEnv = flag.String("value-from-env", "", "Environment variable to read secret value from")
37-
flagAPIBase = flag.String("api-url", "", "GitHub API base URL (default: https://api.github.com or $GITHUB_API_URL)")
46+
flagOwner string
47+
flagRepo string
48+
flagValue string
49+
flagValueEnv string
50+
flagAPIBase string
3851
)
3952

40-
flag.Parse()
53+
cmd := &cobra.Command{
54+
Use: "set <secret-name>",
55+
Short: "Create or update a repository secret",
56+
Long: `Create or update a GitHub Actions secret for a repository.
57+
58+
The secret value can be provided in three ways:
59+
1. Via the --value flag
60+
2. Via the --value-from-env flag (reads from environment variable)
61+
3. From stdin (if neither flag is provided)
62+
63+
Examples:
64+
# From stdin
65+
gh aw secret set MY_SECRET --owner myorg --repo myrepo
66+
67+
# From flag
68+
gh aw secret set MY_SECRET --value "secret123" --owner myorg --repo myrepo
69+
70+
# From environment variable
71+
export MY_TOKEN="secret123"
72+
gh aw secret set MY_SECRET --value-from-env MY_TOKEN --owner myorg --repo myrepo`,
73+
Args: cobra.ExactArgs(1),
74+
RunE: func(cmd *cobra.Command, args []string) error {
75+
secretName := args[0]
76+
77+
// Validate required flags
78+
if flagOwner == "" || flagRepo == "" {
79+
return fmt.Errorf("--owner and --repo flags are required")
80+
}
4181

42-
if *flagOwner == "" || *flagRepo == "" || *flagSecretName == "" {
43-
flag.Usage()
44-
os.Exit(1)
45-
}
82+
// Create GitHub REST client using go-gh
83+
opts := api.ClientOptions{}
84+
if flagAPIBase != "" {
85+
opts.Host = strings.TrimPrefix(strings.TrimPrefix(flagAPIBase, "https://"), "http://")
86+
}
87+
client, err := api.NewRESTClient(opts)
88+
if err != nil {
89+
return fmt.Errorf("cannot create GitHub client: %w", err)
90+
}
4691

47-
// Create GitHub REST client using go-gh
48-
opts := api.ClientOptions{}
49-
if *flagAPIBase != "" {
50-
opts.Host = strings.TrimPrefix(strings.TrimPrefix(*flagAPIBase, "https://"), "http://")
51-
}
52-
client, err := api.NewRESTClient(opts)
53-
if err != nil {
54-
log.Fatalf("cannot create GitHub client: %v", err)
55-
}
92+
secretValue, err := resolveSecretValueForSet(flagValueEnv, flagValue)
93+
if err != nil {
94+
return fmt.Errorf("cannot resolve secret value: %w", err)
95+
}
5696

57-
secretValue, err := resolveSecretValue(*flagValueEnv, *flagValue)
58-
if err != nil {
59-
log.Fatalf("cannot resolve secret value: %v", err)
60-
}
97+
if err := setRepoSecret(client, flagOwner, flagRepo, secretName, secretValue); err != nil {
98+
return fmt.Errorf("failed to set secret: %w", err)
99+
}
61100

62-
if err := setRepoSecret(client, *flagOwner, *flagRepo, *flagSecretName, secretValue); err != nil {
63-
log.Fatalf("failed to set secret: %v", err)
101+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Secret %s updated for %s/%s", secretName, flagOwner, flagRepo)))
102+
return nil
103+
},
64104
}
65105

66-
fmt.Printf("Secret %s updated for %s/%s\n", *flagSecretName, *flagOwner, *flagRepo)
106+
cmd.Flags().StringVar(&flagOwner, "owner", "", "GitHub repository owner or organization (required)")
107+
cmd.Flags().StringVar(&flagRepo, "repo", "", "GitHub repository name (required)")
108+
cmd.Flags().StringVar(&flagValue, "value", "", "Secret value (if empty, read from stdin)")
109+
cmd.Flags().StringVar(&flagValueEnv, "value-from-env", "", "Environment variable to read secret value from")
110+
cmd.Flags().StringVar(&flagAPIBase, "api-url", "", "GitHub API base URL (default: https://api.github.com or $GITHUB_API_URL)")
111+
112+
cmd.MarkFlagRequired("owner")
113+
cmd.MarkFlagRequired("repo")
114+
115+
return cmd
67116
}
68117

69-
func resolveSecretValue(fromEnv, fromFlag string) (string, error) {
118+
func resolveSecretValueForSet(fromEnv, fromFlag string) (string, error) {
70119
if fromEnv != "" {
71120
v := os.Getenv(fromEnv)
72121
if v == "" {

pkg/cli/secret_set_command_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestResolveSecretValueForSet(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
fromEnv string
12+
fromFlag string
13+
envValue string
14+
wantErr bool
15+
wantValue string
16+
errContains string
17+
}{
18+
{
19+
name: "from flag",
20+
fromFlag: "secret123",
21+
wantValue: "secret123",
22+
},
23+
{
24+
name: "from env var - set",
25+
fromEnv: "TEST_SECRET",
26+
envValue: "envvalue123",
27+
wantValue: "envvalue123",
28+
},
29+
{
30+
name: "from env var - empty",
31+
fromEnv: "TEST_SECRET_MISSING",
32+
wantErr: true,
33+
errContains: "not set or empty",
34+
},
35+
}
36+
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
// Set up environment
40+
if tt.envValue != "" {
41+
t.Setenv(tt.fromEnv, tt.envValue)
42+
}
43+
44+
got, err := resolveSecretValueForSet(tt.fromEnv, tt.fromFlag)
45+
if (err != nil) != tt.wantErr {
46+
t.Errorf("resolveSecretValueForSet() error = %v, wantErr %v", err, tt.wantErr)
47+
return
48+
}
49+
if tt.wantErr && tt.errContains != "" {
50+
if err == nil || !strings.Contains(err.Error(), tt.errContains) {
51+
t.Errorf("expected error containing %q, got %v", tt.errContains, err)
52+
}
53+
}
54+
if !tt.wantErr && got != tt.wantValue {
55+
t.Errorf("resolveSecretValueForSet() = %v, want %v", got, tt.wantValue)
56+
}
57+
})
58+
}
59+
}
60+
61+
func TestEncryptWithPublicKey(t *testing.T) {
62+
// Valid 32-byte public key in base64
63+
validKey := "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUY="
64+
plaintext := "my-secret-value"
65+
66+
encrypted, err := encryptWithPublicKey(validKey, plaintext)
67+
if err != nil {
68+
t.Fatalf("encryptWithPublicKey() error = %v", err)
69+
}
70+
71+
if encrypted == "" {
72+
t.Error("encryptWithPublicKey() returned empty string")
73+
}
74+
75+
// The encrypted value should be different from the plaintext
76+
if encrypted == plaintext {
77+
t.Error("encrypted value should differ from plaintext")
78+
}
79+
}
80+
81+
func TestEncryptWithPublicKeyInvalidKey(t *testing.T) {
82+
tests := []struct {
83+
name string
84+
key string
85+
plaintext string
86+
errContains string
87+
}{
88+
{
89+
name: "invalid base64",
90+
key: "not-valid-base64!@#$",
91+
plaintext: "secret",
92+
errContains: "decode public key",
93+
},
94+
{
95+
name: "wrong key length",
96+
key: "YWJjZA==", // "abcd" in base64 = 4 bytes, not 32
97+
plaintext: "secret",
98+
errContains: "unexpected public key length",
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
_, err := encryptWithPublicKey(tt.key, tt.plaintext)
105+
if err == nil {
106+
t.Fatal("encryptWithPublicKey() expected error, got nil")
107+
}
108+
if !strings.Contains(err.Error(), tt.errContains) {
109+
t.Errorf("expected error containing %q, got %v", tt.errContains, err)
110+
}
111+
})
112+
}
113+
}
114+

pkg/cli/templates/github-agentic-workflows.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ The YAML frontmatter supports these fields:
7979
- Object: Complex trigger configuration
8080
- Special: `command:` for /mention triggers
8181
- **`forks:`** - Fork allowlist for `pull_request` triggers (array or string). By default, workflows block all forks and only allow same-repo PRs. Use `["*"]` to allow all forks, or specify patterns like `["org/*", "user/repo"]`
82+
- **`lock-for-agent:`** - Lock issue when agent works on it (boolean, issues trigger only). Prevents concurrent modifications during workflow execution
8283
- **`stop-after:`** - Can be included in the `on:` object to set a deadline for workflow execution. Supports absolute timestamps ("YYYY-MM-DD HH:MM:SS") or relative time deltas (+25h, +3d, +1d12h). The minimum unit for relative deltas is hours (h). Uses precise date calculations that account for varying month lengths.
8384
- **`reaction:`** - Add emoji reactions to triggering items
8485
- **`manual-approval:`** - Require manual approval using environment protection rules

0 commit comments

Comments
 (0)