Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,29 @@ resolver := githubforge.NewCommitResolver(os.Getenv("GITHUB_TOKEN"), nil)
sha, err := resolver.ResolveCommit(ctx, "actions", "checkout", "v4.2.1")
```

## Testing

```sh
go test ./...
```

Most backend tests run against `httptest` servers with hand-written responses. Tests named `TestReplay*` instead replay responses recorded from the real APIs (api.github.com, gitlab.com, codeberg.org and api.bitbucket.org), so they check the backends against what the forges actually send without needing network access. The recordings live in `testdata/replay/<test name>.json` next to each test.

Set `FORGE_RECORD=1` to send the requests to the real API and rewrite the recordings, for example after adding a test or when an API changes:

```sh
FORGE_RECORD=1 go test ./github -run TestReplayGetRepo
```

A recording is only written when the test passes. Only the method, URL and body of each request are stored, so a token sent in a request header never ends up in a fixture, and `Set-Cookie` response headers are dropped. Replay fails the test if the code makes a request that is not in the recording, or if a recorded request is never made.

New replay tests get their client from `internal/httpreplay`:

```go
f := github.New("", httpreplay.Client(t))
repo, err := f.Repos().Get(ctx, "octocat", "Hello-World")
```

## License

[MIT](LICENSE).
39 changes: 39 additions & 0 deletions bitbucket/replay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package bitbucket

import (
"context"
"errors"
"testing"

forge "github.com/git-pkgs/forge"
"github.com/git-pkgs/forge/internal/httpreplay"
)

// These tests replay responses recorded from api.bitbucket.org. Re-record
// them with FORGE_RECORD=1 go test ./bitbucket -run TestReplay.

func TestReplayGetRepo(t *testing.T) {
f := New("", httpreplay.Client(t))
repo, err := f.Repos().Get(context.Background(), "tutorials", "markdowndemo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertEqual(t, "FullName", "tutorials/markdowndemo", repo.FullName)
assertEqual(t, "Owner", "tutorials-team", repo.Owner)
assertEqual(t, "Name", "markdowndemo", repo.Name)
assertEqual(t, "HTMLURL", "https://bitbucket.org/tutorials/markdowndemo", repo.HTMLURL)
assertEqual(t, "DefaultBranch", "master", repo.DefaultBranch)
assertEqualBool(t, "Private", false, repo.Private)
if repo.CloneURL == "" {
t.Error("CloneURL is empty")
}
}

func TestReplayGetRepoNotFound(t *testing.T) {
f := New("", httpreplay.Client(t))
_, err := f.Repos().Get(context.Background(), "tutorials", "this-repository-does-not-exist")
if !errors.Is(err, forge.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
129 changes: 129 additions & 0 deletions bitbucket/testdata/replay/TestReplayGetRepo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
[
{
"request": {
"method": "GET",
"url": "https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo"
},
"response": {
"status_code": 200,
"header": {
"Age": [
"0"
],
"Atl-Request-Id": [
"fa76de95-4504-44c5-bc60-efb41265d706"
],
"Atl-Traceid": [
"fa76de95450444c5bc60efb41265d706"
],
"Cache-Control": [
"max-age=900"
],
"Content-Type": [
"application/json; charset=utf-8"
],
"Date": [
"Fri, 13 Nov 2026 04:13:10 GMT"
],
"Etag": [
"W/\"542ef878500116b3fdf8ec9da10487a7\""
],
"Nel": [
"{\"failure_fraction\": 0.01, \"include_subdomains\": true, \"max_age\": 600, \"report_to\": \"endpoint-1\"}"
],
"Report-To": [
"{\"endpoints\": [{\"url\": \"https://dz8aopenkvv6s.cloudfront.net\"}], \"group\": \"endpoint-1\", \"include_subdomains\": true, \"max_age\": 600}"
],
"Server": [
"AtlassianEdge"
],
"Server-Timing": [
"cdn-upstream-layer;desc=\"EDGE\",cdn-upstream-dns;dur=0,cdn-upstream-connect;dur=0,cdn-upstream-fbl;dur=334,atl-edge;dur=130,atl-edge-internal;dur=9,atl-edge-upstream;dur=121,atl-edge-pop;desc=\"aws-us-east-1\",cdn-cache-miss,cdn-pop;desc=\"HYD57-P6\",cdn-rid;desc=\"zpUcAbr-tPY3-6k8cpBL8z16RjSfvxISe08eudpKqITVmPP_K6VJYw==\",cdn-downstream-fbl;dur=338"
],
"Strict-Transport-Security": [
"max-age=63072000; includeSubDomains; preload"
],
"Vary": [
"Authorization, origin",
"Accept-Encoding"
],
"Via": [
"1.1 b9bc310de68fa79af0899ef6365bb4bc.cloudfront.net (CloudFront)"
],
"X-Accepted-Oauth-Scopes": [
"repository"
],
"X-Amz-Cf-Id": [
"zpUcAbr-tPY3-6k8cpBL8z16RjSfvxISe08eudpKqITVmPP_K6VJYw=="
],
"X-Amz-Cf-Pop": [
"HYD57-P6"
],
"X-Asap-Succeeded": [
"True"
],
"X-B3-Spanid": [
"10b4a9ae58b2bfd6"
],
"X-B3-Traceid": [
"fa76de95450444c5bc60efb41265d706"
],
"X-Cache": [
"Miss from cloudfront"
],
"X-Content-Type-Options": [
"nosniff"
],
"X-Credential-Type": [
"unauthenticated_identity"
],
"X-Dc-Location": [
"Micros-3"
],
"X-Envoy-Attempt-Count": [
"1"
],
"X-Frame-Options": [
"SAMEORIGIN"
],
"X-Render-Time": [
"0.10052609443664551"
],
"X-Request-Count": [
"562"
],
"X-Served-By": [
"9002dd80eba1"
],
"X-Static-Version": [
"650ce3fb4191"
],
"X-Usage-Input-Ops": [
"0"
],
"X-Usage-Output-Ops": [
"0"
],
"X-Usage-System-Time": [
"0.003965"
],
"X-Usage-User-Time": [
"0.059263"
],
"X-Used-Mesh": [
"False"
],
"X-Version": [
"650ce3fb4191"
],
"X-View-Name": [
"bitbucket.apps.repo2.api.v20.repo.RepositoryHandler"
],
"X-Xss-Protection": [
"1; mode=block"
]
},
"body": "{\"type\": \"repository\", \"full_name\": \"tutorials/markdowndemo\", \"links\": {\"self\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo\"}, \"html\": {\"href\": \"https://bitbucket.org/tutorials/markdowndemo\"}, \"avatar\": {\"href\": \"https://bytebucket.org/ravatar/%7B97a9a6a1-2b16-445f-bda9-5781d0153d70%7D?ts=default\"}, \"pullrequests\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/pullrequests\"}, \"commits\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/commits\"}, \"forks\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/forks\"}, \"watchers\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/watchers\"}, \"branches\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/refs/branches\"}, \"tags\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/refs/tags\"}, \"downloads\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/downloads\"}, \"source\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/src\"}, \"clone\": [{\"name\": \"https\", \"href\": \"https://bitbucket.org/tutorials/markdowndemo.git\"}, {\"name\": \"ssh\", \"href\": \"git@ssh.bitbucket.org:tutorials/markdowndemo.git\"}], \"issues\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/issues\"}, \"hooks\": {\"href\": \"https://api.bitbucket.org/2.0/repositories/tutorials/markdowndemo/hooks\"}}, \"name\": \"MarkdownDemo\", \"slug\": \"markdowndemo\", \"description\": \"\", \"scm\": \"git\", \"website\": \"\", \"owner\": {\"display_name\": \"tutorials account\", \"links\": {\"self\": {\"href\": \"https://api.bitbucket.org/2.0/workspaces/%7Bc788b2da-b7a2-404c-9e26-d3f077557007%7D\"}, \"avatar\": {\"href\": \"https://bitbucket.org/account/tutorials-team/avatar/\"}, \"html\": {\"href\": \"https://bitbucket.org/%7B7995b79e-9d2c-4c79-8303-635527d06dbb%7D/\"}}, \"type\": \"team\", \"uuid\": \"{7995b79e-9d2c-4c79-8303-635527d06dbb}\", \"username\": \"tutorials-team\"}, \"workspace\": {\"type\": \"workspace\", \"uuid\": \"{c788b2da-b7a2-404c-9e26-d3f077557007}\", \"name\": \"tutorials account\", \"slug\": \"tutorials\", \"links\": {\"avatar\": {\"href\": \"https://bitbucket.org/workspaces/tutorials/avatar/?ts=1543463025\"}, \"html\": {\"href\": \"https://bitbucket.org/tutorials/\"}, \"self\": {\"href\": \"https://api.bitbucket.org/2.0/workspaces/tutorials\"}}}, \"is_private\": false, \"project\": {\"type\": \"project\", \"key\": \"PROJ\", \"uuid\": \"{142f0b6a-6cdf-4f19-b99f-dcc5e58a8f03}\", \"name\": \"Untitled project\", \"links\": {\"self\": {\"href\": \"https://api.bitbucket.org/2.0/workspaces/tutorials/projects/PROJ\"}, \"html\": {\"href\": \"https://bitbucket.org/tutorials/workspace/projects/PROJ\"}, \"avatar\": {\"href\": \"https://bitbucket.org/tutorials/workspace/projects/PROJ/avatar/32?ts=1543463025\"}}}, \"fork_policy\": \"allow_forks\", \"created_on\": \"2013-08-26T16:37:02.542269+00:00\", \"updated_on\": \"2026-10-08T08:08:35.973576+00:00\", \"size\": 1554546, \"language\": \"\", \"uuid\": \"{97a9a6a1-2b16-445f-bda9-5781d0153d70}\", \"mainbranch\": {\"name\": \"master\", \"type\": \"branch\"}, \"override_settings\": {\"default_merge_strategy\": false, \"branching_model\": false}, \"parent\": null, \"enforced_signed_commits\": null, \"has_issues\": true, \"has_wiki\": true}"
}
}
]
128 changes: 128 additions & 0 deletions bitbucket/testdata/replay/TestReplayGetRepoNotFound.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
[
{
"request": {
"method": "GET",
"url": "https://api.bitbucket.org/2.0/repositories/tutorials/this-repository-does-not-exist"
},
"response": {
"status_code": 404,
"header": {
"Age": [
"0"
],
"Atl-Request-Id": [
"92cd09f7-7628-4b0f-bd9f-7b84d2bc5d25"
],
"Atl-Traceid": [
"92cd09f776284b0fbd9f7b84d2bc5d25"
],
"Cache-Control": [
"max-age=900"
],
"Content-Type": [
"application/json; charset=utf-8"
],
"Date": [
"Fri, 13 Nov 2026 04:13:10 GMT"
],
"Etag": [
"\"540e4ce8c3885eb9205fe234e50848c2\""
],
"Nel": [
"{\"failure_fraction\": 0.01, \"include_subdomains\": true, \"max_age\": 600, \"report_to\": \"endpoint-1\"}"
],
"Report-To": [
"{\"endpoints\": [{\"url\": \"https://dz8aopenkvv6s.cloudfront.net\"}], \"group\": \"endpoint-1\", \"include_subdomains\": true, \"max_age\": 600}"
],
"Server": [
"AtlassianEdge"
],
"Server-Timing": [
"atl-edge;dur=74,atl-edge-internal;dur=7,atl-edge-upstream;dur=67,atl-edge-pop;desc=\"aws-us-east-1\""
],
"Strict-Transport-Security": [
"max-age=63072000; includeSubDomains; preload"
],
"Vary": [
"authorization, cookie, user-context, origin"
],
"Via": [
"1.1 b9bc310de68fa79af0899ef6365bb4bc.cloudfront.net (CloudFront)"
],
"X-Accepted-Oauth-Scopes": [
"repository"
],
"X-Amz-Cf-Id": [
"YqT7rO9d3Hyw3oEJIWwN71cmOLXgdtAVUuGg6Jr_DjVgf0ghUzN-8Q=="
],
"X-Amz-Cf-Pop": [
"HYD57-P6"
],
"X-Asap-Succeeded": [
"True"
],
"X-B3-Spanid": [
"938d3fc44740ee12"
],
"X-B3-Traceid": [
"92cd09f776284b0fbd9f7b84d2bc5d25"
],
"X-Cache": [
"Error from cloudfront"
],
"X-Content-Type-Options": [
"nosniff"
],
"X-Credential-Type": [
"unauthenticated_identity"
],
"X-Dc-Location": [
"Micros-3"
],
"X-Envoy-Attempt-Count": [
"1"
],
"X-Frame-Options": [
"SAMEORIGIN"
],
"X-Render-Time": [
"0.03886270523071289"
],
"X-Request-Count": [
"611"
],
"X-Served-By": [
"a73bb510fedf"
],
"X-Static-Version": [
"650ce3fb4191"
],
"X-Usage-Input-Ops": [
"0"
],
"X-Usage-Output-Ops": [
"0"
],
"X-Usage-System-Time": [
"0.000876"
],
"X-Usage-User-Time": [
"0.032838"
],
"X-Used-Mesh": [
"False"
],
"X-Version": [
"650ce3fb4191"
],
"X-View-Name": [
"bitbucket.apps.repo2.api.v20.repo.RepositoryHandler"
],
"X-Xss-Protection": [
"1; mode=block"
]
},
"body": "{\"type\": \"error\", \"error\": {\"message\": \"You may not have access to this repository or it no longer exists in this workspace. If you think this repository exists and you have access, make sure you are authenticated.\"}}"
}
}
]
68 changes: 68 additions & 0 deletions gitea/replay_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package gitea

import (
"context"
"errors"
"strings"
"testing"

forge "github.com/git-pkgs/forge"
"github.com/git-pkgs/forge/internal/httpreplay"
)

// These tests replay responses recorded from codeberg.org, which runs
// Forgejo. Re-record them with FORGE_RECORD=1 go test ./gitea -run TestReplay.

func TestReplayGetRepo(t *testing.T) {
f := New("https://codeberg.org", "", httpreplay.Client(t))
repo, err := f.Repos().Get(context.Background(), "Codeberg", "Documentation")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertEqual(t, "FullName", "Codeberg/Documentation", repo.FullName)
assertEqual(t, "Owner", "Codeberg", repo.Owner)
assertEqual(t, "Name", "Documentation", repo.Name)
assertEqual(t, "HTMLURL", "https://codeberg.org/Codeberg/Documentation", repo.HTMLURL)
assertEqual(t, "CloneURL", "https://codeberg.org/Codeberg/Documentation.git", repo.CloneURL)
assertEqual(t, "DefaultBranch", "main", repo.DefaultBranch)
assertEqualBool(t, "Private", false, repo.Private)
if repo.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
}

func TestReplayGetRepoNotFound(t *testing.T) {
f := New("https://codeberg.org", "", httpreplay.Client(t))
_, err := f.Repos().Get(context.Background(), "Codeberg", "this-repository-does-not-exist")
if !errors.Is(err, forge.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}

func TestReplayListBranches(t *testing.T) {
f := New("https://codeberg.org", "", httpreplay.Client(t))
branches, err := f.Branches().List(context.Background(), "Codeberg", "Documentation", forge.ListBranchOpts{Limit: 1})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertEqualInt(t, "len(branches)", 1, len(branches))
if branches[0].Name == "" || len(branches[0].SHA) != 40 {
t.Errorf("branch missing name or SHA: %+v", branches[0])
}
}

func TestReplayGetFile(t *testing.T) {
f := New("https://codeberg.org", "", httpreplay.Client(t))
file, err := f.Files().Get(context.Background(), "Codeberg", "Documentation", "LICENSE.md", "main")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

assertEqual(t, "Name", "LICENSE.md", file.Name)
assertEqual(t, "Path", "LICENSE.md", file.Path)
if !strings.Contains(string(file.Content), "licensed under [CC-BY-SA 4.0]") {
t.Errorf("Content does not look like the decoded license: %q", file.Content)
}
}
Loading
Loading