From 7b463e822636023c824e657640d0542df94d572c Mon Sep 17 00:00:00 2001 From: nandosobral03 Date: Thu, 20 Feb 2025 11:26:29 -0300 Subject: [PATCH 1/2] feat: ticket submission date --- cmd/root.go | 68 +++++++++++++--- cmd/root_test.go | 130 +++++++++++++++++++++++++++++- cmd/skyline/skyline.go | 113 ++++++++++++++++++-------- cmd/skyline/skyline_test.go | 2 +- internal/ascii/generator.go | 28 ++++++- internal/github/client.go | 28 ++++++- internal/testutil/mocks/github.go | 17 ++++ internal/utils/utils.go | 34 +++++--- internal/utils/utils_test.go | 2 +- 9 files changed, 364 insertions(+), 58 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 38d2b97..a268860 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,13 +19,15 @@ import ( // Command line variables and root command configuration var ( - yearRange string - user string - full bool - debug bool - web bool - artOnly bool - output string // new output path flag + yearRange string + user string + full bool + debug bool + web bool + artOnly bool + output string + yearToDate bool // renamed from ytd + until string // renamed from ytdEnd ) // rootCmd is the root command for the GitHub Skyline CLI tool. @@ -47,7 +49,20 @@ ASCII Preview Legend: Layout: Each column represents one week. Days within each week are reordered vertically -to create a "building" effect, with empty spaces (no contributions) at the top.`, +to create a "building" effect, with empty spaces (no contributions) at the top. + +Examples: + # Generate skyline for current year + gh skyline + + # Generate skyline for a specific year + gh skyline --year 2023 + + # Generate skyline for the last 12 months up to today + gh skyline --year-to-date + + # Generate skyline for the last 12 months up to a specific date + gh skyline --year-to-date --until 2024-02-29`, RunE: handleSkylineCommand, } @@ -74,6 +89,21 @@ func initFlags() { flags.BoolVarP(&web, "web", "w", false, "Open GitHub profile (authenticated or specified user).") flags.BoolVarP(&artOnly, "art-only", "a", false, "Generate only ASCII preview") flags.StringVarP(&output, "output", "o", "", "Output file path (optional)") + flags.BoolVar(&yearToDate, "year-to-date", false, "Generate contribution graph for the last 12 months") + flags.StringVar(&until, "until", "", "End date for year-to-date period in YYYY-MM-DD format (defaults to today)") + + // Mark mutually exclusive flags + rootCmd.MarkFlagsMutuallyExclusive("year", "year-to-date") + rootCmd.MarkFlagsMutuallyExclusive("year", "full") + rootCmd.MarkFlagsMutuallyExclusive("full", "year-to-date") + + // Add validation to ensure --until is only used with --year-to-date + rootCmd.PreRunE = func(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("until") && !yearToDate { + return fmt.Errorf("--until can only be used with --year-to-date") + } + return nil + } } // executeRootCmd is the main execution function for the root command. @@ -100,12 +130,32 @@ func handleSkylineCommand(_ *cobra.Command, _ []string) error { return nil } + if yearToDate { + // Parse custom end date if provided + now := time.Now() + endDate := now + if until != "" { + parsedEnd, err := time.Parse("2006-01-02", until) + if err != nil { + return fmt.Errorf("invalid until date format, expected YYYY-MM-DD: %v", err) + } + if parsedEnd.After(now) { + return fmt.Errorf("until date cannot be in the future") + } + endDate = parsedEnd + } + + // Calculate start date as 12 months before end date + startDate := endDate.AddDate(0, -12, 0) + yearRange = fmt.Sprintf("%d-%d", startDate.Year(), endDate.Year()) + } + startYear, endYear, err := utils.ParseYearRange(yearRange) if err != nil { return fmt.Errorf("invalid year range: %v", err) } - return skyline.GenerateSkyline(startYear, endYear, user, full, output, artOnly) + return skyline.GenerateSkyline(startYear, endYear, user, full, output, artOnly, until) } // Browser interface matches browser.Browser functionality. diff --git a/cmd/root_test.go b/cmd/root_test.go index c6bd877..5b93457 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "testing" + "time" "github.com/github/gh-skyline/internal/testutil/mocks" ) @@ -34,7 +35,17 @@ func TestRootCmd(t *testing.T) { func TestInit(t *testing.T) { flags := rootCmd.Flags() - expectedFlags := []string{"year", "user", "full", "debug", "web", "art-only", "output"} + expectedFlags := []string{ + "year", + "user", + "full", + "debug", + "web", + "art-only", + "output", + "year-to-date", + "until", + } for _, flag := range expectedFlags { if flags.Lookup(flag) == nil { t.Errorf("expected flag %s to be initialized", flag) @@ -42,6 +53,123 @@ func TestInit(t *testing.T) { } } +func TestMutuallyExclusiveFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "year and year-to-date are mutually exclusive", + args: []string{"--year", "2024", "--year-to-date"}, + wantErr: true, + }, + { + name: "year and full are mutually exclusive", + args: []string{"--year", "2024", "--full"}, + wantErr: true, + }, + { + name: "full and year-to-date are mutually exclusive", + args: []string{"--full", "--year-to-date"}, + wantErr: true, + }, + { + name: "until requires year-to-date", + args: []string{"--until", "2024-03-21"}, + wantErr: true, + }, + { + name: "valid year-to-date with until", + args: []string{"--year-to-date", "--until", "2024-03-21"}, + wantErr: false, + }, + { + name: "valid single year", + args: []string{"--year", "2024"}, + wantErr: false, + }, + { + name: "valid year-to-date", + args: []string{"--year-to-date"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := rootCmd + cmd.SetArgs(tt.args) + err := cmd.Execute() + if (err != nil) != tt.wantErr { + t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestYearToDatePeriodCalculation(t *testing.T) { + tests := []struct { + name string + untilDate string + wantStartYear int + wantEndYear int + wantErr bool + }{ + { + name: "default to current date", + untilDate: "", + wantStartYear: time.Now().AddDate(0, -12, 0).Year(), + wantEndYear: time.Now().Year(), + wantErr: false, + }, + { + name: "specific date in current year", + untilDate: time.Now().Format("2006-01-02"), + wantStartYear: time.Now().AddDate(0, -12, 0).Year(), + wantEndYear: time.Now().Year(), + wantErr: false, + }, + { + name: "invalid date format", + untilDate: "2024/03/21", + wantStartYear: 0, + wantEndYear: 0, + wantErr: true, + }, + { + name: "future date", + untilDate: time.Now().AddDate(0, 1, 0).Format("2006-01-02"), + wantStartYear: 0, + wantEndYear: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset flags before each test + yearToDate = true + until = tt.untilDate + yearRange = "" + + err := handleSkylineCommand(rootCmd, nil) + if (err != nil) != tt.wantErr { + t.Errorf("handleSkylineCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + // Check if the yearRange was set correctly + expectedYearRange := fmt.Sprintf("%d-%d", tt.wantStartYear, tt.wantEndYear) + if yearRange != expectedYearRange { + t.Errorf("yearRange = %v, want %v", yearRange, expectedYearRange) + } + } + }) + } +} + // TestOpenGitHubProfile tests the openGitHubProfile function func TestOpenGitHubProfile(t *testing.T) { tests := []struct { diff --git a/cmd/skyline/skyline.go b/cmd/skyline/skyline.go index de62e5e..a9dc051 100644 --- a/cmd/skyline/skyline.go +++ b/cmd/skyline/skyline.go @@ -21,10 +21,11 @@ type GitHubClientInterface interface { GetAuthenticatedUser() (string, error) GetUserJoinYear(username string) (int, error) FetchContributions(username string, year int) (*types.ContributionsResponse, error) + FetchContributionsForDateRange(username string, from, to time.Time) (*types.ContributionsResponse, error) } // GenerateSkyline creates a 3D model with ASCII art preview of GitHub contributions for the specified year range, or "full lifetime" of the user -func GenerateSkyline(startYear, endYear int, targetUser string, full bool, output string, artOnly bool) error { +func GenerateSkyline(startYear, endYear int, targetUser string, full bool, output string, artOnly bool, ytdEnd string) error { log := logger.GetLogger() client, err := github.InitializeGitHubClient() @@ -52,50 +53,103 @@ func GenerateSkyline(startYear, endYear int, targetUser string, full bool, outpu endYear = time.Now().Year() } + // Handle YTD mode (last 12 months) + now := time.Now() + isYTD := endYear == now.Year() && startYear == now.AddDate(0, -12, 0).Year() var allContributions [][][]types.ContributionDay - for year := startYear; year <= endYear; year++ { - contributions, err := fetchContributionData(client, targetUser, year) + + if isYTD { + // For YTD mode, fetch a single continuous period from 12 months ago until now + endDate := now + if ytdEnd != "" { + parsedEnd, err := time.Parse("2006-01-02", ytdEnd) + if err != nil { + return fmt.Errorf("invalid ytd-end date format, expected YYYY-MM-DD: %v", err) + } + if parsedEnd.After(now) { + return fmt.Errorf("ytd-end date cannot be in the future") + } + endDate = parsedEnd + } + startDate := endDate.AddDate(0, -12, 0) + + response, err := client.FetchContributionsForDateRange(targetUser, startDate, endDate) if err != nil { return err } + contributions := convertResponseToGrid(response) allContributions = append(allContributions, contributions) - // Generate ASCII art for each year - asciiArt, err := ascii.GenerateASCII(contributions, targetUser, year, (year == startYear) && !artOnly, !artOnly) + // Generate ASCII art for the YTD period + // Use both years for YTD display + yearDisplay := fmt.Sprintf("%d-%d", startDate.Year(), endDate.Year()) + asciiArt, err := ascii.GenerateASCII(contributions, targetUser, startYear, true, !artOnly) if err != nil { if warnErr := log.Warning("Failed to generate ASCII preview: %v", err); warnErr != nil { return warnErr } } else { - if year == startYear { - // For first year, show full ASCII art including header - fmt.Println(asciiArt) + // Replace the year display in ASCII art + lines := strings.Split(asciiArt, "\n") + for i, line := range lines { + if strings.Contains(line, fmt.Sprintf("%d", startYear)) { + lines[i] = strings.ReplaceAll(line, fmt.Sprintf("%d", startYear), yearDisplay) + break + } + } + fmt.Println(strings.Join(lines, "\n")) + } + } else { + // Handle regular year-based contributions + for year := startYear; year <= endYear; year++ { + response, err := client.FetchContributions(targetUser, year) + if err != nil { + return err + } + contributions := convertResponseToGrid(response) + allContributions = append(allContributions, contributions) + + // Generate ASCII art for each year + asciiArt, err := ascii.GenerateASCII(contributions, targetUser, year, (year == startYear) && !artOnly, !artOnly) + if err != nil { + if warnErr := log.Warning("Failed to generate ASCII preview: %v", err); warnErr != nil { + return warnErr + } } else { - // For subsequent years, skip the header - lines := strings.Split(asciiArt, "\n") - gridStart := 0 - for i, line := range lines { - containsEmptyBlock := strings.Contains(line, string(ascii.EmptyBlock)) - containsFoundationLow := strings.Contains(line, string(ascii.FoundationLow)) - isNotOnlyEmptyBlocks := strings.Trim(line, string(ascii.EmptyBlock)) != "" - - if (containsEmptyBlock || containsFoundationLow) && isNotOnlyEmptyBlocks { - gridStart = i - break + if year == startYear { + // For first year, show full ASCII art including header + fmt.Println(asciiArt) + } else { + // For subsequent years, skip the header + lines := strings.Split(asciiArt, "\n") + gridStart := 0 + for i, line := range lines { + containsEmptyBlock := strings.Contains(line, string(ascii.EmptyBlock)) + containsFoundationLow := strings.Contains(line, string(ascii.FoundationLow)) + isNotOnlyEmptyBlocks := strings.Trim(line, string(ascii.EmptyBlock)) != "" + + if (containsEmptyBlock || containsFoundationLow) && isNotOnlyEmptyBlocks { + gridStart = i + break + } } + // Print just the grid and user info + fmt.Println(strings.Join(lines[gridStart:], "\n")) } - // Print just the grid and user info - fmt.Println(strings.Join(lines[gridStart:], "\n")) } } } if !artOnly { - // Generate filename - outputPath := utils.GenerateOutputFilename(targetUser, startYear, endYear, output) + // Generate filename with custom end date for YTD mode + outputPath := utils.GenerateOutputFilename(targetUser, startYear, endYear, output, ytdEnd) // Generate the STL file if len(allContributions) == 1 { + if isYTD { + // For YTD mode, pass both years to the STL generator + return stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear) + } return stl.GenerateSTL(allContributions[0], outputPath, targetUser, startYear) } return stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear) @@ -104,19 +158,12 @@ func GenerateSkyline(startYear, endYear int, targetUser string, full bool, outpu return nil } -// fetchContributionData retrieves and formats the contribution data for the specified year. -func fetchContributionData(client *github.Client, username string, year int) ([][]types.ContributionDay, error) { - response, err := client.FetchContributions(username, year) - if err != nil { - return nil, fmt.Errorf("failed to fetch contributions: %w", err) - } - - // Convert weeks data to 2D array for STL generation +// convertResponseToGrid converts a GitHub API response to a 2D grid of contributions +func convertResponseToGrid(response *types.ContributionsResponse) [][]types.ContributionDay { weeks := response.User.ContributionsCollection.ContributionCalendar.Weeks contributionGrid := make([][]types.ContributionDay, len(weeks)) for i, week := range weeks { contributionGrid[i] = week.ContributionDays } - - return contributionGrid, nil + return contributionGrid } diff --git a/cmd/skyline/skyline_test.go b/cmd/skyline/skyline_test.go index 05970a5..93b55f3 100644 --- a/cmd/skyline/skyline_test.go +++ b/cmd/skyline/skyline_test.go @@ -72,7 +72,7 @@ func TestGenerateSkyline(t *testing.T) { return github.NewClient(tt.mockClient), nil } - err := GenerateSkyline(tt.startYear, tt.endYear, tt.targetUser, tt.full, "", false) + err := GenerateSkyline(tt.startYear, tt.endYear, tt.targetUser, tt.full, "", false, "") if (err != nil) != tt.wantErr { t.Errorf("GenerateSkyline() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/internal/ascii/generator.go b/internal/ascii/generator.go index 1044619..1f2bdf1 100644 --- a/internal/ascii/generator.go +++ b/internal/ascii/generator.go @@ -33,11 +33,22 @@ func GenerateASCII(contributionGrid [][]types.ContributionDay, username string, // Find max contribution count for normalization maxContributions := 0 + var firstDate, lastDate time.Time for _, week := range contributionGrid { for _, day := range week { if day.ContributionCount > maxContributions { maxContributions = day.ContributionCount } + // Track first and last dates for YTD display + date, err := time.Parse("2006-01-02", day.Date) + if err == nil { + if firstDate.IsZero() || date.Before(firstDate) { + firstDate = date + } + if lastDate.IsZero() || date.After(lastDate) { + lastDate = date + } + } } } @@ -81,7 +92,22 @@ func GenerateASCII(contributionGrid [][]types.ContributionDay, username string, // Add centered user info below buffer.WriteString("\n") buffer.WriteString(centerText(username)) - buffer.WriteString(centerText(fmt.Sprintf("%d", year))) + + // Check if this is a YTD view (spans exactly 12 months) + isYTD := !firstDate.IsZero() && !lastDate.IsZero() && + lastDate.Sub(firstDate) >= 360*24*time.Hour && // approximately 12 months + lastDate.Sub(firstDate) <= 370*24*time.Hour // allow for some variation + + if isYTD { + // For YTD mode, show date range + dateRange := fmt.Sprintf("%s - %s", + firstDate.Format("Jan 2, 2006"), + lastDate.Format("Jan 2, 2006")) + buffer.WriteString(centerText(dateRange)) + } else { + // For regular mode, show year + buffer.WriteString(centerText(fmt.Sprintf("%d", year))) + } } return buffer.String(), nil diff --git a/internal/github/client.go b/internal/github/client.go index 8694f44..c54f7fd 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -3,7 +3,6 @@ package github import ( - "fmt" "time" "github.com/github/gh-skyline/internal/errors" @@ -64,8 +63,31 @@ func (c *Client) FetchContributions(username string, year int) (*types.Contribut return nil, errors.New(errors.ValidationError, "year cannot be before GitHub's launch (2008)", nil) } - startDate := fmt.Sprintf("%d-01-01T00:00:00Z", year) - endDate := fmt.Sprintf("%d-12-31T23:59:59Z", year) + // Calculate start and end dates + now := time.Now() + startDate := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + endDate := time.Date(year, 12, 31, 23, 59, 59, 0, time.UTC) + + // If fetching current year, only get contributions up to now + if year == now.Year() { + endDate = now + } + + return c.FetchContributionsForDateRange(username, startDate, endDate) +} + +// FetchContributionsForDateRange retrieves contribution data for a custom date range. +func (c *Client) FetchContributionsForDateRange(username string, from, to time.Time) (*types.ContributionsResponse, error) { + if username == "" { + return nil, errors.New(errors.ValidationError, "username cannot be empty", nil) + } + + if from.After(to) { + return nil, errors.New(errors.ValidationError, "start date must be before end date", nil) + } + + startDate := from.Format("2006-01-02T15:04:05Z") + endDate := to.Format("2006-01-02T15:04:05Z") // GraphQL query to fetch the user's contributions within the specified date range. query := ` diff --git a/internal/testutil/mocks/github.go b/internal/testutil/mocks/github.go index 8d6f906..a18b321 100644 --- a/internal/testutil/mocks/github.go +++ b/internal/testutil/mocks/github.go @@ -49,6 +49,23 @@ func (m *MockGitHubClient) FetchContributions(username string, year int) (*types return fixtures.GenerateContributionsResponse(username, year), nil } +// FetchContributionsForDateRange implements GitHubClientInterface +func (m *MockGitHubClient) FetchContributionsForDateRange(username string, from, to time.Time) (*types.ContributionsResponse, error) { + if m.Err != nil { + return nil, m.Err + } + if username == "" { + return nil, fmt.Errorf("username cannot be empty") + } + if from.After(to) { + return nil, fmt.Errorf("start date must be before end date") + } + if m.MockData != nil { + return m.MockData, nil + } + return fixtures.GenerateContributionsResponse(username, from.Year()), nil +} + // Do implements APIClient func (m *MockGitHubClient) Do(_ string, _ map[string]interface{}, response interface{}) error { if m.Err != nil { diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 61b681b..329610f 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -62,15 +62,31 @@ func FormatYearRange(startYear, endYear int) string { return fmt.Sprintf("%04d-%02d", startYear, endYear%100) } -// GenerateOutputFilename creates a consistent filename for the STL output -func GenerateOutputFilename(user string, startYear, endYear int, output string) string { - if output != "" { - // Ensure the filename ends with .stl - if !strings.HasSuffix(strings.ToLower(output), ".stl") { - return output + ".stl" +// GenerateOutputFilename creates a filename for the STL output based on the user and year range. +func GenerateOutputFilename(username string, startYear, endYear int, customPath string, ytdEnd string) string { + if customPath != "" { + return customPath + } + + now := time.Now() + isYTD := endYear == now.Year() && startYear == now.AddDate(0, -12, 0).Year() + + var filename string + switch { + case isYTD: + // For YTD mode, use a date range format + endDate := now + if ytdEnd != "" { + if parsed, err := time.Parse("2006-01-02", ytdEnd); err == nil { + endDate = parsed + } } - return output + filename = fmt.Sprintf("%s-contributions-ytd-%s.stl", username, endDate.Format("2006-01-02")) + case startYear == endYear: + filename = fmt.Sprintf("%s-contributions-%d.stl", username, startYear) + default: + filename = fmt.Sprintf("%s-contributions-%d-%d.stl", username, startYear, endYear) } - yearStr := FormatYearRange(startYear, endYear) - return fmt.Sprintf(outputFileFormat, user, yearStr) + + return filename } diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 8253ed1..b303b2c 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -166,7 +166,7 @@ func TestGenerateOutputFilename(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := GenerateOutputFilename(tt.user, tt.startYear, tt.endYear, tt.output) + got := GenerateOutputFilename(tt.user, tt.startYear, tt.endYear, tt.output, "") if got != tt.want { t.Errorf("generateOutputFilename() = %v, want %v", got, tt.want) } From f774b9b7c655f10c940605bdef96963657eefcde Mon Sep 17 00:00:00 2001 From: nandosobral03 Date: Thu, 20 Feb 2025 12:22:22 -0300 Subject: [PATCH 2/2] fix: comment --- cmd/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a268860..67051f7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,8 +26,8 @@ var ( web bool artOnly bool output string - yearToDate bool // renamed from ytd - until string // renamed from ytdEnd + yearToDate bool + until string ) // rootCmd is the root command for the GitHub Skyline CLI tool.