Skip to content

Commit b569a22

Browse files
Merge pull request #123 from github/chrisreddington/fix-code-review-issues
Improve error handling and refactor geometry generation
2 parents fd47b95 + a21ef85 commit b569a22

10 files changed

Lines changed: 144 additions & 144 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ func handleSkylineCommand(_ *cobra.Command, _ []string) error {
9494
if web {
9595
b := browser.New("", os.Stdout, os.Stderr)
9696
if err := openGitHubProfile(user, client, b); err != nil {
97-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
98-
os.Exit(1)
97+
return err
9998
}
10099
return nil
101100
}

cmd/skyline/skyline.go

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package skyline
44

55
import (
66
"fmt"
7-
"strings"
87
"time"
98

109
"github.com/github/gh-skyline/internal/ascii"
@@ -67,26 +66,7 @@ func GenerateSkyline(startYear, endYear int, targetUser string, full bool, outpu
6766
return warnErr
6867
}
6968
} else {
70-
if year == startYear {
71-
// For first year, show full ASCII art including header
72-
fmt.Println(asciiArt)
73-
} else {
74-
// For subsequent years, skip the header
75-
lines := strings.Split(asciiArt, "\n")
76-
gridStart := 0
77-
for i, line := range lines {
78-
containsEmptyBlock := strings.Contains(line, string(ascii.EmptyBlock))
79-
containsFoundationLow := strings.Contains(line, string(ascii.FoundationLow))
80-
isNotOnlyEmptyBlocks := strings.Trim(line, string(ascii.EmptyBlock)) != ""
81-
82-
if (containsEmptyBlock || containsFoundationLow) && isNotOnlyEmptyBlocks {
83-
gridStart = i
84-
break
85-
}
86-
}
87-
// Print just the grid and user info
88-
fmt.Println(strings.Join(lines[gridStart:], "\n"))
89-
}
69+
fmt.Println(asciiArt)
9070
}
9171
}
9272

internal/errors/errors.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
NetworkError ErrorType = "NETWORK" // Network communication errors
1818
GraphQLError ErrorType = "GRAPHQL" // GitHub GraphQL API errors
1919
STLError ErrorType = "STL" // STL file generation errors
20+
GeneralError ErrorType = "GENERAL" // General errors not fitting other categories
2021
)
2122

2223
// SkylineError provides structured error information including type and context
@@ -59,9 +60,9 @@ func Wrap(err error, message string) error {
5960
}
6061
}
6162

62-
// For other errors, treat as a generic error
63+
// For other errors, use GeneralError to avoid misleading type attribution
6364
return &SkylineError{
64-
Type: STLError, // Default to STLError for wrapped errors
65+
Type: GeneralError,
6566
Message: message,
6667
Err: err,
6768
}

internal/errors/errors_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func TestWrap(t *testing.T) {
5959
name: "wrap standard error",
6060
err: errors.New("original error"),
6161
message: "wrapped message",
62-
want: "[STL] wrapped message: original error",
62+
want: "[GENERAL] wrapped message: original error",
6363
},
6464
{
6565
name: "wrap SkylineError preserves type",

internal/github/client.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,13 @@ func (c *Client) GetUserJoinYear(username string) (int, error) {
137137
return 0, errors.New(errors.NetworkError, "failed to fetch user's join date", err)
138138
}
139139

140-
// Parse the join date
141-
joinYear := response.User.CreatedAt.Year()
142-
if joinYear == 0 {
140+
// Validate that the API returned a real creation date
141+
if response.User.CreatedAt.IsZero() {
143142
return 0, errors.New(errors.ValidationError, "invalid join date received from GitHub API", nil)
144143
}
145144

145+
// Parse the join date
146+
joinYear := response.User.CreatedAt.Year()
147+
146148
return joinYear, nil
147149
}

internal/github/client_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package github
22

33
import (
44
"testing"
5-
"time"
65

76
"github.com/github/gh-skyline/internal/errors"
87
"github.com/github/gh-skyline/internal/testutil/mocks"
@@ -57,15 +56,13 @@ func TestGetUserJoinYear(t *testing.T) {
5756
tests := []struct {
5857
name string
5958
username string
60-
mockResponse time.Time
6159
mockError error
6260
expectedYear int
6361
expectedError bool
6462
}{
6563
{
6664
name: "successful response",
6765
username: "testuser",
68-
mockResponse: time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC),
6966
expectedYear: 2015,
7067
expectedError: false,
7168
},
@@ -80,6 +77,12 @@ func TestGetUserJoinYear(t *testing.T) {
8077
mockError: errors.New(errors.NetworkError, "network error", nil),
8178
expectedError: true,
8279
},
80+
{
81+
name: "zero join date",
82+
username: "testuser",
83+
// expectedYear is 0, so mock.Do leaves CreatedAt as zero time.Time, triggering IsZero guard
84+
expectedError: true,
85+
},
8386
}
8487

8588
for _, tt := range tests {

internal/logger/logger_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,40 @@ func TestError(t *testing.T) {
151151
}
152152
}
153153

154+
func TestWarning(t *testing.T) {
155+
logger, capture := setupTestLogger(t)
156+
157+
tests := []struct {
158+
name string
159+
level LogLevel
160+
message string
161+
wantLog bool
162+
}{
163+
{"Warning when level is DEBUG", DEBUG, "test warning message", true},
164+
{"Warning when level is INFO", INFO, "test warning info level", true},
165+
{"Warning when level is WARNING", WARNING, "test warning", true},
166+
{"Warning when level is ERROR", ERROR, "should not show", false},
167+
}
168+
169+
for _, tt := range tests {
170+
t.Run(tt.name, func(t *testing.T) {
171+
capture.stdout.Reset()
172+
logger.SetLevel(tt.level)
173+
if err := logger.Warning("%s", tt.message); err != nil {
174+
t.Errorf("Warning() error = %v", err)
175+
}
176+
177+
hasOutput := capture.stdout.Len() > 0
178+
if hasOutput != tt.wantLog {
179+
t.Errorf("Warning() output = %v, want %v", hasOutput, tt.wantLog)
180+
}
181+
if tt.wantLog && !strings.Contains(capture.stdout.String(), tt.message) {
182+
t.Errorf("Warning() output doesn't contain message: %s", tt.message)
183+
}
184+
})
185+
}
186+
}
187+
154188
func TestLogLevelString(t *testing.T) {
155189
tests := []struct {
156190
name string

internal/stl/generator.go

Lines changed: 46 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package stl
22

33
import (
44
"fmt"
5-
"sync"
65

76
"github.com/github/gh-skyline/internal/errors"
87
"github.com/github/gh-skyline/internal/logger"
@@ -32,10 +31,25 @@ func GenerateSTLRange(contributions [][][]types.ContributionDay, outputPath, use
3231
return errors.Wrap(err, "failed to log debug message")
3332
}
3433

34+
if len(contributions) == 0 {
35+
return errors.New(errors.ValidationError, "contributions data cannot be empty", nil)
36+
}
37+
3538
if err := validateInput(contributions[0], outputPath, username); err != nil {
3639
return errors.Wrap(err, "input validation failed")
3740
}
3841

42+
// Apply the same size bounds to every remaining year.
43+
// outputPath and username are shared across all years and have already been validated above.
44+
for i := 1; i < len(contributions); i++ {
45+
if len(contributions[i]) == 0 {
46+
return errors.New(errors.ValidationError, fmt.Sprintf("contributions data for year index %d cannot be empty", i), nil)
47+
}
48+
if len(contributions[i]) > geometry.GridSize {
49+
return errors.New(errors.ValidationError, fmt.Sprintf("contributions data for year index %d exceeds maximum grid size", i), nil)
50+
}
51+
}
52+
3953
dimensions, err := calculateDimensions(len(contributions))
4054
if err != nil {
4155
return errors.Wrap(err, "failed to calculate dimensions")
@@ -144,49 +158,51 @@ type geometryResult struct {
144158

145159
// generateModelGeometry orchestrates the concurrent generation of all model components.
146160
// It manages four parallel processes for generating the base, columns, text, and logo.
161+
// Channels are buffered so every goroutine can send and exit even if an error causes
162+
// an early return, preventing goroutine leaks.
147163
func generateModelGeometry(contributionsPerYear [][][]types.ContributionDay, dims modelDimensions, maxContrib int, username string, startYear, endYear int) ([]types.Triangle, error) {
148164
if len(contributionsPerYear) == 0 {
149165
return nil, errors.New(errors.ValidationError, "contributions data cannot be empty", nil)
150166
}
151167

152-
// Create channels for each geometry component
153-
channels := map[string]chan geometryResult{
154-
"base": make(chan geometryResult),
155-
"columns": make(chan geometryResult),
156-
"text": make(chan geometryResult),
157-
"image": make(chan geometryResult),
168+
// componentChannel pairs a name with its buffered result channel.
169+
// Using a slice (not a map) preserves a stable iteration order so that
170+
// triangles are always appended base → columns → text → image, giving
171+
// reproducible STL output across runs.
172+
type componentChannel struct {
173+
name string
174+
ch chan geometryResult
158175
}
159176

160-
var wg sync.WaitGroup
161-
wg.Add(len(channels))
177+
// Buffered channels (size 1) allow each goroutine to send its result and exit
178+
// regardless of whether the main goroutine reads or returns early on error.
179+
components := []componentChannel{
180+
{"base", make(chan geometryResult, 1)},
181+
{"columns", make(chan geometryResult, 1)},
182+
{"text", make(chan geometryResult, 1)},
183+
{"image", make(chan geometryResult, 1)},
184+
}
162185

163186
// Launch goroutines for each component
164-
go generateBase(dims, channels["base"], &wg)
165-
go generateColumnsForYearRange(contributionsPerYear, maxContrib, channels["columns"], &wg)
166-
go generateText(username, startYear, endYear, dims, channels["text"], &wg)
167-
go generateLogo(dims, channels["image"], &wg)
187+
go generateBase(dims, components[0].ch)
188+
go generateColumnsForYearRange(contributionsPerYear, maxContrib, components[1].ch)
189+
go generateText(username, startYear, endYear, dims, components[2].ch)
190+
go generateLogo(dims, components[3].ch)
168191

169-
// Collect results from all channels
192+
// Collect results in declaration order for a reproducible triangle sequence.
170193
modelTriangles := make([]types.Triangle, 0, estimateTriangleCount(contributionsPerYear[0])*len(contributionsPerYear))
171-
for componentName := range channels {
172-
result := <-channels[componentName]
194+
for _, component := range components {
195+
result := <-component.ch
173196
if result.err != nil {
174-
return nil, errors.Wrap(result.err, fmt.Sprintf("failed to generate %s geometry", componentName))
197+
return nil, errors.Wrap(result.err, fmt.Sprintf("failed to generate %s geometry", component.name))
175198
}
176199
modelTriangles = append(modelTriangles, result.triangles...)
177200
}
178201

179-
// Clean up
180-
wg.Wait()
181-
for _, ch := range channels {
182-
close(ch)
183-
}
184-
185202
return modelTriangles, nil
186203
}
187204

188-
func generateBase(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitGroup) {
189-
defer wg.Done()
205+
func generateBase(dims modelDimensions, ch chan<- geometryResult) {
190206
baseTriangles, err := geometry.CreateCuboidBase(dims.innerWidth, dims.innerDepth)
191207

192208
if err != nil {
@@ -202,8 +218,7 @@ func generateBase(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitG
202218
}
203219

204220
// generateText creates 3D text geometry for the model
205-
func generateText(username string, startYear int, endYear int, dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitGroup) {
206-
defer wg.Done()
221+
func generateText(username string, startYear int, endYear int, dims modelDimensions, ch chan<- geometryResult) {
207222
embossedYear := fmt.Sprintf("%d", endYear)
208223

209224
// If start year and end year are the same, only show one year
@@ -225,8 +240,7 @@ func generateText(username string, startYear int, endYear int, dims modelDimensi
225240
}
226241

227242
// generateLogo handles the generation of the GitHub logo geometry
228-
func generateLogo(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitGroup) {
229-
defer wg.Done()
243+
func generateLogo(dims modelDimensions, ch chan<- geometryResult) {
230244
logoTriangles, err := geometry.GenerateImageGeometry(dims.innerWidth, geometry.BaseHeight)
231245
if err != nil {
232246
// Log warning and continue without logo instead of failing
@@ -257,8 +271,7 @@ func estimateTriangleCount(contributions [][]types.ContributionDay) int {
257271
}
258272

259273
// generateColumnsForYearRange generates contribution columns for multiple years
260-
func generateColumnsForYearRange(contributionsPerYear [][][]types.ContributionDay, maxContrib int, ch chan<- geometryResult, wg *sync.WaitGroup) {
261-
defer wg.Done()
274+
func generateColumnsForYearRange(contributionsPerYear [][][]types.ContributionDay, maxContrib int, ch chan<- geometryResult) {
262275
var yearTriangles []types.Triangle
263276

264277
// Process years in reverse order so most recent year is at the front
@@ -267,6 +280,8 @@ func generateColumnsForYearRange(contributionsPerYear [][][]types.ContributionDa
267280
triangles, err := geometry.CreateContributionGeometry(contributionsPerYear[i], yearOffset, maxContrib)
268281
if err != nil {
269282
if logErr := logger.GetLogger().Warning("Failed to generate column geometry for year %d: %v. Skipping year.", i, err); logErr != nil {
283+
// logErr is secondary; report the original geometry error to the caller.
284+
ch <- geometryResult{triangles: []types.Triangle{}, err: err}
270285
return
271286
}
272287
continue
@@ -276,34 +291,3 @@ func generateColumnsForYearRange(contributionsPerYear [][][]types.ContributionDa
276291

277292
ch <- geometryResult{triangles: yearTriangles}
278293
}
279-
280-
// CreateContributionGeometry generates geometry for a single year's worth of contributions
281-
func CreateContributionGeometry(contributions [][]types.ContributionDay, yearIndex int, maxContrib int) []types.Triangle {
282-
var triangles []types.Triangle
283-
284-
// Calculate the Y offset for this year's grid
285-
// Each subsequent year is placed further back (larger Y value)
286-
baseYOffset := float64(yearIndex) * (geometry.YearOffset + geometry.YearSpacing)
287-
288-
// Generate contribution columns
289-
for weekIdx, week := range contributions {
290-
for dayIdx, day := range week {
291-
if day.ContributionCount > 0 {
292-
height := geometry.NormalizeContribution(day.ContributionCount, maxContrib)
293-
x := float64(weekIdx) * geometry.CellSize
294-
y := baseYOffset + float64(dayIdx)*geometry.CellSize
295-
296-
columnTriangles, err := geometry.CreateColumn(x, y, height, geometry.CellSize)
297-
if err != nil {
298-
if logErr := logger.GetLogger().Warning("Failed to generate column geometry: %v. Skipping column.", err); logErr != nil {
299-
return nil
300-
}
301-
continue
302-
}
303-
triangles = append(triangles, columnTriangles...)
304-
}
305-
}
306-
}
307-
308-
return triangles
309-
}

0 commit comments

Comments
 (0)