Skip to content

Introduce a specialized agent to migrate commands from PHP to Go#16

Closed
akalipetis wants to merge 8 commits intomainfrom
feature/command-migration-agent
Closed

Introduce a specialized agent to migrate commands from PHP to Go#16
akalipetis wants to merge 8 commits intomainfrom
feature/command-migration-agent

Conversation

@akalipetis
Copy link
Copy Markdown
Contributor

Introduce a specialized agent to handle such migrations and migrate the project:list command.

Also introduce:

  • Copilot setup steps
  • A package to format tables in the Legacy CLI way
  • Go SDK parts for listing projects through refs and expanding refs

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Introduces infrastructure and guidance to support migrating legacy PHP (Symfony Console) commands to native Go (Cobra), and ports the project:list command along with supporting API/table utilities.

Changes:

  • Adds a PHP to Go Command Migrator Specialist Agent guide and Copilot setup workflow to streamline migrations.
  • Implements a legacy-style table output formatter with wrapping support.
  • Adds Go API client methods for listing projects via user grants and resolving signed HAL ref links; adds the Go project:list command and registers it.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
internal/tableoutput/table.go New table rendering package with terminal-width handling and CSV/TSV/plain output.
internal/tableoutput/table_test.go Unit tests for table rendering and wrapping behavior.
internal/api/users.go Adds GetMyUserID() helper for current-user lookups.
internal/api/refs.go Adds helpers to extract/use signed HAL ref links for projects/orgs.
internal/api/project.go Adds GetMyProjects() using extended-access grants + HAL refs expansion.
commands/project_list.go New native Go implementation of project:list (filters/sort/output).
commands/root.go Registers the new project:list command with the root command.
README.md Documents the migration agent and migration workflow pointers.
CLAUDE.md Updates repo guidance and documents new migration/table/API components.
.github/workflows/copilot-setup-steps.yml Adds GitHub Copilot coding agent setup steps workflow.
.github/agents/migrator-php-to-go.agent.md Adds the specialized migration agent instructions and checklists.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread commands/project_list.go
Comment on lines +95 to +99
cmd.Flags().Int("refresh", 1, "Whether to refresh the list")
cmd.Flags().String("sort", "title", "A property to sort by")
cmd.Flags().Bool("reverse", false, "Sort in reverse (descending) order")
cmd.Flags().Int("page", 0, "Page number. This enables pagination.")
cmd.Flags().IntP("count", "c", 0, "The number of projects to display per page. Use 0 to disable pagination.")
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The flags --refresh, --page, and --count are defined here but are never read/used in runProjectList. In the legacy implementation these options affect caching and pagination, so leaving them unused is misleading and changes behavior; either implement them (including page-count interactions) or remove them from the command interface.

Suggested change
cmd.Flags().Int("refresh", 1, "Whether to refresh the list")
cmd.Flags().String("sort", "title", "A property to sort by")
cmd.Flags().Bool("reverse", false, "Sort in reverse (descending) order")
cmd.Flags().Int("page", 0, "Page number. This enables pagination.")
cmd.Flags().IntP("count", "c", 0, "The number of projects to display per page. Use 0 to disable pagination.")
cmd.Flags().String("sort", "title", "A property to sort by")
cmd.Flags().Bool("reverse", false, "Sort in reverse (descending) order")

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
Comment on lines +340 to +352
func renderOutput(cmd *cobra.Command, table *tableoutput.Table) error {
format, _ := cmd.Flags().GetString("format")
noHeader, _ := cmd.Flags().GetBool("no-header")
w := cmd.OutOrStdout()

switch format {
case "csv":
return table.RenderCSV(w, noHeader)
case "tsv", "plain":
return table.RenderPlain(w)
default: // table
return table.RenderTable(w)
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

--no-header is parsed but only applied to CSV output. For table/plain/tsv formats the header is always printed, which diverges from the legacy Table behavior (where --no-header removes headers for all formats). Consider extending tableoutput to support rendering without a header, and have renderOutput honor --no-header for all formats.

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
Comment on lines +223 to +230
if cnf.API.EnableOrganizations {
result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool {
if p.OrganizationRef != nil {
return p.OrganizationRef.OwnerID != myUserID
}
return true
})
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The --my filter is only applied when organizations are enabled and OrganizationRef is present; otherwise it leaves the result unfiltered (or filters everything if orgs are enabled but OrganizationRef is nil). In the legacy command, --my falls back to comparing the project's owner_id when org info isn't available. To match that behavior, include OwnerID in ProjectInfo and use it as a fallback when OrganizationRef is nil or organizations are disabled.

Suggested change
if cnf.API.EnableOrganizations {
result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool {
if p.OrganizationRef != nil {
return p.OrganizationRef.OwnerID != myUserID
}
return true
})
}
result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool {
// When organizations are enabled and the project has an organization,
// use the organization's owner as the source of truth.
if cnf.API.EnableOrganizations && p.OrganizationRef != nil {
return p.OrganizationRef.OwnerID != myUserID
}
// Otherwise, fall back to the project's direct OwnerID.
return p.OwnerID != myUserID
})

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
Comment on lines +91 to +94
cmd.Flags().Bool("pipe", false, "Output a simple list of project IDs. Disables pagination.")
cmd.Flags().String("region", "", "Filter by region (exact match)")
cmd.Flags().String("title", "", "Filter by title (case-insensitive search)")
cmd.Flags().Bool("my", false, "Display only the projects you own")
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The legacy command supports a deprecated --host option as an alias for --region (and emits a deprecation warning). This Go implementation omits --host entirely, which can break existing scripts. Consider adding a hidden --host flag mapped to --region (with a deprecation notice) to preserve CLI compatibility.

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
Comment on lines +222 to +231
if err == nil && myUserID != "" {
if cnf.API.EnableOrganizations {
result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool {
if p.OrganizationRef != nil {
return p.OrganizationRef.OwnerID != myUserID
}
return true
})
}
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

filterProjects ignores errors from apiClient.GetMyUserID() when --my is set (it silently skips filtering). In the legacy implementation, failing to determine the current user is an error for --my. Consider returning an error from filterProjects (or handling it in runProjectList) so the command fails loudly when ownership filtering can't be computed.

Suggested change
if err == nil && myUserID != "" {
if cnf.API.EnableOrganizations {
result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool {
if p.OrganizationRef != nil {
return p.OrganizationRef.OwnerID != myUserID
}
return true
})
}
}
if err != nil {
return nil, fmt.Errorf("failed to determine current user for --my filter: %w", err)
}
if myUserID == "" {
return nil, fmt.Errorf("failed to determine current user for --my filter: empty user ID")
}
if cnf.API.EnableOrganizations {
result = slices.DeleteFunc(result, func(p *api.ProjectInfo) bool {
if p.OrganizationRef != nil {
return p.OrganizationRef.OwnerID != myUserID
}
return true
})
}

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +27
+--------+-----------+----------+
| ID | Title | Region |
+--------+-----------+----------+
| proj-1 | Project 1 | region-1 |
| proj-2 | Project 2 | region-2 |
+--------+-----------+----------+`)
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The expected table output includes an extra leading "|" on the header and data rows (e.g., "|| ID..."), but RenderTable prints a single leading pipe ("| ID..."). This makes the test fail; update the expected string to match the actual table row format and spacing.

Suggested change
+--------+-----------+----------+
| ID | Title | Region |
+--------+-----------+----------+
| proj-1 | Project 1 | region-1 |
| proj-2 | Project 2 | region-2 |
+--------+-----------+----------+`)
| ID | Title | Region |
| proj-1 | Project 1 | region-1 |
| proj-2 | Project 2 | region-2 |`)

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
Comment on lines +175 to +177
fmt.Fprintf(cmd.OutOrStdout(), "No projects found (filters in use: %s).\n", strings.Join(filtersInUse, ", "))
} else {
fmt.Fprintln(cmd.OutOrStdout(), "No projects found.")
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

When no projects are found, messages are written to stdout. In the legacy implementation these informational messages are written to stderr (stdout is reserved for machine-readable output like --pipe/--format). To preserve compatibility with scripts and existing expectations, write the "No projects found..." messages to cmd.ErrOrStderr() instead.

Suggested change
fmt.Fprintf(cmd.OutOrStdout(), "No projects found (filters in use: %s).\n", strings.Join(filtersInUse, ", "))
} else {
fmt.Fprintln(cmd.OutOrStdout(), "No projects found.")
fmt.Fprintf(cmd.ErrOrStderr(), "No projects found (filters in use: %s).\n", strings.Join(filtersInUse, ", "))
} else {
fmt.Fprintln(cmd.ErrOrStderr(), "No projects found.")

Copilot uses AI. Check for mistakes.
Comment thread internal/api/project.go
Comment on lines +51 to +89
func (c *Client) GetMyProjects(ctx context.Context) ([]*ProjectInfo, error) {
// Get the current user's ID
meURL, err := c.resolveURL("users/me")
if err != nil {
return nil, err
}

var me struct {
ID string `json:"id"`
}
if err := c.getResource(ctx, meURL.String(), &me); err != nil {
return nil, err
}

// Get user extended access (project grants)
accessURL, err := c.resolveURL("users/" + url.PathEscape(me.ID) + "/extended-access?filter[resource_type]=project")
if err != nil {
return nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, accessURL.String(), http.NoBody)
if err != nil {
return nil, Error{Original: err, URL: accessURL.String()}
}

resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, Error{Original: err, URL: accessURL.String(), Response: resp}
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, Error{Response: resp, URL: accessURL.String()}
}

var accessResp userExtendedAccessResponse
if err := json.NewDecoder(resp.Body).Decode(&accessResp); err != nil {
return nil, Error{Original: err, URL: accessURL.String()}
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

GetMyProjects re-implements fetching the current user (users/me) and does a manual HTTP request/JSON decode for extended-access. This duplicates GetMyUserID and getResource logic and makes error/status handling inconsistent across the api package. Consider calling GetMyUserID and using c.getResource to fetch/decode userExtendedAccessResponse for consistency and less duplication.

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
}

cmd.Flags().String("format", "table", "The output format: table, plain, csv, or tsv")
cmd.Flags().String("columns", "", "Columns to display (comma-separated)")
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The legacy CLI's --columns option supports multiple occurrences and special behaviors like "+" (default columns placeholder) and wildcards (see legacy/src/Service/Table.php:66-73). This Go implementation only accepts a single comma-separated string and silently ignores unknown columns, which is a compatibility break for existing usage. Consider switching to a string slice flag and implementing the "+"/wildcard expansion + validation to match legacy behavior.

Suggested change
cmd.Flags().String("columns", "", "Columns to display (comma-separated)")
cmd.Flags().StringSlice("columns", nil, "Columns to display (comma-separated or repeated)")

Copilot uses AI. Check for mistakes.
Comment thread commands/project_list.go
Comment on lines +171 to +178
// Check if no projects found and display appropriate message
if len(projects) == 0 {
filtersInUse := getFiltersInUse(cmd, cnf)
if len(filtersInUse) > 0 {
fmt.Fprintf(cmd.OutOrStdout(), "No projects found (filters in use: %s).\n", strings.Join(filtersInUse, ", "))
} else {
fmt.Fprintln(cmd.OutOrStdout(), "No projects found.")
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The "no projects" messaging differs from the legacy command (which prints "You do not have any projects yet." and shows follow-up guidance, and prints a different message when filters are in use). If the goal is exact behavior parity, align these messages and where they are written (stderr vs stdout) with legacy/src/Command/Project/ProjectListCommand.php:124-146.

Copilot uses AI. Check for mistakes.
@akalipetis
Copy link
Copy Markdown
Contributor Author

Closing this one as it never materialized. We can open it again when we are ready to pursue this.

@akalipetis akalipetis closed this Feb 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants