diff --git a/cmd/hub/hub.go b/cmd/hub/hub.go new file mode 100644 index 00000000..8156be7e --- /dev/null +++ b/cmd/hub/hub.go @@ -0,0 +1,15 @@ +package hub + +import ( + "github.com/spf13/cobra" +) + +var hubRootCmd = &cobra.Command{ + Use: "hub", + Short: "Shopware Community Hub commands", +} + +// Register adds the hub command tree to rootCmd. +func Register(rootCmd *cobra.Command) { + rootCmd.AddCommand(hubRootCmd) +} diff --git a/cmd/hub/hub_updates.go b/cmd/hub/hub_updates.go new file mode 100644 index 00000000..f7557692 --- /dev/null +++ b/cmd/hub/hub_updates.go @@ -0,0 +1,135 @@ +package hub + +import ( + "cmp" + "fmt" + "maps" + "os" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + + hub_api "github.com/shopware/shopware-cli/internal/hub-api" + "github.com/shopware/shopware-cli/internal/tui" +) + +// eventLabels maps raw event.event values to human-readable group headings. +var eventLabels = map[string]string{ + "hub:release:created": "New releases", + "hub:release:updated": "Updated releases", + "hub:plugin:approved": "Approved extensions", + "hub:plugin:rejected": "Rejected extensions", + "hub:plugin:published": "Published extensions", + "hub:review:received": "New reviews", +} + +// labelForEvent returns the human-readable label for an event type, +// falling back to the raw event string when no mapping exists. +func labelForEvent(event string) string { + if label, ok := eventLabels[event]; ok { + return label + } + return event +} + +// hyperlink renders text as a clickable OSC 8 terminal hyperlink when the +// terminal supports it (i.e. stdout is a TTY). In non-TTY contexts the URL +// is appended in parentheses so the information is never lost. +func hyperlink(text, url string) string { + if url == "" { + return text + } + if isatty.IsTerminal(os.Stdout.Fd()) { + // OSC 8 hyperlink: ESC ] 8 ; ; ESC \ ESC ] 8 ; ; ESC \ + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, text) + } + return fmt.Sprintf("%s (%s)", text, url) +} + +// groupByEvent groups updates by their event.event value and returns the keys +// sorted by their human-readable label. +func groupByEvent(updates []hub_api.HubUpdate) ([]string, map[string][]hub_api.HubUpdate) { + grouped := make(map[string][]hub_api.HubUpdate) + for _, u := range updates { + key := u.Event.Event + grouped[key] = append(grouped[key], u) + } + + keys := slices.Collect(maps.Keys(grouped)) + slices.SortFunc(keys, func(a, b string) int { + return cmp.Compare(labelForEvent(a), labelForEvent(b)) + }) + + return keys, grouped +} + +var hubUpdatesCmd = &cobra.Command{ + Use: "updates", + Short: "Show latest updates from the Shopware Community Hub", + RunE: func(cmd *cobra.Command, _ []string) error { + updates, err := hub_api.FetchUpdates(cmd.Context()) + if err != nil { + return fmt.Errorf("could not fetch hub updates: %w", err) + } + + if len(updates) == 0 { + fmt.Println(tui.DimText.Render(" No updates available.")) + return nil + } + + keys, grouped := groupByEvent(updates) + + headingStyle := lipgloss.NewStyle().Bold(true).Underline(true) + itemStyle := lipgloss.NewStyle().PaddingLeft(2) + linkStyle := tui.BlueText.Underline(true) + dimStyle := tui.DimText + + for i, key := range keys { + if i > 0 { + fmt.Println() + } + + label := labelForEvent(key) + fmt.Println(headingStyle.Render(label)) + + for _, u := range grouped[key] { + title := u.Title + if title == "" { + title = dimStyle.Render("(no title)") + } + + var line string + if u.Link != "" { + linkedTitle := hyperlink(linkStyle.Render(title), u.Link) + line = itemStyle.Render(fmt.Sprintf("• %s", linkedTitle)) + } else { + line = itemStyle.Render(fmt.Sprintf("• %s", title)) + } + + fmt.Println(line) + + if u.Event.CreatedAt != "" { + date := formatDate(u.Event.CreatedAt) + fmt.Println(itemStyle.Render(fmt.Sprintf(" %s", dimStyle.Render(date)))) + } + } + } + + return nil + }, +} + +// formatDate trims the time portion from ISO 8601 timestamps for a cleaner display. +func formatDate(s string) string { + if idx := strings.IndexByte(s, 'T'); idx >= 0 { + return s[:idx] + } + return s +} + +func init() { + hubRootCmd.AddCommand(hubUpdatesCmd) +} diff --git a/cmd/hub/hub_updates_test.go b/cmd/hub/hub_updates_test.go new file mode 100644 index 00000000..a50d3dd6 --- /dev/null +++ b/cmd/hub/hub_updates_test.go @@ -0,0 +1,72 @@ +package hub + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + hub_api "github.com/shopware/shopware-cli/internal/hub-api" +) + +func TestLabelForEvent(t *testing.T) { + assert.Equal(t, "New releases", labelForEvent("hub:release:created")) + assert.Equal(t, "Updated releases", labelForEvent("hub:release:updated")) + assert.Equal(t, "hub:unknown:event", labelForEvent("hub:unknown:event")) + assert.Equal(t, "", labelForEvent("")) +} + +func TestGroupByEvent(t *testing.T) { + updates := []hub_api.HubUpdate{ + {Event: hub_api.HubUpdateEvent{Event: "hub:release:created"}, Title: "Plugin A"}, + {Event: hub_api.HubUpdateEvent{Event: "hub:release:updated"}, Title: "Plugin B"}, + {Event: hub_api.HubUpdateEvent{Event: "hub:release:created"}, Title: "Plugin C"}, + } + + keys, grouped := groupByEvent(updates) + + assert.Len(t, keys, 2) + assert.Len(t, grouped["hub:release:created"], 2) + assert.Len(t, grouped["hub:release:updated"], 1) + + // Keys must be sorted by human-readable label. + // "New releases" < "Updated releases" alphabetically. + assert.Equal(t, "hub:release:created", keys[0]) + assert.Equal(t, "hub:release:updated", keys[1]) +} + +func TestGroupByEvent_Empty(t *testing.T) { + keys, grouped := groupByEvent(nil) + + assert.Empty(t, keys) + assert.Empty(t, grouped) +} + +func TestGroupByEvent_UnknownEvents(t *testing.T) { + updates := []hub_api.HubUpdate{ + {Event: hub_api.HubUpdateEvent{Event: "hub:custom:event"}, Title: "X"}, + } + + keys, grouped := groupByEvent(updates) + + assert.ElementsMatch(t, []string{"hub:custom:event"}, keys) + assert.Len(t, grouped["hub:custom:event"], 1) +} + +func TestFormatDate(t *testing.T) { + assert.Equal(t, "2024-03-15", formatDate("2024-03-15T12:00:00Z")) + assert.Equal(t, "2024-03-15", formatDate("2024-03-15")) + assert.Equal(t, "", formatDate("")) + // 'T' at position 0: strip everything before (empty string prefix) + assert.Equal(t, "", formatDate("T12:00:00")) +} + +func TestHyperlink_NonTTY(t *testing.T) { + // In a test environment stdout is not a TTY, so the URL is appended. + result := hyperlink("Click me", "https://example.com") + assert.Equal(t, "Click me (https://example.com)", result) +} + +func TestHyperlink_EmptyURL(t *testing.T) { + result := hyperlink("No link", "") + assert.Equal(t, "No link", result) +} diff --git a/cmd/root.go b/cmd/root.go index 466f2ab3..78e032a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/shopware/shopware-cli/cmd/account" "github.com/shopware/shopware-cli/cmd/extension" + "github.com/shopware/shopware-cli/cmd/hub" "github.com/shopware/shopware-cli/cmd/project" accountApi "github.com/shopware/shopware-cli/internal/account-api" "github.com/shopware/shopware-cli/internal/system" @@ -87,6 +88,7 @@ func init() { project.Register(rootCmd) extension.Register(rootCmd) + hub.Register(rootCmd) account.Register(rootCmd, func(commandName string) (*account.ServiceContainer, error) { if commandName == "login" || commandName == "logout" { return &account.ServiceContainer{ diff --git a/internal/hub-api/client.go b/internal/hub-api/client.go new file mode 100644 index 00000000..b0fcfe2b --- /dev/null +++ b/internal/hub-api/client.go @@ -0,0 +1,80 @@ +package hub_api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/shopware/shopware-cli/logging" +) + +var hubBaseURL = "https://hub.shopware.com" + +// httpClient is used for all Community Hub requests. A 30-second timeout is +// set to avoid hanging indefinitely on a slow or unresponsive server; context +// cancellation via the request still takes precedence. +var httpClient = &http.Client{Timeout: 30 * time.Second} + +func init() { + if v := os.Getenv("SHOPWARE_CLI_HUB_URL"); v != "" { + hubBaseURL = v + } +} + +// HubUpdateEvent holds the event metadata returned by the Community Hub API. +type HubUpdateEvent struct { + Event string `json:"event"` + CreatedAt string `json:"createdAt"` +} + +// HubUpdate represents a single item from the Community Hub updates feed. +type HubUpdate struct { + Event HubUpdateEvent `json:"event"` + Title string `json:"title"` + Link string `json:"link"` +} + +// FetchUpdates retrieves the latest items from the Community Hub updates feed. +// The endpoint does not require authentication. +func FetchUpdates(ctx context.Context) ([]HubUpdate, error) { + url := fmt.Sprintf("%s/api/updates", hubBaseURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("hub updates: %w", err) + } + + req.Header.Set("Accept", "application/json") + + start := time.Now() + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("hub updates: %w", err) + } + logging.FromContext(ctx).Debugf("GET %s took %s", url, time.Since(start)) + + defer func() { + if err := resp.Body.Close(); err != nil { + logging.FromContext(ctx).Errorf("hub updates: close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + logging.FromContext(ctx).Debugf("hub updates: read error body: %v", readErr) + } + return nil, fmt.Errorf("hub updates: API returned status %d: %s", resp.StatusCode, string(body)) + } + + var updates []HubUpdate + if err := json.NewDecoder(resp.Body).Decode(&updates); err != nil { + return nil, fmt.Errorf("hub updates: decode response: %w", err) + } + + return updates, nil +}