Automate GitHub Workflows¶
GTB's pkg/vcs/github package wraps the GitHub API behind a testable interface. This guide covers the four most common automation patterns: creating pull requests, downloading release assets, reading file contents, and uploading SSH keys.
Prerequisites¶
Configuration¶
Add a github section to your embedded defaults (assets/config/defaults.yaml):
github:
url:
api: "" # empty = github.com; set for GitHub Enterprise
upload: ""
auth:
env: GITHUB_TOKEN
value: ""
Users set GITHUB_TOKEN in their environment:
Creating the Client¶
import "gitlab.com/phpboyscout/go-tool-base/pkg/vcs/github"
client, err := github.NewGitHubClient(props.Config.Sub("github"))
if err != nil {
return err
}
Pass the github config subtree โ NewGitHubClient reads url.api, url.upload, and resolves the token automatically.
Creating a Pull Request¶
import gogithub "github.com/google/go-github/v80/github"
pr, err := client.CreatePullRequest(ctx, "my-org", "my-repo", &gogithub.NewPullRequest{
Title: gogithub.Ptr("feat: add new command"),
Head: gogithub.Ptr("feature/new-command"), // source branch
Base: gogithub.Ptr("main"), // target branch
Body: gogithub.Ptr("Automated PR from mytool.\n\n## Changes\n- ..."),
Draft: gogithub.Ptr(false),
})
if err != nil {
return errorhandling.WrapWithHint(err,
"failed to create pull request",
"Check that the branch exists and the token has 'repo' scope.")
}
props.Logger.Info("Pull request created",
"number", pr.GetNumber(),
"url", pr.GetHTMLURL(),
)
Adding Labels to a Pull Request¶
labels := []string{"automated", "feat"}
if err := client.AddLabelsToPullRequest(ctx, "my-org", "my-repo", pr.GetNumber(), labels); err != nil {
// Non-fatal โ log and continue
props.Logger.Warn("Failed to add labels", "error", err)
}
Finding an Existing Pull Request¶
Before creating a PR, check if one already exists for the branch:
existing, err := client.GetPullRequestByBranch(ctx, "my-org", "my-repo", "feature/new-command", "open")
if err != nil {
if !errors.Is(err, github.ErrNoPullRequestFound) {
return err
}
// No existing PR โ create one
existing, err = client.CreatePullRequest(ctx, "my-org", "my-repo", newPR)
if err != nil {
return err
}
}
props.Logger.Info("Working with PR", "number", existing.GetNumber())
Updating an Existing Pull Request¶
updated, _, err := client.UpdatePullRequest(ctx, "my-org", "my-repo", pr.GetNumber(), &gogithub.PullRequest{
Title: gogithub.Ptr("feat: add new command (updated)"),
Body: gogithub.Ptr("Updated description."),
})
Downloading a Release Asset¶
The lower-level GHClient methods give you direct access to asset IDs. Use this pattern for downloading binaries, archives, or data files bundled with a release:
// Step 1: find the asset by name in a specific release tag
assetID, err := client.GetReleaseAssetID(ctx, "my-org", "my-repo", "v1.2.0", "mytool_linux_amd64.tar.gz")
if err != nil {
return err
}
// Step 2: download directly into the afero filesystem
destPath := "/tmp/mytool_linux_amd64.tar.gz"
if err := client.DownloadAssetTo(ctx, props.FS, "my-org", "my-repo", assetID, destPath); err != nil {
return err
}
props.Logger.Info("Asset downloaded", "path", destPath)
DownloadAssetTo writes into props.FS โ use afero.NewMemMapFs() in tests to avoid touching disk.
If you need to stream the asset (e.g. to extract it on the fly):
rc, err := client.DownloadAsset(ctx, "my-org", "my-repo", assetID)
if err != nil {
return err
}
defer rc.Close()
// Stream directly to stdout or into an archive reader
io.Copy(os.Stdout, rc)
Reading File Contents from a Repository¶
Retrieve a file at a specific branch or commit without cloning the repository:
content, err := client.GetFileContents(ctx, "my-org", "my-repo", "config/schema.json", "main")
if err != nil {
return err
}
// content is the decoded file content as a string
var schema map[string]any
json.Unmarshal([]byte(content), &schema)
Creating a Repository¶
repo, err := client.CreateRepo(ctx, "my-org", "new-repo-name")
if err != nil {
if errors.Is(err, github.ErrRepoExists) {
props.Logger.Info("Repository already exists", "repo", "new-repo-name")
} else {
return err
}
}
Uploading an SSH Key¶
Used by the GitHub initialiser to register a deploy key:
pubKeyBytes, err := os.ReadFile("~/.ssh/id_ed25519.pub")
if err != nil {
return err
}
if err := client.UploadKey(ctx, "mytool-deploy-key", pubKeyBytes); err != nil {
return err
}
Using the Release Provider Instead¶
For standard release-browsing workflows (auto-update, version checking), prefer github.NewReleaseProvider over the raw GHClient methods. It returns a backend-agnostic release.Provider that can be swapped for GitLab:
provider := github.NewReleaseProvider(client)
latest, err := provider.GetLatestRelease(ctx, "my-org", "my-repo")
fmt.Println("Latest:", latest.GetTagName())
See Release Provider for the full interface.
Testing¶
Use the generated mock to test commands that depend on GHClient:
import mock_github "gitlab.com/phpboyscout/go-tool-base/mocks/pkg/vcs/github"
mockClient := mock_github.NewMockGitHubClient(t)
mockClient.EXPECT().
GetPullRequestByBranch(mock.Anything, "my-org", "my-repo", "feature/x", "open").
Return(nil, github.ErrNoPullRequestFound)
mockClient.EXPECT().
CreatePullRequest(mock.Anything, "my-org", "my-repo", mock.Anything).
Return(&gogithub.PullRequest{Number: gogithub.Ptr(42)}, nil)
mockClient.EXPECT().
AddLabelsToPullRequest(mock.Anything, "my-org", "my-repo", 42, mock.Anything).
Return(nil)
For HTTP-level tests, stand up a net/http/httptest server and pass its URL as url.api in the test config. See pkg/vcs/github/client_coverage_test.go for the established pattern.
Related Documentation¶
- GitHub component โ full
GitHubClientinterface reference - Release Provider โ backend-agnostic release interface
- Configure Self-Updating โ wiring the release provider into the update command