Skip to content
Merged
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
31 changes: 28 additions & 3 deletions github/strings.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,30 @@ import (
"bytes"
"fmt"
"reflect"
"strconv"
"sync"
)

var timestampType = reflect.TypeFor[Timestamp]()

var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}

// Stringify attempts to create a reasonable string representation of types in
// the GitHub library. It does things like resolve pointers to their values
// and omits struct fields with nil values.
func Stringify(message any) string {
var buf bytes.Buffer
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()

v := reflect.ValueOf(message)
stringifyValue(&buf, v)
stringifyValue(buf, v)
return buf.String()
}

Expand All @@ -34,8 +47,20 @@ func stringifyValue(w *bytes.Buffer, val reflect.Value) {
v := reflect.Indirect(val)

switch v.Kind() {
case reflect.Bool:
w.Write(strconv.AppendBool(w.Bytes(), v.Bool())[w.Len():])
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
w.Write(strconv.AppendInt(w.Bytes(), v.Int(), 10)[w.Len():])
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
w.Write(strconv.AppendUint(w.Bytes(), v.Uint(), 10)[w.Len():])
case reflect.Float32:
w.Write(strconv.AppendFloat(w.Bytes(), v.Float(), 'g', -1, 32)[w.Len():])
case reflect.Float64:
w.Write(strconv.AppendFloat(w.Bytes(), v.Float(), 'g', -1, 64)[w.Len():])
case reflect.String:
fmt.Fprintf(w, `"%v"`, v)
w.WriteByte('"')
w.WriteString(v.String())
w.WriteByte('"')
case reflect.Slice:
w.WriteByte('[')
for i := range v.Len() {
Expand Down
37 changes: 37 additions & 0 deletions github/strings_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2026 The go-github AUTHORS. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package github

import (
"testing"
)

type BenchmarkStruct struct {
Name string
Age int
Active bool
Score float32
Rank float64
Tags []string
Pointer *int
}

func BenchmarkStringify(b *testing.B) {
val := 42
s := &BenchmarkStruct{
Name: "benchmark",
Age: 30,
Active: true,
Score: 1.1,
Rank: 99.999999,
Tags: []string{"go", "github", "api"},
Pointer: Ptr(val),
}
b.ResetTimer()
for b.Loop() {
Stringify(s)
}
}
106 changes: 106 additions & 0 deletions github/strings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,92 @@ func TestStringify(t *testing.T) {
}
}

func TestStringify_Primitives(t *testing.T) {
t.Parallel()
tests := []struct {
in any
out string
}{
// Bool
{true, "true"},
{false, "false"},

// Int variants
{int(1), "1"},
{int8(2), "2"},
{int16(3), "3"},
{int32(4), "4"},
{int64(5), "5"},

// Uint variants
{uint(6), "6"},
{uint8(7), "7"},
{uint16(8), "8"},
{uint32(9), "9"},
{uint64(10), "10"},
{uintptr(11), "11"},

// Float variants (Precision Correctness)
{float32(1.1), "1.1"},
{float64(1.1), "1.1"},
{float32(1.0000001), "1.0000001"},
{float64(1.000000000000001), "1.000000000000001"},

// Boundary Cases
{int8(-128), "-128"},
{int8(127), "127"},
{uint64(18446744073709551615), "18446744073709551615"},

// String Optimization
{"hello", `"hello"`},
{"", `""`},
}

for i, tt := range tests {
s := Stringify(tt.in)
if s != tt.out {
t.Errorf("%v. Stringify(%T) => %q, want %q", i, tt.in, s, tt.out)
}
}
}

func TestStringify_BufferPool(t *testing.T) {
t.Parallel()
// Verify that concurrent usage of Stringify is safe and doesn't corrupt buffers.
// While we can't easily verify reuse without exposing internal metrics,
// we can verify correctness under load which implies proper Reset() handling.
const goroutines = 10
const iterations = 100

errCh := make(chan error, goroutines)

for range goroutines {
go func() {
for range iterations {
// Use a mix of types to exercise different code paths
s1 := Stringify(123)
if s1 != "123" {
errCh <- fmt.Errorf("got %q, want %q", s1, "123")
return
}

s2 := Stringify("test")
if s2 != `"test"` {
errCh <- fmt.Errorf("got %q, want %q", s2, `"test"`)
return
}
}
errCh <- nil
}()
}

for range goroutines {
if err := <-errCh; err != nil {
t.Error(err)
}
}
}

// Directly test the String() methods on various GitHub types. We don't do an
// exhaustive test of all the various field types, since TestStringify() above
// takes care of that. Rather, we just make sure that Stringify() is being
Expand Down Expand Up @@ -143,3 +229,23 @@ func TestString(t *testing.T) {
}
}
}

func TestStringify_Floats(t *testing.T) {
t.Parallel()
tests := []struct {
in any
out string
}{
{float32(1.1), "1.1"},
{float64(1.1), "1.1"},
{float32(1.0000001), "1.0000001"},
{struct{ F float32 }{1.1}, "{F:1.1}"},
}

for i, tt := range tests {
s := Stringify(tt.in)
if s != tt.out {
t.Errorf("%v. Stringify(%v) = %q, want %q", i, tt.in, s, tt.out)
}
}
}
Loading