Skip to content

Commit bb3cbfc

Browse files
committed
feat(app,github,cli): add structured logging, circuit breaker, and trace IDs
- Add internal/log package using log/slog with RedactedString for credential scrubbing - Wire structured logger through main, CLI, and app service with per-component attribution - Generate UUID trace ID per invocation for cross-line log correlation - Add circuit breaker to GitHub retry transport with closed/open/half-open states - Add circuit breaker tests covering all state transitions - Add .github/labels.yml with priority, type, and area labels - Expand runbooks with deployment observability and circuit breaker troubleshooting - Exclude .factory/ from git tracking
1 parent 0e70dd9 commit bb3cbfc

19 files changed

Lines changed: 444 additions & 33 deletions

.github/labels.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Priority labels
2+
- name: "priority/critical"
3+
color: "B60205"
4+
description: "Must be addressed immediately"
5+
- name: "priority/high"
6+
color: "D93F0B"
7+
description: "Should be addressed in current sprint"
8+
- name: "priority/medium"
9+
color: "FBCA04"
10+
description: "Should be addressed soon"
11+
- name: "priority/low"
12+
color: "0E8A16"
13+
description: "Nice to have"
14+
15+
# Type labels
16+
- name: "bug"
17+
color: "D73A4A"
18+
description: "Something isn't working"
19+
- name: "enhancement"
20+
color: "A2EEEF"
21+
description: "New feature or improvement"
22+
- name: "chore"
23+
color: "FEF2C0"
24+
description: "Maintenance or tooling work"
25+
- name: "documentation"
26+
color: "0075CA"
27+
description: "Documentation improvements"
28+
29+
# Area labels
30+
- name: "area/cli"
31+
color: "0052CC"
32+
description: "CLI adapter and user interface"
33+
- name: "area/corpus"
34+
color: "5319E7"
35+
description: "Storage, SQLite, and query layer"
36+
- name: "area/github"
37+
color: "EDEDED"
38+
description: "GitHub API client and network I/O"
39+
- name: "area/app"
40+
color: "D4C5F9"
41+
description: "Application service and orchestration"
42+
- name: "area/testing"
43+
color: "1D76DB"
44+
description: "Test infrastructure and coverage"
45+
- name: "area/ci"
46+
color: "BFDADC"
47+
description: "CI/CD workflows and automation"
48+
49+
# Dependency labels
50+
- name: "dependencies"
51+
color: "0366D6"
52+
description: "Dependency updates"
53+
- name: "go"
54+
color: "00ADD8"
55+
description: "Go-related changes"
56+
- name: "ci"
57+
color: "BFC1C3"
58+
description: "CI/CD changes"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ go.work.sum
2525
# === IDE ===
2626
.idea/
2727
.vscode/
28+
.factory/
2829
*.swp
2930
*.swo
3031
*~

cmd/gitcontribute/main.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"os/signal"
99
"syscall"
1010

11+
"github.com/google/uuid"
1112
"github.com/morluto/gitcontribute/internal/app"
1213
"github.com/morluto/gitcontribute/internal/cli"
1314
"github.com/morluto/gitcontribute/internal/config"
15+
gitlog "github.com/morluto/gitcontribute/internal/log"
1416
"github.com/morluto/gitcontribute/internal/tui"
1517
)
1618

@@ -20,25 +22,54 @@ func main() {
2022
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
2123
defer stop()
2224

25+
logger := gitlog.New("main")
26+
27+
// Generate a trace ID for this invocation so all log lines from the
28+
// same command run can be correlated.
29+
traceID := uuid.NewString()
30+
logger.InfoContext(ctx, "starting",
31+
"version", version,
32+
"trace_id", traceID,
33+
"args", os.Args[1:],
34+
)
35+
ctx = gitlog.WithTrace(ctx, traceID)
36+
2337
paths := config.NewPaths(nil)
24-
svc, err := app.New(paths, version)
38+
svc, err := app.New(paths, version, logger.With("component", "app"))
2539
if err != nil {
40+
logger.ErrorContext(ctx, "failed to initialize application", "error", err)
2641
fmt.Fprintln(os.Stderr, err)
2742
os.Exit(ExitGeneral)
2843
}
29-
defer func() { _ = svc.Close() }()
44+
defer func() {
45+
if err := svc.Close(); err != nil {
46+
logger.ErrorContext(ctx, "error during shutdown", "error", err)
47+
}
48+
}()
3049

3150
c := cli.New(svc, svc.NewMCPRunner(), os.Stdout, os.Stderr)
51+
c.SetLogger(logger.With("component", "cli"))
3252
c.SetTUIRunner(tui.NewRunner(svc, os.Stdin, os.Stdout))
3353
if err := c.Run(ctx, os.Args[1:]); err != nil {
3454
var ce *cli.CLIError
3555
if errors.As(err, &ce) {
56+
logger.ErrorContext(ctx, "command failed",
57+
"error", ce.Error(),
58+
"code", ce.Code,
59+
"trace_id", traceID,
60+
)
3661
fmt.Fprintln(os.Stderr, ce.Error())
3762
os.Exit(ce.Code)
3863
}
64+
logger.ErrorContext(ctx, "command failed",
65+
"error", err,
66+
"trace_id", traceID,
67+
)
3968
fmt.Fprintln(os.Stderr, err)
4069
os.Exit(ExitGeneral)
4170
}
71+
72+
logger.InfoContext(ctx, "command completed", "trace_id", traceID)
4273
}
4374

4475
const ExitGeneral = 1

docs/runbooks.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ gitcontribute health
88

99
Checks SQLite database integrity, GitHub API connectivity, and local filesystem state.
1010

11+
## Deployment Observability
12+
13+
**CI Pipeline**: https://github.com/morluto/gitcontribute/actions
14+
15+
Monitor the CI workflow for build status, test coverage trends, and lint results.
16+
Coverage reports are uploaded as artifacts on each run.
17+
18+
**Release Dashboard**: https://github.com/morluto/gitcontribute/releases
19+
20+
Track version history and release notes. Each release is built via GoReleaser
21+
with cross-platform binaries and checksums.
22+
1123
## Database Integrity
1224

1325
If SQLite corruption is detected:
@@ -25,6 +37,18 @@ If GitHub API rate limits are hit:
2537
2. Wait for the reset window (shown in `X-RateLimit-Reset` header)
2638
3. Reduce concurrent operations via `--concurrency` flag
2739

40+
## Circuit Breaker
41+
42+
The GitHub client uses a circuit breaker that opens after 5 consecutive failures.
43+
When the circuit is open, all requests fail fast with `ErrCircuitOpen` rather
44+
than retrying. After a 30-second cooldown, a single probe request is allowed.
45+
If the probe succeeds, the circuit closes; if it fails, the circuit re-opens.
46+
47+
To check circuit status, enable debug logging:
48+
```sh
49+
GITCONTRIBUTE_LOG_LEVEL=debug gitcontribute sync owner/repo
50+
```
51+
2852
## Job Reconciliation
2953

3054
If jobs appear stuck:

internal/app/app.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"log/slog"
89
"net/http"
910
"os"
1011
"path/filepath"
@@ -35,15 +36,16 @@ type Service struct {
3536
archiveFetcher discovery.ArchiveFetcher
3637
clock func() time.Time
3738
version string
39+
logger *slog.Logger
3840
}
3941

4042
// New creates a Service and resolves local configuration. GitHub credentials
4143
// are resolved lazily only when a network-reading operation is requested.
42-
func New(paths *config.Paths, version string) (*Service, error) {
44+
func New(paths *config.Paths, version string, logger *slog.Logger) (*Service, error) {
4345
if paths == nil {
4446
paths = config.NewPaths(nil)
4547
}
46-
s := &Service{paths: paths, version: version, clock: time.Now}
48+
s := &Service{paths: paths, version: version, clock: time.Now, logger: logger}
4749
if _, err := s.loadConfig(false); err != nil {
4850
return nil, err
4951
}

internal/app/app_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ func newTestService(t *testing.T, srv *httptest.Server) *Service {
179179
t.Helper()
180180
ctx := context.Background()
181181
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
182-
svc, err := New(paths, "test")
182+
svc, err := New(paths, "test", nil)
183183
if err != nil {
184184
t.Fatalf("new service: %v", err)
185185
}
@@ -405,7 +405,7 @@ func TestLocalInitializationDoesNotResolveKeyringAuth(t *testing.T) {
405405
if err := config.Save(configPath, cfg); err != nil {
406406
t.Fatal(err)
407407
}
408-
svc, err := New(paths, "test")
408+
svc, err := New(paths, "test", nil)
409409
if err != nil {
410410
t.Fatalf("local service construction resolved GitHub auth: %v", err)
411411
}
@@ -444,7 +444,7 @@ func TestNewRejectsInvalidConfiguredTokenSource(t *testing.T) {
444444
t.Fatal(err)
445445
}
446446

447-
_, err = New(paths, "test")
447+
_, err = New(paths, "test", nil)
448448
if err == nil || !strings.Contains(err.Error(), "invalid token_source method") {
449449
t.Fatalf("New error = %v, want invalid token source", err)
450450
}
@@ -537,7 +537,7 @@ func TestMCPSearchRequiresCompleteRepositoryFilter(t *testing.T) {
537537
func TestSearchCodeUsesStoredSnapshotWithoutNetwork(t *testing.T) {
538538
ctx := context.Background()
539539
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
540-
svc, err := New(paths, "test")
540+
svc, err := New(paths, "test", nil)
541541
if err != nil {
542542
t.Fatal(err)
543543
}
@@ -564,7 +564,7 @@ func TestSearchCodeUsesStoredSnapshotWithoutNetwork(t *testing.T) {
564564
func TestInvestigationAndOpportunityFlow(t *testing.T) {
565565
ctx := context.Background()
566566
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
567-
svc, err := New(paths, "test")
567+
svc, err := New(paths, "test", nil)
568568
if err != nil {
569569
t.Fatal(err)
570570
}
@@ -649,7 +649,7 @@ func TestInvestigationAndOpportunityFlow(t *testing.T) {
649649
func TestPrepareContributionDrafts(t *testing.T) {
650650
ctx := context.Background()
651651
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
652-
svc, err := New(paths, "test")
652+
svc, err := New(paths, "test", nil)
653653
if err != nil {
654654
t.Fatalf("new service: %v", err)
655655
}
@@ -725,7 +725,7 @@ func TestPrepareContributionDrafts(t *testing.T) {
725725
func TestValidationDefineRunAndCompare(t *testing.T) {
726726
ctx := context.Background()
727727
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
728-
svc, err := New(paths, "test")
728+
svc, err := New(paths, "test", nil)
729729
if err != nil {
730730
t.Fatalf("new service: %v", err)
731731
}
@@ -794,7 +794,7 @@ func TestValidationDefineRunAndCompare(t *testing.T) {
794794
func TestDefineValidationParsesQuotedArguments(t *testing.T) {
795795
ctx := context.Background()
796796
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
797-
svc, err := New(paths, "test")
797+
svc, err := New(paths, "test", nil)
798798
if err != nil {
799799
t.Fatal(err)
800800
}
@@ -829,7 +829,7 @@ func TestWorkspaceCreateAndShow(t *testing.T) {
829829
remote, baseSHA, candidateSHA := setupAppGitRemote(t)
830830

831831
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
832-
svc, err := New(paths, "test")
832+
svc, err := New(paths, "test", nil)
833833
if err != nil {
834834
t.Fatalf("new service: %v", err)
835835
}
@@ -935,7 +935,7 @@ func newLocalService(t *testing.T) *Service {
935935
t.Helper()
936936
ctx := context.Background()
937937
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
938-
svc, err := New(paths, "test")
938+
svc, err := New(paths, "test", nil)
939939
if err != nil {
940940
t.Fatalf("new service: %v", err)
941941
}

internal/app/control_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func (s failingAuthSource) Token(context.Context) (string, error) { return "", s
2222

2323
func TestMetadataIsLocalAndDoesNotCreateCorpus(t *testing.T) {
2424
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
25-
svc, err := New(paths, "v1.2.3")
25+
svc, err := New(paths, "v1.2.3", nil)
2626
if err != nil {
2727
t.Fatal(err)
2828
}
@@ -45,7 +45,7 @@ func TestMetadataIsLocalAndDoesNotCreateCorpus(t *testing.T) {
4545

4646
func TestConfigureInvalidInputDoesNotReplaceFile(t *testing.T) {
4747
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
48-
svc, err := New(paths, "test")
48+
svc, err := New(paths, "test", nil)
4949
if err != nil {
5050
t.Fatal(err)
5151
}
@@ -77,7 +77,7 @@ func TestConfigureInvalidInputDoesNotReplaceFile(t *testing.T) {
7777

7878
func TestConfigureDryRunDoesNotSave(t *testing.T) {
7979
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
80-
svc, err := New(paths, "test")
80+
svc, err := New(paths, "test", nil)
8181
if err != nil {
8282
t.Fatal(err)
8383
}
@@ -98,7 +98,7 @@ func TestConfigureDryRunDoesNotSave(t *testing.T) {
9898

9999
func TestControlStatusUsesLocalCorpus(t *testing.T) {
100100
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
101-
svc, err := New(paths, "test")
101+
svc, err := New(paths, "test", nil)
102102
if err != nil {
103103
t.Fatal(err)
104104
}
@@ -136,7 +136,7 @@ func TestDoctorDoesNotExposeEnvironmentToken(t *testing.T) {
136136
secret := strings.Join([]string{"github_pat", "fixture-control-value"}, "_")
137137
t.Setenv("GITCONTRIBUTE_TEST_TOKEN", secret)
138138
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
139-
svc, err := New(paths, "test")
139+
svc, err := New(paths, "test", nil)
140140
if err != nil {
141141
t.Fatal(err)
142142
}
@@ -161,7 +161,7 @@ func TestDoctorDoesNotExposeEnvironmentToken(t *testing.T) {
161161

162162
func TestDoctorInspectsEffectiveRuntimeConfig(t *testing.T) {
163163
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
164-
svc, err := New(paths, "test")
164+
svc, err := New(paths, "test", nil)
165165
if err != nil {
166166
t.Fatal(err)
167167
}
@@ -189,7 +189,7 @@ func TestConfigureDoesNotPersistEnvOverrides(t *testing.T) {
189189
t.Fatal(err)
190190
}
191191

192-
svc, err := New(paths, "test")
192+
svc, err := New(paths, "test", nil)
193193
if err != nil {
194194
t.Fatal(err)
195195
}

internal/app/discovery_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ func newTestServiceNoNetwork(t *testing.T) *Service {
641641
t.Helper()
642642
ctx := context.Background()
643643
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
644-
svc, err := New(paths, "test")
644+
svc, err := New(paths, "test", nil)
645645
if err != nil {
646646
t.Fatalf("new service: %v", err)
647647
}

internal/app/dossier_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818
func TestBuildAndGetRepositoryDossier(t *testing.T) {
1919
ctx := context.Background()
2020
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
21-
svc, err := New(paths, "test")
21+
svc, err := New(paths, "test", nil)
2222
if err != nil {
2323
t.Fatal(err)
2424
}
@@ -143,7 +143,7 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) {
143143
func TestExtractSeeds(t *testing.T) {
144144
ctx := context.Background()
145145
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
146-
svc, err := New(paths, "test")
146+
svc, err := New(paths, "test", nil)
147147
if err != nil {
148148
t.Fatal(err)
149149
}
@@ -273,7 +273,7 @@ func TestExtractSeeds(t *testing.T) {
273273
func TestExtractSeedsRequiresNoNetwork(t *testing.T) {
274274
ctx := context.Background()
275275
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
276-
svc, err := New(paths, "test")
276+
svc, err := New(paths, "test", nil)
277277
if err != nil {
278278
t.Fatal(err)
279279
}

internal/app/health_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
func TestRepositoryHealth(t *testing.T) {
1717
ctx := context.Background()
1818
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
19-
svc, err := New(paths, "test")
19+
svc, err := New(paths, "test", nil)
2020
if err != nil {
2121
t.Fatal(err)
2222
}

0 commit comments

Comments
 (0)