Skip to content

Commit 8c13a29

Browse files
feat(cli): add cs preview github command for PR preview environments
Refactor the standalone gh-action-deploy binary into two layers: - pkg/preview/: generic, provider-agnostic preview environment engine (find, create, update, delete workspace + run pipeline) - cli/cmd/preview_github.go: GitHub-specific subcommand that reads GitHub Actions env vars and delegates to pkg/preview New commands: cs preview - parent command for preview environments cs preview github - GitHub Actions integration The generic engine in pkg/preview/ can be reused by future providers (e.g. cs preview gitlab). Signed-off-by: Alex <132889147+alexvcodesphere@users.noreply.github.com>
1 parent f1af57b commit 8c13a29

File tree

8 files changed

+1270
-0
lines changed

8 files changed

+1270
-0
lines changed

.mockery.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ packages:
3838
config:
3939
all: true
4040
interfaces:
41+
github.com/codesphere-cloud/cs-go/pkg/preview:
42+
config:
43+
all: true
44+
interfaces:

cli/cmd/preview.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Codesphere Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"github.com/spf13/cobra"
8+
)
9+
10+
type PreviewCmd struct {
11+
cmd *cobra.Command
12+
}
13+
14+
func AddPreviewCmd(rootCmd *cobra.Command, opts GlobalOptions) {
15+
preview := PreviewCmd{
16+
cmd: &cobra.Command{
17+
Use: "preview",
18+
Short: "Manage preview environments",
19+
Long: `Manage preview environments for pull requests and merge requests. Supports creating, updating, and deleting preview workspaces tied to git provider events.`,
20+
},
21+
}
22+
rootCmd.AddCommand(preview.cmd)
23+
24+
// Add provider-specific subcommands
25+
AddPreviewGitHubCmd(preview.cmd, opts)
26+
}

cli/cmd/preview_github.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) Codesphere Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"os"
10+
"strconv"
11+
"strings"
12+
"time"
13+
14+
"github.com/codesphere-cloud/cs-go/pkg/io"
15+
"github.com/codesphere-cloud/cs-go/pkg/preview"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
type PreviewGitHubCmd struct {
20+
cmd *cobra.Command
21+
Opts PreviewGitHubOpts
22+
}
23+
24+
type PreviewGitHubOpts struct {
25+
GlobalOptions
26+
PlanId *int
27+
Env *[]string
28+
VpnConfig *string
29+
Branch *string
30+
Stages *string
31+
Timeout *time.Duration
32+
}
33+
34+
func (c *PreviewGitHubCmd) RunE(_ *cobra.Command, args []string) error {
35+
client, err := NewClient(c.Opts.GlobalOptions)
36+
if err != nil {
37+
return fmt.Errorf("failed to create Codesphere client: %w", err)
38+
}
39+
40+
teamId, err := c.Opts.GetTeamId()
41+
if err != nil {
42+
return fmt.Errorf("failed to get team ID: %w", err)
43+
}
44+
45+
// Load GitHub context
46+
eventName := os.Getenv("GITHUB_EVENT_NAME")
47+
prAction, prNumber := loadGitHubEvent()
48+
repository := os.Getenv("GITHUB_REPOSITORY")
49+
serverUrl := os.Getenv("GITHUB_SERVER_URL")
50+
51+
// Determine workspace name: <repo>-#<pr>
52+
parts := strings.Split(repository, "/")
53+
repo := parts[len(parts)-1]
54+
wsName := fmt.Sprintf("%s-#%s", repo, prNumber)
55+
56+
// Resolve branch
57+
branch := c.resolveBranch()
58+
59+
// Resolve repo URL
60+
repoUrl := fmt.Sprintf("%s/%s.git", serverUrl, repository)
61+
62+
// Parse stages
63+
var stages []string
64+
for _, s := range strings.Fields(*c.Opts.Stages) {
65+
if s != "" {
66+
stages = append(stages, s)
67+
}
68+
}
69+
70+
// Parse env vars
71+
envVars := make(map[string]string)
72+
for _, e := range *c.Opts.Env {
73+
if idx := strings.Index(e, "="); idx > 0 {
74+
envVars[e[:idx]] = e[idx+1:]
75+
}
76+
}
77+
78+
cfg := preview.Config{
79+
TeamId: teamId,
80+
PlanId: *c.Opts.PlanId,
81+
Name: wsName,
82+
EnvVars: envVars,
83+
VpnConfig: *c.Opts.VpnConfig,
84+
Branch: branch,
85+
Stages: stages,
86+
RepoUrl: repoUrl,
87+
Timeout: *c.Opts.Timeout,
88+
}
89+
90+
// Determine if this is a delete operation
91+
isDelete := eventName == "pull_request" && prAction == "closed"
92+
93+
deployer := preview.NewDeployer(client)
94+
result, err := deployer.Deploy(cfg, isDelete)
95+
if err != nil {
96+
return err
97+
}
98+
99+
// Write GitHub-specific outputs
100+
if result != nil {
101+
setGitHubOutputs(result.WorkspaceId, result.WorkspaceURL)
102+
}
103+
104+
return nil
105+
}
106+
107+
// resolveBranch determines the branch to deploy with priority:
108+
// flag > GITHUB_HEAD_REF > GITHUB_REF_NAME > "main"
109+
func (c *PreviewGitHubCmd) resolveBranch() string {
110+
if c.Opts.Branch != nil && *c.Opts.Branch != "" {
111+
return *c.Opts.Branch
112+
}
113+
if headRef := os.Getenv("GITHUB_HEAD_REF"); headRef != "" {
114+
return headRef
115+
}
116+
if refName := os.Getenv("GITHUB_REF_NAME"); refName != "" {
117+
return refName
118+
}
119+
return "main"
120+
}
121+
122+
// loadGitHubEvent reads the PR action and number from GITHUB_EVENT_PATH.
123+
func loadGitHubEvent() (action string, number string) {
124+
path := os.Getenv("GITHUB_EVENT_PATH")
125+
if path == "" {
126+
return "", ""
127+
}
128+
data, err := os.ReadFile(path)
129+
if err != nil {
130+
return "", ""
131+
}
132+
var event struct {
133+
Action string `json:"action"`
134+
Number int `json:"number"`
135+
}
136+
if json.Unmarshal(data, &event) == nil {
137+
return event.Action, strconv.Itoa(event.Number)
138+
}
139+
return "", ""
140+
}
141+
142+
// setGitHubOutputs writes deployment results to GitHub Actions output files.
143+
func setGitHubOutputs(wsId int, url string) {
144+
if f := os.Getenv("GITHUB_OUTPUT"); f != "" {
145+
appendToFile(f, fmt.Sprintf("deployment-url=%s\nworkspace-id=%d\n", url, wsId))
146+
}
147+
148+
if f := os.Getenv("GITHUB_STEP_SUMMARY"); f != "" {
149+
appendToFile(f, fmt.Sprintf(
150+
"### 🚀 Codesphere Deployment\n\n| Property | Value |\n|----------|-------|\n| **URL** | [%s](%s) |\n| **Workspace** | `%d` |\n",
151+
url, url, wsId,
152+
))
153+
}
154+
}
155+
156+
func appendToFile(path, content string) {
157+
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
158+
if err != nil {
159+
return
160+
}
161+
defer f.Close() //nolint:errcheck // best-effort append
162+
_, _ = f.WriteString(content)
163+
}
164+
165+
func AddPreviewGitHubCmd(previewCmd *cobra.Command, opts GlobalOptions) {
166+
github := PreviewGitHubCmd{
167+
cmd: &cobra.Command{
168+
Use: "github",
169+
Short: "Manage GitHub PR preview environments",
170+
Long: io.Long(`Manage preview environments for GitHub Pull Requests.
171+
172+
Automatically detects the PR context from GitHub Actions environment
173+
variables (GITHUB_EVENT_NAME, GITHUB_HEAD_REF, GITHUB_REPOSITORY, etc.)
174+
and creates, updates, or deletes preview workspaces accordingly.
175+
176+
On PR open/synchronize: creates or updates a preview workspace.
177+
On PR close: deletes the preview workspace.
178+
179+
Designed to be used from GitHub Actions workflows.`),
180+
Example: io.FormatExampleCommands("preview github", []io.Example{
181+
{Cmd: "", Desc: "Deploy preview using GitHub Actions environment variables"},
182+
{Cmd: "--plan-id 20", Desc: "Deploy preview with a specific plan"},
183+
{Cmd: "--stages 'prepare test run'", Desc: "Deploy and run specific pipeline stages"},
184+
{Cmd: "--branch feature-x", Desc: "Override the branch to deploy"},
185+
}),
186+
},
187+
Opts: PreviewGitHubOpts{GlobalOptions: opts},
188+
}
189+
190+
github.Opts.PlanId = github.cmd.Flags().Int("plan-id", 8, "Plan ID for the preview workspace")
191+
github.Opts.Env = github.cmd.Flags().StringArray("env", []string{}, "Environment variables in KEY=VALUE format")
192+
github.Opts.VpnConfig = github.cmd.Flags().String("vpn-config", "", "VPN config name to connect the workspace to")
193+
github.Opts.Branch = github.cmd.Flags().StringP("branch", "b", "", "Git branch to deploy (auto-detected from GitHub context if not set)")
194+
github.Opts.Stages = github.cmd.Flags().String("stages", "prepare run", "Pipeline stages to run (space-separated: prepare test run)")
195+
github.Opts.Timeout = github.cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for workspace creation/readiness")
196+
197+
previewCmd.AddCommand(github.cmd)
198+
github.cmd.RunE = github.RunE
199+
}

cli/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ func GetRootCmd() *cobra.Command {
9494
AddGoCmd(rootCmd)
9595
AddWakeUpCmd(rootCmd, opts)
9696
AddCurlCmd(rootCmd, opts)
97+
AddPreviewCmd(rootCmd, opts)
9798

9899
return rootCmd
99100
}

0 commit comments

Comments
 (0)