Skip to content
Draft
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
15 changes: 15 additions & 0 deletions cmd/hub/hub.go
Original file line number Diff line number Diff line change
@@ -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)
}
135 changes: 135 additions & 0 deletions cmd/hub/hub_updates.go
Original file line number Diff line number Diff line change
@@ -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 ; ; <url> ESC \ <text> 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)
}
72 changes: 72 additions & 0 deletions cmd/hub/hub_updates_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
80 changes: 80 additions & 0 deletions internal/hub-api/client.go
Original file line number Diff line number Diff line change
@@ -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
}