Release Notes v0.18.0¶
Release Date: 2026-10-03
This is a non-breaking, additive release. It adds repository health collection for dashboards — open issues, open pull requests, and the status of every GitHub Actions workflow across a set of repositories — and answers a repository access question — which repositories have I been granted access to in organizations I don't belong to? — along with the authentication path needed to ask it of organizations that forbid personal access tokens.
New Features¶
Repository health¶
import "github.com/grokify/gogithub/health"
results, err := health.CollectAll(ctx, client, []string{"grokify/gogithub", "grokify/mogo"}, nil)
for _, r := range results {
if r.Err != nil {
continue // partial results: the other repositories are still collected
}
fmt.Printf("%s: %s, %d issues, %d PRs\n", r.FullName, r.Health.State, r.Health.OpenIssues, r.Health.OpenPullRequests)
for _, wf := range r.Health.Workflows {
fmt.Printf(" %s: %s\n", wf.Workflow.Name, wf.State) // passing, failing, running, inconclusive, none
}
}
The new health package collects, per repository, the open issue count (excluding pull requests, which GitHub's open_issues_count includes), the open pull request count, and every workflow with its latest run on the default branch. The repository State is the most severe state among active workflows; disabled workflows are listed but do not count.
Collection costs four API requests per repository regardless of how many pull requests or workflows it has, so a dashboard over a hundred repositories can refresh every few minutes with one token. CollectAll runs repositories concurrently and returns partial results with per-repository errors. Options select another branch, any branch (for tag-triggered workflows), the concurrency, and the page size. See the health guide.
All four requests return ETags, so with the conditional-request transport below a refresh in which nothing changed costs no rate limit.
Two clientv1 methods make the request count possible:
CountPullRequests(ctx, owner, repo, state)counts pull requests in one request by reading the pagination header, instead of listing them all.ListRepositoryWorkflowRuns(ctx, owner, repo, opts)returns one page of recent runs across all workflows, so each workflow's latest run comes from one request instead of one per workflow.
Conditional requests¶
import "github.com/grokify/gogithub/etagcache"
cache := etagcache.NewTransport(nil)
client, err := clientv1.NewClientWithOptions(ctx, clientv1.ClientOptions{Token: token, Transport: cache})
// ...
fmt.Println(cache.Stats()) // "412 requests, 397 served from cache, 15 fetched, 0 uncacheable"
The new etagcache package is an HTTP transport that remembers each GET response's ETag and body, sends If-None-Match on the next request for the same URL, and turns GitHub's 304 Not Modified back into the cached 200 so go-github and every gogithub package see an ordinary success. GitHub does not count 304s against the rate limit, which makes polling — a dashboard, a scheduled job — nearly free once warmed up. MemoryStore (LRU) suits long-running processes; FileStore persists entries between runs of a command-line tool with owner-only permissions. Entries are keyed by a hash of the credential as well as the URL, so a shared store never crosses tokens. clientv1.ClientOptions.Transport is the new hook that accepts it. See the conditional requests guide.
health CLI command¶
gogithub health --repo grokify/gogithub --repo grokify/mogo
gogithub health --repos-file repos.txt -f json # for dashboards
gogithub health --repo owner/name --any-branch # include tag-triggered workflows
gogithub health --repos-file repos.txt --cache-dir ~/.cache/gogithub # conditional requests across runs
Text output lists each repository's state and counts, then each workflow's latest run with its URL. JSON output includes each workflow's name, path, and run details with links to the workflow file, all of its runs (health.RunsURL, derived since the API does not return it), the latest run, and the badge image. --cache-dir persists an ETag cache between runs and prints the hit statistics to stderr. The command exits non-zero if any repository could not be collected, after printing the rest.
Repository access listing¶
import "github.com/grokify/gogithub/repo"
repos, err := repo.ListNonMemberOrgRepos(ctx, client)
for _, r := range repos {
fmt.Printf("%s: %s\n", r.FullName, r.Permissions.Highest())
}
ListNonMemberOrgRepos lists the authenticated user's repositories across all affiliations, then keeps the organization-owned ones whose owner is not an active membership. Filtering by owner matters: a plain affiliation=collaborator query also returns direct grants inside organizations the user already belongs to.
The two clientv1 methods behind it are available directly:
repos, err := client.ListAuthenticatedUserRepos(ctx, &clientv1.ListAuthenticatedUserReposOptions{
Visibility: "private",
Affiliations: []string{clientv1.AffiliationCollaborator},
})
memberships, err := client.ListOrgMemberships(ctx, &clientv1.ListOrgMembershipsOptions{
State: gogithub.MembershipStateActive,
})
Unlike ListUserRepos, which wraps the public /users/{user}/repos endpoint, ListAuthenticatedUserRepos includes private repositories and populates Repository.Permissions.
Checking access to one repository¶
result, err := repo.CheckAccess(ctx, client, "owner", "name")
switch result.Status {
case repo.AccessGranted: // result.Permission is "admin", "push", etc.
case repo.AccessNotVisible: // does not exist, or not accessible
case repo.AccessBlockedByTokenPolicy: // the owner rejects this kind of token
}
An organization can forbid classic personal access tokens. Its private repositories then return a plain 404 to such a token — indistinguishable from a repository the user cannot access. CheckAccess probes the owner on a 404 and reports the policy block separately, with GitHub's message in result.Detail.
Token policy errors¶
import ghErrors "github.com/grokify/gogithub/errors"
if ghErrors.IsTokenPolicyError(err) {
fmt.Println(ghErrors.Message(err)) // "`org` forbids access via a personal access token (classic). ..."
}
IsTokenPolicyError works on raw errors from any clientv1 call, like IsRateLimitError. Message extracts the text GitHub returned with any error.
OAuth app authentication¶
A token issued to an OAuth app is accepted where personal access tokens are forbidden, provided the organization allows the app. The new auth/credentialsset package obtains one from a goauth credentials set file:
import "github.com/grokify/gogithub/auth/credentialsset"
client, err := credentialsset.NewClient(ctx, "credentials.json", "github",
credentialsset.PromptReadWriter(os.Stderr, os.Stdin))
Without a stored token, the authorization code grant runs: the prompt prints the authorization URL, the user authorizes in a browser, and enters the code shown on the redirect page. Register credentialsset.RedirectURL (https://grokify.github.io/goauth/oauth2callback/) as the OAuth app's callback URL to use the hosted redirect page. A token stored with the account skips the prompt.
The package is separate from auth so that importing auth does not pull in goauth. See the auth guide for the credentials file format.
repo-access CLI command¶
gogithub repo-access # every accessible repository, with permission
gogithub repo-access --non-member-orgs # grants in organizations you don't belong to
gogithub repo-access --repo owner/name # granted / not_visible / blocked_by_token_policy
gogithub repo-access --creds credentials.json --account github --non-member-orgs
-f json emits JSON. Without --creds, the command uses GITHUB_TOKEN.
New Stable Types¶
RepositoryPermissionswithHighest(), andRepository.Permissions(new field on the existing type)OrgMembership- Constants:
OwnerTypeUser/OwnerTypeOrganization,Permission*,MembershipState*, andclientv1.Affiliation* health.RepoHealth,health.WorkflowHealth,health.Result,health.Stateand thehealth.Conclusion*run conclusion constantsetagcache.Transport,etagcache.Store,etagcache.Entry,etagcache.Stats,etagcache.MemoryStore,etagcache.FileStoreclientv1.ClientOptions.Transport(new field on the existing type)
Fixes and Changes¶
bulk_git_rmtakes the repository directory as--dir(default: current directory) instead of a hardcoded local path, and printsgit rmcommands to stdout or to--out.cliutilruns git in the target directory with-Cinstead of changing the process working directory, uses the stable porcelain status format, omits empty status lines, returnsgit rmcommands without trailing newlines, and replaces the output file inGitRmDeletedFile.
Token Requirements¶
- Health collection needs
repoon a classic token for private repositories and Actions data, or Metadata, Pull requests, and Actions read access on a fine-grained token. - A classic personal access token or OAuth token needs
repoto include private repositories andread:orgto list memberships. Withread:orgalone, only public repositories are listed. - A fine-grained personal access token is bound to a single resource owner and cannot be used for outside-collaborator access, so it cannot enumerate access across organizations.
- Organizations that forbid classic personal access tokens are omitted from listings entirely; use an OAuth app token (above) or
CheckAccessto detect the block for a known repository.
Dependencies¶
- Added
github.com/grokify/goauthv0.25.0. It brings Google API, gRPC, and OpenTelemetry modules intogo.modas indirect dependencies; onlyauth/credentialssetimports it. - Bumped
mogo,gocharts, andgolang.org/xmodules.
Installation¶
See CHANGELOG.md for the categorized commit list.