feat: add heatmap, stats, trends shortcuts to user module
- heatmap: show user contribution heatmap with optional --year flag - stats: show user development statistics with optional --start-time/--end-time - trends: show user project trends - includes 11 new unit tests covering normal paths, parameter validation, and HTTP error handling Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6e7ee9cd12
commit
015789c092
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue