Skip to content

Commit 36f6534

Browse files
julianknutsenclaude
andcommitted
Add github-local backend and E2E test coverage across all provider types
Introduce FakeGitHubProvider (embeds GitProvider, Type()="github") for offline integration testing of GitHub-specific code paths. Wire a --github-local flag on `wl join` so the offline test suite can exercise the github provider without requiring a real GitHub account or gh CLI. All existing integration tests now run against 3 backends (file, git, github) instead of 2. New tests cover browse, status lifecycle, provider gates (approve/request-changes/review --gh-pr), config get provider-type, and review output formats (--md, --json). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0be2f9e commit 36f6534

11 files changed

Lines changed: 487 additions & 8 deletions

File tree

cmd/wl/cmd_join.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func newJoinCmd(stdout, stderr io.Writer) *cobra.Command {
2525
remoteBase string
2626
gitRemote string
2727
github bool
28+
githubLocal string
2829
)
2930

3031
cmd := &cobra.Command{
@@ -60,7 +61,7 @@ Examples:
6061
if len(args) > 0 {
6162
upstream = args[0]
6263
}
63-
return runJoin(stdout, stderr, upstream, handle, displayName, email, forkOrg, remoteBase, gitRemote, github)
64+
return runJoin(stdout, stderr, upstream, handle, displayName, email, forkOrg, remoteBase, gitRemote, github, githubLocal)
6465
},
6566
}
6667

@@ -71,12 +72,13 @@ Examples:
7172
cmd.Flags().StringVar(&remoteBase, "remote-base", "", "Base directory for file:// remotes (offline mode)")
7273
cmd.Flags().StringVar(&gitRemote, "git-remote", "", "Base directory for bare git remotes")
7374
cmd.Flags().BoolVar(&github, "github", false, "Use GitHub as the upstream provider")
74-
cmd.MarkFlagsMutuallyExclusive("remote-base", "git-remote", "github")
75+
cmd.Flags().StringVar(&githubLocal, "github-local", "", "Local base directory for GitHub-compatible testing mode")
76+
cmd.MarkFlagsMutuallyExclusive("remote-base", "git-remote", "github", "github-local")
7577

7678
return cmd
7779
}
7880

79-
func runJoin(stdout, stderr io.Writer, upstream, handle, displayName, email, forkOrg, remoteBase, gitRemote string, github bool) error {
81+
func runJoin(stdout, stderr io.Writer, upstream, handle, displayName, email, forkOrg, remoteBase, gitRemote string, github bool, githubLocal string) error {
8082
// Parse upstream path (validate early)
8183
_, _, err := federation.ParseUpstream(upstream)
8284
if err != nil {
@@ -123,6 +125,13 @@ func runJoin(stdout, stderr io.Writer, upstream, handle, displayName, email, for
123125
}
124126
provider = remote.NewGitHubProvider()
125127

128+
case githubLocal != "":
129+
// GitHub-local mode — bare git repos that report type "github" for testing.
130+
if forkOrg == "" {
131+
return fmt.Errorf("--fork-org is required in GitHub-local mode (or set DOLTHUB_ORG)")
132+
}
133+
provider = remote.NewFakeGitHubProvider(githubLocal)
134+
126135
default:
127136
// DoltHub mode — requires token and org.
128137
token := commons.DoltHubToken()

cmd/wl/testdata/errors.txtar

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ env DOLTHUB_TOKEN=fake-token
1818
stderr 'DOLTHUB_ORG'
1919
env DOLTHUB_TOKEN=
2020

21+
# join --github-local with --remote-base (mutually exclusive).
22+
! exec wl join hop/wl-commons --github-local /tmp --remote-base /tmp
23+
stderr 'none of the others can be'
24+
2125
# post missing --title.
2226
! exec wl post
2327
stderr 'required flag.*"title"'

internal/remote/conformance_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ func providers() []providerFactory {
173173
}
174174
},
175175
},
176+
{
177+
name: "FakeGitHubProvider",
178+
setup: func(t *testing.T, baseDir string) remote.Provider {
179+
createGitSource(t, baseDir, "src-org", "testdb")
180+
return remote.NewFakeGitHubProvider(baseDir)
181+
},
182+
urlTest: func(t *testing.T, url string) {
183+
if !strings.HasPrefix(url, "file://") {
184+
t.Errorf("FakeGitHubProvider URL should start with file://, got %q", url)
185+
}
186+
if !strings.HasSuffix(url, ".git") {
187+
t.Errorf("FakeGitHubProvider URL should end with .git, got %q", url)
188+
}
189+
},
190+
},
176191
}
177192
}
178193

internal/remote/github_fake.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package remote
2+
3+
// FakeGitHubProvider embeds GitProvider and overrides Type() to return "github".
4+
// This enables offline integration tests to exercise GitHub-specific code paths
5+
// (provider gates, config fields) without requiring a real GitHub account or
6+
// the gh CLI.
7+
type FakeGitHubProvider struct {
8+
*GitProvider
9+
}
10+
11+
// NewFakeGitHubProvider creates a FakeGitHubProvider rooted at baseDir.
12+
func NewFakeGitHubProvider(baseDir string) *FakeGitHubProvider {
13+
return &FakeGitHubProvider{GitProvider: NewGitProvider(baseDir)}
14+
}
15+
16+
// Type returns "github" so cfg.IsGitHub() returns true after join.
17+
func (f *FakeGitHubProvider) Type() string { return "github" }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package remote_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/steveyegge/wasteland/internal/remote"
8+
)
9+
10+
func TestFakeGitHubProviderType(t *testing.T) {
11+
p := remote.NewFakeGitHubProvider("/tmp/test")
12+
if got := p.Type(); got != "github" {
13+
t.Errorf("Type() = %q, want %q", got, "github")
14+
}
15+
}
16+
17+
func TestFakeGitHubProviderDatabaseURL(t *testing.T) {
18+
p := remote.NewFakeGitHubProvider("/tmp/base")
19+
url := p.DatabaseURL("myorg", "mydb")
20+
if !strings.HasPrefix(url, "file://") {
21+
t.Errorf("URL should start with file://, got %q", url)
22+
}
23+
if !strings.HasSuffix(url, ".git") {
24+
t.Errorf("URL should end with .git, got %q", url)
25+
}
26+
if !strings.Contains(url, "myorg") {
27+
t.Errorf("URL should contain org, got %q", url)
28+
}
29+
if !strings.Contains(url, "mydb") {
30+
t.Errorf("URL should contain db, got %q", url)
31+
}
32+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//go:build integration
2+
3+
package offline
4+
5+
import (
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestBrowseIntegration(t *testing.T) {
11+
for _, backend := range backends {
12+
t.Run(string(backend), func(t *testing.T) {
13+
env := newTestEnv(t, backend)
14+
env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB)
15+
env.joinWasteland(t, upstream, forkOrg)
16+
17+
stdout, stderr, err := runWL(t, env, "browse")
18+
if err != nil {
19+
t.Fatalf("wl browse failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
20+
}
21+
22+
if !strings.Contains(stdout, "Seed item from upstream") {
23+
t.Errorf("expected browse output to contain 'Seed item from upstream', got: %s", stdout)
24+
}
25+
})
26+
}
27+
}
28+
29+
func TestBrowseJSON(t *testing.T) {
30+
for _, backend := range backends {
31+
t.Run(string(backend), func(t *testing.T) {
32+
env := newTestEnv(t, backend)
33+
env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB)
34+
env.joinWasteland(t, upstream, forkOrg)
35+
36+
stdout, stderr, err := runWL(t, env, "browse", "--json")
37+
if err != nil {
38+
t.Fatalf("wl browse --json failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
39+
}
40+
41+
if !strings.Contains(stdout, "w-seed001") {
42+
t.Errorf("expected JSON output to contain 'w-seed001', got: %s", stdout)
43+
}
44+
})
45+
}
46+
}
47+
48+
func TestBrowseFilterByType(t *testing.T) {
49+
for _, backend := range backends {
50+
t.Run(string(backend), func(t *testing.T) {
51+
env := newTestEnv(t, backend)
52+
env.createUpstreamStoreWithData(t, upstreamOrg, upstreamDB)
53+
env.joinWasteland(t, upstream, forkOrg)
54+
55+
// Seed data has type=feature. Filter by --type bug.
56+
stdout, stderr, err := runWL(t, env, "browse", "--type", "bug")
57+
if err != nil {
58+
t.Fatalf("wl browse --type bug failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
59+
}
60+
61+
if strings.Contains(stdout, "Seed item from upstream") {
62+
t.Errorf("expected seed item NOT in bug-filtered output, got: %s", stdout)
63+
}
64+
})
65+
}
66+
}

test/integration/offline/lifecycle_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,42 @@ func TestJoinCreatesConfig(t *testing.T) {
5757
if localDir == "" {
5858
t.Fatal("local_dir is empty")
5959
}
60+
61+
// Verify provider_type matches the backend.
62+
providerType, _ := cfg["provider_type"].(string)
63+
switch backend {
64+
case fileBackend:
65+
if providerType != "file" {
66+
t.Errorf("provider_type = %q, want %q", providerType, "file")
67+
}
68+
case gitBackend:
69+
if providerType != "git" {
70+
t.Errorf("provider_type = %q, want %q", providerType, "git")
71+
}
72+
case githubBackend:
73+
if providerType != "github" {
74+
t.Errorf("provider_type = %q, want %q", providerType, "github")
75+
}
76+
}
77+
78+
// Verify upstream_url is set and has expected format.
79+
upstreamURL, _ := cfg["upstream_url"].(string)
80+
if upstreamURL == "" {
81+
t.Fatal("upstream_url is empty")
82+
}
83+
if !strings.HasPrefix(upstreamURL, "file://") {
84+
t.Errorf("upstream_url should start with file://, got %q", upstreamURL)
85+
}
86+
switch backend {
87+
case gitBackend, githubBackend:
88+
if !strings.HasSuffix(upstreamURL, ".git") {
89+
t.Errorf("upstream_url for %s should end with .git, got %q", backend, upstreamURL)
90+
}
91+
case fileBackend:
92+
if strings.HasSuffix(upstreamURL, ".git") {
93+
t.Errorf("upstream_url for file should not end with .git, got %q", upstreamURL)
94+
}
95+
}
6096
})
6197
}
6298
}

test/integration/offline/offline_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
// Package offline contains integration tests that exercise the wl binary
44
// against real dolt databases using local remotes. No network required.
55
//
6-
// Every test is parameterized over two backends:
6+
// Every test is parameterized over three backends:
77
// - file: dolt remote stores via --remote-base (file:// URLs)
88
// - git: bare git repos via --git-remote (file:// URLs to .git dirs)
9+
// - github: bare git repos via --github-local (provider_type="github")
910
//
1011
// Every test goes through the front door: "wl join" sets up the fork and
1112
// config, then post/claim/done/sync operate on the result.
@@ -80,12 +81,13 @@ func findRepoRoot() string {
8081
type backendKind string
8182

8283
const (
83-
fileBackend backendKind = "file"
84-
gitBackend backendKind = "git"
84+
fileBackend backendKind = "file"
85+
gitBackend backendKind = "git"
86+
githubBackend backendKind = "github"
8587
)
8688

8789
// backends lists all backends that every test runs against.
88-
var backends = []backendKind{fileBackend, gitBackend}
90+
var backends = []backendKind{fileBackend, gitBackend, githubBackend}
8991

9092
// testEnv provides an isolated filesystem environment for each test.
9193
type testEnv struct {
@@ -201,7 +203,7 @@ func (e *testEnv) pushToUpstreamStore(t *testing.T, org, db, sql string) {
201203
func (e *testEnv) createStoreDir(t *testing.T, org, db string) string {
202204
t.Helper()
203205
switch e.Backend {
204-
case gitBackend:
206+
case gitBackend, githubBackend:
205207
gitDir := filepath.Join(e.RemoteBase, org, db+".git")
206208
if err := os.MkdirAll(gitDir, 0o755); err != nil {
207209
t.Fatalf("creating upstream git dir: %v", err)
@@ -231,6 +233,8 @@ func (e *testEnv) remoteArgs() []string {
231233
switch e.Backend {
232234
case gitBackend:
233235
return []string{"--git-remote", e.RemoteBase}
236+
case githubBackend:
237+
return []string{"--github-local", e.RemoteBase}
234238
default:
235239
return []string{"--remote-base", e.RemoteBase}
236240
}

test/integration/offline/pr_mode_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,98 @@ import (
1010
"testing"
1111
)
1212

13+
func TestConfigGetProviderType(t *testing.T) {
14+
for _, backend := range backends {
15+
t.Run(string(backend), func(t *testing.T) {
16+
env := joinedEnv(t, backend)
17+
18+
stdout, stderr, err := runWL(t, env, "config", "get", "provider-type")
19+
if err != nil {
20+
t.Fatalf("wl config get provider-type failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
21+
}
22+
23+
got := strings.TrimSpace(stdout)
24+
want := string(backend)
25+
if got != want {
26+
t.Errorf("provider-type = %q, want %q", got, want)
27+
}
28+
})
29+
}
30+
}
31+
32+
func TestReviewMarkdown(t *testing.T) {
33+
for _, backend := range backends {
34+
t.Run(string(backend), func(t *testing.T) {
35+
env := joinedEnv(t, backend)
36+
37+
// Switch to PR mode and post.
38+
setMode(t, env, upstream, "pr")
39+
40+
stdout, _, err := runWL(t, env, "post",
41+
"--title", "Markdown review test",
42+
"--type", "feature",
43+
"--no-push",
44+
)
45+
if err != nil {
46+
t.Fatalf("wl post failed: %v", err)
47+
}
48+
wantedID := extractWantedID(t, stdout)
49+
branch := "wl/" + forkOrg + "/" + wantedID
50+
51+
// Review with --md.
52+
stdout, stderr, err := runWL(t, env, "review", branch, "--md")
53+
if err != nil {
54+
t.Fatalf("wl review --md failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
55+
}
56+
57+
if !strings.Contains(stdout, "## wl review") {
58+
t.Errorf("expected '## wl review' header, got: %s", stdout)
59+
}
60+
if !strings.Contains(stdout, "### Summary") {
61+
t.Errorf("expected '### Summary' section, got: %s", stdout)
62+
}
63+
if !strings.Contains(stdout, "### Changes") {
64+
t.Errorf("expected '### Changes' section, got: %s", stdout)
65+
}
66+
})
67+
}
68+
}
69+
70+
func TestReviewJSON(t *testing.T) {
71+
for _, backend := range backends {
72+
t.Run(string(backend), func(t *testing.T) {
73+
env := joinedEnv(t, backend)
74+
75+
// Switch to PR mode and post.
76+
setMode(t, env, upstream, "pr")
77+
78+
stdout, _, err := runWL(t, env, "post",
79+
"--title", "JSON review test",
80+
"--type", "feature",
81+
"--no-push",
82+
)
83+
if err != nil {
84+
t.Fatalf("wl post failed: %v", err)
85+
}
86+
wantedID := extractWantedID(t, stdout)
87+
branch := "wl/" + forkOrg + "/" + wantedID
88+
89+
// Review with --json.
90+
stdout, stderr, err := runWL(t, env, "review", branch, "--json")
91+
if err != nil {
92+
t.Fatalf("wl review --json failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr)
93+
}
94+
95+
// If there's output, it should be valid JSON.
96+
// (dolt diff -r json may produce empty output on some versions.)
97+
trimmed := strings.TrimSpace(stdout)
98+
if trimmed != "" && !json.Valid([]byte(trimmed)) {
99+
t.Errorf("review --json output is not valid JSON: %s", trimmed)
100+
}
101+
})
102+
}
103+
}
104+
13105
// setMode updates the wasteland config to the given mode.
14106
func setMode(t *testing.T, env *testEnv, upstreamPath, mode string) {
15107
t.Helper()

0 commit comments

Comments
 (0)