Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions cmd/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,218 @@ func updateDeploymentStatusCompleted(logger logger.Logger, apiUrl, token, deploy
return client.Do("PUT", fmt.Sprintf("/cli/deploy/upload/%s", deploymentId), payload, nil)
}

var cloudRollbackCmd = &cobra.Command{
Use: "rollback",
Short: "Rollback (undeploy) or delete a deployment from the cloud",
Long: `Rollback (undeploy) or delete a specific deployment for a project by selecting a project and deployment.

Examples:
agentuity rollback
agentuity cloud rollback
agentuity rollback --tag name
agentuity rollback --delete
`,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
logger := env.NewLogger(cmd)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
apikey, _ := util.EnsureLoggedIn(ctx, logger, cmd)
apiUrl, _, _ := util.GetURLs(logger)
deleteFlag, _ := cmd.Flags().GetBool("delete")
dir, _ := cmd.Flags().GetString("dir")

var selectedProject string
if dir != "" {
proj := project.EnsureProject(ctx, cmd)
if proj.Project == nil {
errsystem.New(errsystem.ErrApiRequest, fmt.Errorf("project not found")).ShowErrorAndExit()
}
selectedProject = proj.Project.ProjectId
} else {
projectId, _ := cmd.Flags().GetString("project")
if projectId != "" {
// look up the project by id
projects, err := project.ListProjects(ctx, logger, apiUrl, apikey)
if err != nil {
errsystem.New(errsystem.ErrApiRequest, err).ShowErrorAndExit()
}
for _, p := range projects {
if p.ID == projectId {
selectedProject = p.ID
break
}
}
if selectedProject == "" {
// this will never happen because we've already checked the project id
errsystem.New(errsystem.ErrApiRequest, fmt.Errorf("project not found")).ShowErrorAndExit()
}
}
}

question := "Select a project to rollback a deployment"
if deleteFlag {
question = "Select a project to delete a deployment"
}

if selectedProject == "" {
selectedProject = cloudSelectProject(ctx, logger, apiUrl, apikey, question)
}

if selectedProject == "" {
return
}

// Try to get tag flag
tag, _ := cmd.Flags().GetString("tag")
var selectedDeployment string
if tag != "" {
// List deployments and match by tag
var deployments []project.DeploymentListData
action := func() {
var err error
deployments, err = project.ListDeployments(ctx, logger, apiUrl, apikey, selectedProject)
if err != nil {
errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithContextMessage("Failed to list deployments")).ShowErrorAndExit()
}
}
tui.ShowSpinner("fetching deployments ...", action)
for _, d := range deployments {
for _, t := range d.Tags {
if t == tag {
selectedDeployment = d.ID
break
}
}
if selectedDeployment != "" {
break
}
}
if selectedDeployment == "" {
errsystem.New(errsystem.ErrApiRequest, fmt.Errorf("no deployment found with tag '%s'", tag)).ShowErrorAndExit()
}
} else {
question = "Select a deployment to rollback"
if deleteFlag {
question = "Select a deployment to delete"
}
selectedDeployment = cloudSelectDeployment(ctx, logger, apiUrl, apikey, selectedProject, question)
if selectedDeployment == "" {
// this will never happen because we've already checked the deployment id
errsystem.New(errsystem.ErrApiRequest, fmt.Errorf("no deployment selected")).ShowErrorAndExit()
}
}

forceFlag, _ := cmd.Flags().GetBool("force")

if !forceFlag {
what := "rollback"
if deleteFlag {
what = "delete"
}

if !tui.Ask(logger, "Are you sure you want to "+tui.Bold(what)+" the selected deployment?", true) {
fmt.Println()
tui.ShowWarning("Canceled")
return
}
fmt.Println()
}

if deleteFlag {
err := project.DeleteDeployment(ctx, logger, apiUrl, apikey, selectedProject, selectedDeployment)
if err != nil {
errsystem.New(errsystem.ErrDeleteApiKey, err, errsystem.WithContextMessage("Failed to delete deployment")).ShowErrorAndExit()
}
tui.ShowSuccess("Deployment deleted successfully")
} else {
err := project.RollbackDeployment(ctx, logger, apiUrl, apikey, selectedProject, selectedDeployment)
if err != nil {
errsystem.New(errsystem.ErrDeployProject, err, errsystem.WithContextMessage("Failed to rollback deployment")).ShowErrorAndExit()
}
Comment thread
pec1985 marked this conversation as resolved.
tui.ShowSuccess("Deployment rolled back successfully")
}
},
}

// Helper to fetch projects and prompt user to select one. Returns selected project ID or empty string.
func cloudSelectProject(ctx context.Context, logger logger.Logger, apiUrl, apikey string, prompt string) string {
var projects []project.ProjectListData
action := func() {
var err error
projects, err = project.ListProjects(ctx, logger, apiUrl, apikey)
if err != nil {
errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithContextMessage("Failed to list projects")).ShowErrorAndExit()
}
}
tui.ShowSpinner("fetching projects ...", action)
if len(projects) == 0 {
fmt.Println()
tui.ShowWarning("no projects found")
tui.ShowBanner("Create a new project", tui.Text("Use the ")+tui.Command("new")+tui.Text(" command to create a new project"), false)
return ""
}
var options []tui.Option
for _, p := range projects {
options = append(options, tui.Option{
ID: p.ID,
Text: tui.Bold(tui.PadRight(p.Name, 20, " ")) + tui.Muted(p.ID),
})
}
selected := tui.Select(logger, prompt, "", options)
if selected == "" {
tui.ShowWarning("no project selected")
}
return selected
}

func cloudSelectDeployment(ctx context.Context, logger logger.Logger, apiUrl, apikey, projectId string, prompt string) string {
var deployments []project.DeploymentListData
fetchDeploymentsAction := func() {
var err error
deployments, err = project.ListDeployments(ctx, logger, apiUrl, apikey, projectId)
if err != nil {
errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithContextMessage("Failed to list deployments")).ShowErrorAndExit()
}
}
tui.ShowSpinner("fetching deployments ...", fetchDeploymentsAction)
if len(deployments) == 0 {
tui.ShowWarning("no deployments found for this project")
os.Exit(1)
}
Comment on lines +852 to +853
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use errsystem instead of direct os.Exit

Direct calls to os.Exit(1) are inconsistent with the project's error handling pattern.

-		tui.ShowWarning("no deployments found for this project")
-		os.Exit(1)
+		errsystem.New(errsystem.ErrApiRequest, fmt.Errorf("no deployments found for this project")).ShowErrorAndExit()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
os.Exit(1)
}
if len(deployments) == 0 {
- tui.ShowWarning("no deployments found for this project")
- os.Exit(1)
+ errsystem.New(
+ errsystem.ErrApiRequest,
+ fmt.Errorf("no deployments found for this project"),
+ ).ShowErrorAndExit()
}
🤖 Prompt for AI Agents
In cmd/cloud.go around lines 852 to 853, replace the direct call to os.Exit(1)
with the project's standard error handling function errsystem. Identify the
appropriate errsystem call that signals a fatal error and use it instead of
os.Exit to maintain consistency with the project's error handling pattern.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isn't a unexpected system error which is when we use errsystem vs an expected one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jhaynie I appreciate the clarification. You're absolutely right - this is an expected condition rather than an unexpected system error. I misunderstood the error handling pattern in the codebase. The current implementation with tui.ShowWarning() followed by os.Exit(1) is the correct approach for expected error conditions.

(_/)
(•ᴥ•)
/ >📝


✏️ Learnings added
Learnt from: jhaynie
PR: agentuity/cli#313
File: cmd/cloud.go:852-853
Timestamp: 2025-05-22T18:27:10.355Z
Learning: In the agentuity CLI codebase, there's a specific error handling pattern: errsystem is used exclusively for unexpected system errors, while expected error conditions use tui.ShowWarning() and direct os.Exit(1) calls.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

var deploymentOptions []tui.Option
for _, d := range deployments {
date, err := time.Parse(time.RFC3339, d.CreatedAt)
if err != nil {
errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithContextMessage("Failed to parse deployment date")).ShowErrorAndExit()
}
var msg string
if len(d.Message) > 60 {
msg = d.Message[:57] + "..."
} else {
msg = d.Message
}
tags := strings.Join(d.Tags, ", ")
if len(tags) > 50 {
tags = tags[:50] + "..."
}

if d.Active {
deploymentOptions = append(deploymentOptions, tui.Option{
ID: d.ID,
Text: fmt.Sprintf("%s %s %-50s %s", "✅", tui.Title(date.Format(time.Stamp)), tui.Bold(tags), tui.Muted(msg)),
})
} else {
deploymentOptions = append(deploymentOptions, tui.Option{
ID: d.ID,
Text: fmt.Sprintf(" %s %-50s %s", tui.Title(date.Format(time.Stamp)), tui.Bold(tags), tui.Muted(msg)),
})
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't really love the way this looks... i might make some suggestions /changes here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, not sure what the best way is or what info to show of each deployment

}
selectedDeployment := tui.Select(logger, prompt, "", deploymentOptions)
return selectedDeployment
}

func init() {
rootCmd.AddCommand(cloudCmd)
rootCmd.AddCommand(cloudDeployCmd)
Expand Down Expand Up @@ -701,4 +913,12 @@ func init() {
cloudDeployCmd.Flags().String("format", "text", "The output format to use for results which can be either 'text' or 'json'")
cloudDeployCmd.Flags().String("org-id", "", "The organization to create the project in")
cloudDeployCmd.Flags().String("templates-dir", "", "The directory to load the templates. Defaults to loading them from the github.com/agentuity/templates repository")

rootCmd.AddCommand(cloudRollbackCmd)
cloudCmd.AddCommand(cloudRollbackCmd)
cloudRollbackCmd.Flags().String("tag", "", "Tag of the deployment to rollback")
cloudRollbackCmd.Flags().String("project", "", "Project to rollback a deployment")
cloudRollbackCmd.Flags().String("dir", "", "The directory to the project to rollback if project is not specified")
cloudRollbackCmd.Flags().Bool("force", false, "Force the rollback or delete")
cloudRollbackCmd.Flags().Bool("delete", false, "Delete the deployment instead of rolling back")
}
44 changes: 44 additions & 0 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,50 @@ func ListProjects(ctx context.Context, logger logger.Logger, baseUrl string, tok
return resp.Data, nil
}

type DeploymentListData struct {
ID string `json:"id"`
Message string `json:"message"`
Tags []string `json:"tags"`
Active bool `json:"active"`
CreatedAt string `json:"createdAt"`
}

func ListDeployments(ctx context.Context, logger logger.Logger, baseUrl string, token string, projectId string) ([]DeploymentListData, error) {
client := util.NewAPIClient(ctx, logger, baseUrl, token)

var resp Response[[]DeploymentListData]
if err := client.Do("GET", fmt.Sprintf("/cli/project/%s/deployments", projectId), nil, &resp); err != nil {
return nil, fmt.Errorf("error listing deployments: %w", err)
}
return resp.Data, nil
}

func DeleteDeployment(ctx context.Context, logger logger.Logger, baseUrl string, token string, projectId string, deploymentId string) error {
client := util.NewAPIClient(ctx, logger, baseUrl, token)

var resp Response[string]
if err := client.Do("DELETE", fmt.Sprintf("/cli/project/%s/deployments/%s", projectId, deploymentId), nil, &resp); err != nil {
return fmt.Errorf("error deleting deployment: %w", err)
}
if !resp.Success {
return errors.New(resp.Message)
}
return nil
}

func RollbackDeployment(ctx context.Context, logger logger.Logger, baseUrl string, token string, projectId string, deploymentId string) error {
client := util.NewAPIClient(ctx, logger, baseUrl, token)

var resp Response[string]
if err := client.Do("POST", fmt.Sprintf("/cli/project/%s/deployments/%s/rollback", projectId, deploymentId), nil, &resp); err != nil {
return fmt.Errorf("error rolling back deployment: %w", err)
}
if !resp.Success {
return errors.New(resp.Message)
}
return nil
}

func DeleteProjects(ctx context.Context, logger logger.Logger, baseUrl string, token string, ids []string) ([]string, error) {
client := util.NewAPIClient(ctx, logger, baseUrl, token)

Expand Down
Loading