diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..f9ecc69 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -2,6 +2,7 @@ package user import ( "fmt" + "net/url" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -37,5 +38,87 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "heatmap", + Description: "Show user contribution heatmap", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "year", Short: "y", Usage: "Year (e.g. 2026)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + path := fmt.Sprintf("/users/%s/headmaps", login) + if year := ctx.Arg("year"); year != "" { + q := url.Values{} + q.Set("year", year) + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "stats", + Description: "Show user development statistics", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start date (YYYY-MM-DD)"}, + {Name: "end-time", Usage: "End date (YYYY-MM-DD)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + path := fmt.Sprintf("/users/%s/statistics/develop", login) + q := url.Values{} + if st := ctx.Arg("start-time"); st != "" { + q.Set("start_time", st) + } + if et := ctx.Arg("end-time"); et != "" { + q.Set("end_time", et) + } + if len(q) > 0 { + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "trends", + Description: "Show user project trends", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/project_trends", login), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 44d504f..74546e7 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -119,3 +119,192 @@ func TestUserInfoHTTPError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } + +// --- heatmap --- + +func TestUserHeatmap(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "" { + t.Fatalf("expected no year query param, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{ + map[string]interface{}{"date": "2026-01-01", "count": float64(5)}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("heatmap failed: %v", err) + } +} + +func TestUserHeatmapWithYear(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "2025" { + t.Fatalf("expected year=2025, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice", "year": "2025"}) + if err != nil { + t.Fatalf("heatmap with year failed: %v", err) + } +} + +func TestUserHeatmapMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserHeatmapHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- stats --- + +func TestUserStats(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "" || r.URL.Query().Get("end_time") != "" { + t.Fatal("expected no time query params") + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(10), + "commit_count": float64(42), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("stats failed: %v", err) + } +} + +func TestUserStatsWithTimeRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "2026-01-01" { + t.Fatalf("expected start_time=2026-01-01, got %s", r.URL.Query().Get("start_time")) + } + if r.URL.Query().Get("end_time") != "2026-03-31" { + t.Fatalf("expected end_time=2026-03-31, got %s", r.URL.Query().Get("end_time")) + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(5), + "commit_count": float64(20), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{ + "login": "alice", + "start-time": "2026-01-01", + "end-time": "2026-03-31", + }) + if err != nil { + t.Fatalf("stats with time range failed: %v", err) + } +} + +func TestUserStatsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserStatsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- trends --- + +func TestUserTrends(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/project_trends.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"id": float64(1), "name": "created project"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("trends failed: %v", err) + } +} + +func TestUserTrendsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserTrendsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +}