gh

package
v0.0.0-...-c4e3c7c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package gh is ghab's data layer: a Client wrapping go-gh's REST client with an in-memory TTL cache. Typed fetchers expose only the fields the UI shows.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssetDestPath

func AssetDestPath(cloneDir, assetName string) string

AssetDestPath resolves the local path a downloaded release asset writes to: cloneDir (expanded) + "/downloads/" + assetName, per BUILD.md's M3 spec. The asset name is base-named so a hostile name with path separators can't write outside the downloads dir.

func ExpandPath

func ExpandPath(path string) string

ExpandPath expands a leading "~" (or "~/...") to the user's home directory, e.g. for [behavior].clone_dir ("~/Developer", "~/src"). Paths without a leading "~" pass through unchanged.

func HumanBytes

func HumanBytes(n int64) string

HumanBytes formats a byte count the way ghab's UI shows it in placeholder lines ("binary file (243 KB)", "file too large (2.1 MB)"): whole KB below 1 MB, one decimal MB at or above it.

Types

type Asset

type Asset struct {
	Name               string `json:"name"`
	Size               int64  `json:"size"`
	DownloadCount      int    `json:"download_count"`
	BrowserDownloadURL string `json:"browser_download_url"`
	URL                string `json:"url"`
}

Asset is one release asset from repos/{o}/{r}/releases: name, size, and both download URLs -- BrowserDownloadURL for a human link, URL (the API endpoint) for DownloadAsset, which needs the API URL to stream through auth via the Accept: application/octet-stream trick.

type BinaryError

type BinaryError struct {
	Path string
	Size int64
}

BinaryError marks a file whose content sniffs as binary (a null byte in the first sniffWindow bytes).

func (*BinaryError) Error

func (e *BinaryError) Error() string

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client fetches GitHub data through go-gh's REST client (auth resolved free: GH_TOKEN env, then gh's stored OAuth token) with a TTL cache in front of it. A second client (raw) is identical except it defaults to the "application/vnd.github.raw+json" Accept header, for the file/readme fetchers that want file bytes back instead of a JSON envelope. A third (assets) defaults to "application/octet-stream", for DownloadAsset.

func NewClient

func NewClient(ttl time.Duration) (*Client, error)

NewClient builds a Client. ttl is the cache lifetime for every endpoint, from [behavior].cache_ttl.

func (*Client) Dir

func (c *Client) Dir(owner, repo, path string) ([]DirEntry, error)

Dir fetches repos/{owner}/{repo}/contents/{path}: one directory's listing. Used for lazy expansion when Tree's recursive fetch was truncated and the initial payload didn't include this directory's children.

func (*Client) DownloadAsset

func (c *Client) DownloadAsset(url, destPath string, progress func(done, total int64)) error

DownloadAsset streams a release asset from url (an Asset's URL field -- the API endpoint, not BrowserDownloadURL, so this works against private repos too) to destPath, creating destPath's parent directories as needed. progress is called after every chunk read with the bytes copied so far and the total reported via resp.ContentLength; total is 0 when the server doesn't report a length, in which case callers should fall back to the Asset.Size they already hold (from the Releases fetch) for display.

func (*Client) FileRaw

func (c *Client) FileRaw(owner, repo, path, ref string, size int64) ([]byte, error)

FileRaw fetches a file's raw content from repos/{owner}/{repo}/contents/{path}?ref={ref}. size is the tree entry's reported size, checked BEFORE any request is made -- a file over 1 MB never touches the network. The response is also sniffed for binary content (a null byte in the first 8 KB); a binary hit returns a BinaryError carrying the fetched byte count instead of the bytes.

func (*Client) IssueDetail

func (c *Client) IssueDetail(owner, repo string, number int) (IssueDetail, error)

IssueDetail fetches a single issue/PR (repos/{o}/{r}/issues/{n}) plus its comments (repos/{o}/{r}/issues/{n}/comments) -- two GETs combined into one typed result.

func (*Client) Issues

func (c *Client) Issues(owner, repo string, perPage int) (issues []Issue, prs []Issue, err error)

Issues fetches repos/{owner}/{repo}/issues?state=all&per_page={n} once and splits the result client-side into issues (no pull_request key) and prs (has it). One network round trip feeds both the issues and PRs tabs -- the second caller (whichever tab asks second) is served from cache, since both request the identical URL. A 404 (or empty body) yields two empty slices rather than an error, same rationale as Releases.

func (*Client) MyPRs

func (c *Client) MyPRs(queries []string, perPage int) (total int, items []SearchIssue, err error)

MyPRs runs GitHub's search/issues endpoint once per configured query and merges the results -- config's [behavior].my_prs_query, default the authored-or-review-requested pair "is:pr is:open author:@me" + "is:pr is:open review-requested:@me" (issue #11: browsing PRs across every repo without leaving the terminal).

It is two calls rather than one because GitHub's search syntax has no OR between qualifiers -- `author:@me review-requested:@me` in one query means BOTH, which matches nothing. The wider `involves:@me` does fit in one query but is a different scope: it also pulls in PRs you were merely mentioned on or commented on.

total is the count of distinct PRs returned, not the sum of GitHub's per-query total_count (which would double-count any overlap).

Same tight search rate bucket as SearchRepos (30 req/min) -- fired once per screen push, never per keystroke; the per-query cache entries mean a repeat push costs nothing.

func (*Client) PullFiles

func (c *Client) PullFiles(owner, repo string, number, perPage int) ([]PullFile, error)

PullFiles fetches a PR's changed-files list, each with a per-file unified diff when GitHub provides one. A 404 (or empty body) yields an empty slice rather than an error, same rationale as Releases/Issues.

func (*Client) PullReviewComments

func (c *Client) PullReviewComments(owner, repo string, number int) ([]ReviewComment, error)

PullReviewComments fetches a PR's line-anchored review comments. A 404 (or empty body) yields an empty slice rather than an error.

func (*Client) Readme

func (c *Client) Readme(owner, repo string) (string, error)

Readme fetches repos/{owner}/{repo}/readme raw (glamour renders it client-side). A 404 becomes a typed NoReadmeError rather than the generic NotFoundError, since "no readme" is expected for plenty of repos.

func (*Client) Refresh

func (c *Client) Refresh(path string)

Refresh busts the cache entry for path, forcing the next fetch to hit the network. Used by the "r" refresh binding.

func (*Client) RefreshIssueDetail

func (c *Client) RefreshIssueDetail(owner, repo string, number int)

RefreshIssueDetail busts the cache entries for one issue/PR's detail (the issue GET plus its comments list), forcing the next IssueDetail call to hit the network. Distinct from RefreshIssues, which busts the list fetch feeding the issues/prs tabs -- used by the "r" refresh binding when a PR's own detail/conversation view is open.

func (*Client) RefreshIssues

func (c *Client) RefreshIssues(owner, repo string, perPage int)

RefreshIssues busts the issues cache entry, forcing the next Issues call to hit the network. Used by the "r" refresh binding when the issues or prs tab is active -- one bust covers both, since they share the one underlying fetch.

func (*Client) RefreshMyPRs

func (c *Client) RefreshMyPRs(queries []string, perPage int)

RefreshMyPRs busts every my-PRs search cache entry for queries/perPage. Used by the "r" refresh binding on the my-PRs screen.

func (*Client) RefreshPullFiles

func (c *Client) RefreshPullFiles(owner, repo string, number, perPage int)

RefreshPullFiles busts the cache entry for one PR's files-changed list. Used by the "r" refresh binding when the PR detail view is open.

func (*Client) RefreshPullReviewComments

func (c *Client) RefreshPullReviewComments(owner, repo string, number int)

RefreshPullReviewComments busts the cache entry for one PR's review comments. Used by the "r" refresh binding when the PR detail view is open.

func (*Client) RefreshReadme

func (c *Client) RefreshReadme(owner, repo string)

RefreshReadme busts the readme cache entry, forcing the next Readme call to hit the network. Used by the "r" refresh binding when the readme tab is active.

func (*Client) RefreshReleases

func (c *Client) RefreshReleases(owner, repo string, perPage int)

RefreshReleases busts the releases cache entry, forcing the next Releases call to hit the network. Used by the "r" refresh binding when the releases tab is active.

func (*Client) RefreshTree

func (c *Client) RefreshTree(owner, repo, branch string)

RefreshTree busts the tree cache entry, forcing the next Tree call to hit the network. Used by the "r" refresh binding when the code tab is active.

func (*Client) RefreshUser

func (c *Client) RefreshUser(login string)

RefreshUser busts the profile screen's cache entries (user card + repo list) so its "r" binding refetches both.

func (*Client) Releases

func (c *Client) Releases(owner, repo string, perPage int) ([]Release, error)

Releases fetches repos/{owner}/{repo}/releases?per_page={n}, serving from cache when fresh. A 404 (or an empty body) becomes an empty slice rather than an error -- a repo with no releases is a normal, expected state (same rationale as NoReadmeError, but releases don't need a typed error since "no releases" isn't worth a distinct render branch beyond an empty list).

func (*Client) RepoMeta

func (c *Client) RepoMeta(owner, repo string) (RepoMeta, error)

RepoMeta fetches repos/{owner}/{repo}, serving from cache when fresh.

func (*Client) SearchRepos

func (c *Client) SearchRepos(query string, perPage int) (total int, items []UserRepo, err error)

SearchRepos queries search/repositories. The search rate bucket is tight (30 req/min) -- callers fire this only on an explicit enter, never per keystroke (BUILD.md's "HARD debounce").

func (*Client) Tree

func (c *Client) Tree(owner, repo, branch string) (Tree, error)

Tree fetches repos/{owner}/{repo}/git/trees/{branch}?recursive=1, serving from cache when fresh. GitHub caps recursive listings; if Truncated is true the response is incomplete and callers should fall back to Dir for lazy per-directory listing of whichever parts of the tree matter.

func (*Client) User

func (c *Client) User(login string) (User, error)

User fetches users/{login}, serving from cache when fresh.

func (*Client) UserRepos

func (c *Client) UserRepos(login string) ([]UserRepo, error)

UserRepos fetches users/{login}/repos sorted by most recently updated (first page of 100 -- the profile hop is a browse, not an archive dump).

type Comment

type Comment struct {
	User      issueUser `json:"user"`
	CreatedAt time.Time `json:"created_at"`
	Body      string    `json:"body"`
}

Comment is one entry from repos/{o}/{r}/issues/{n}/comments.

func (Comment) Author

func (c Comment) Author() string

Author returns the comment author's login, or "" if the API omitted the user object.

type DiffLine

type DiffLine struct {
	Kind    DiffLineKind
	Text    string
	OldLine int
	NewLine int
}

DiffLine is one rendered line of a parsed patch. OldLine/NewLine are the 1-based line numbers on the removed/added side of the diff -- 0 when not applicable to this line's kind (an add line has no OldLine, a remove line has no NewLine, a hunk header has neither). They exist so the diff view can place review threads (anchored to a specific old/new-side line) right after the diff line they belong to.

func ParsePatch

func ParsePatch(patch string) []DiffLine

ParsePatch splits a GitHub "patch" string (a per-file unified diff body -- GitHub's /pulls/{n}/files response omits the "--- a/f" / "+++ b/f" file header lines other diff tools include, starting straight at the first "@@" hunk header) into typed, line-numbered lines. Empty or whitespace-only input (GitHub omits Patch for binary files, pure renames, and oversized text diffs -- see PullFile.Omission) yields nil.

func TrimContext

func TrimContext(lines []DiffLine, contextLines int) []DiffLine

TrimContext caps each run of consecutive DiffContext lines at contextLines per side, collapsing any excess into a single placeholder line -- the [behavior].diff_context_lines knob. GitHub's own patch already caps context at ~3 lines per hunk, so this only visibly bites when contextLines is set below GitHub's default (a config-first knob for narrower terminals / less scrolling); contextLines <= 0 disables trimming entirely (fail-soft: a bad config value floors at the compiled default in config.go, but this guard also protects a deliberate 0).

type DiffLineKind

type DiffLineKind int

DiffLineKind classifies one parsed line of a unified diff patch, for the PR detail view's per-file diff coloring (BUILD.md extension: "add/remove line coloring").

const (
	DiffContext DiffLineKind = iota
	DiffAdd
	DiffRemove
	DiffHunkHeader
)

type DirEntry

type DirEntry struct {
	Name string `json:"name"`
	Path string `json:"path"`
	Type string `json:"type"` // "file" or "dir"
	Size int64  `json:"size"`
	SHA  string `json:"sha"`
}

DirEntry is one entry from repos/{o}/{r}/contents/{path}: a single directory's non-recursive listing, used only when Tree reports Truncated.

func (DirEntry) IsDir

func (e DirEntry) IsDir() bool

IsDir reports whether the entry is a directory.

type FileThread

type FileThread struct {
	Path  string
	Lines []LineThread
}

FileThread groups one file's review comments by anchor line, lines in ascending order (BUILD.md extension: "review threads grouped by file:line").

func GroupReviewThreads

func GroupReviewThreads(comments []ReviewComment) []FileThread

GroupReviewThreads buckets comments by path, then by (side, line) within each path -- files sorted alphabetically, lines ascending within each file, comments oldest-first within each line. Pure and unit-testable without a network round trip (see pulls_test.go).

type Issue

type Issue struct {
	Number      int             `json:"number"`
	Title       string          `json:"title"`
	State       string          `json:"state"`
	User        issueUser       `json:"user"`
	CreatedAt   time.Time       `json:"created_at"`
	UpdatedAt   time.Time       `json:"updated_at"`
	Comments    int             `json:"comments"`
	Body        string          `json:"body"`
	PullRequest json.RawMessage `json:"pull_request,omitempty"`
}

Issue is one entry from repos/{o}/{r}/issues?state=all -- GitHub's issues endpoint returns pull requests too, distinguished only by the presence of a "pull_request" key (real issues never carry that key at all, not even as null). PullRequest is a json.RawMessage purely as a presence marker; its contents are never decoded further.

func (Issue) Author

func (i Issue) Author() string

Author returns the issue/PR author's login, or "" if the API omitted the user object.

func (Issue) IsPullRequest

func (i Issue) IsPullRequest() bool

IsPullRequest reports whether this issues-endpoint entry is actually a pull request.

type IssueDetail

type IssueDetail struct {
	Issue    Issue
	Comments []Comment
}

IssueDetail is the combined result of fetching one issue/PR plus its comments (two GETs -- see Client.IssueDetail).

type LineThread

type LineThread struct {
	Line   int
	Side   string // "RIGHT" or "LEFT" -- see ReviewComment.Side
	Thread []ReviewComment
}

LineThread is every comment anchored to one line within one file, ordered oldest-first (a conversation thread).

type NoReadmeError

type NoReadmeError struct {
	Owner, Repo string
}

NoReadmeError marks a repo with no readme (a 404 on the readme endpoint): the repo tab shows a muted "no readme" line for this, not an error banner -- it's a normal, expected repo state.

func (*NoReadmeError) Error

func (e *NoReadmeError) Error() string

type NotFoundError

type NotFoundError struct {
	Path string
}

NotFoundError marks a 404 from the GitHub API so callers can render "not found or no access" instead of a raw HTTP error.

func (*NotFoundError) Error

func (e *NotFoundError) Error() string

type PatchOmission

type PatchOmission int

PatchOmission says WHY a PullFile carries no unified diff. The files endpoint has no "binary" flag -- it simply omits "patch", and it does that for several unrelated reasons, so "no patch" alone cannot mean binary. Distinguishing them is what keeps an oversized text diff from being labeled a binary file.

const (
	// PatchPresent -- GitHub sent a patch; there is a diff to render.
	PatchPresent PatchOmission = iota
	// PatchOmittedRename -- a pure rename with no content change.
	PatchOmittedRename
	// PatchOmittedBinary -- no patch AND no line counts, so the file has
	// no text lines to diff at all: binary content.
	PatchOmittedBinary
	// PatchOmittedTooLarge -- the line counts say text changed, so a diff
	// exists; GitHub just declined to send it (its per-file diff size and
	// line caps).
	PatchOmittedTooLarge
)

type PullFile

type PullFile struct {
	Filename         string `json:"filename"`
	PreviousFilename string `json:"previous_filename"`
	Status           string `json:"status"` // added, removed, modified, renamed, copied, changed, unchanged
	Additions        int    `json:"additions"`
	Deletions        int    `json:"deletions"`
	Changes          int    `json:"changes"`
	Patch            string `json:"patch"`
}

PullFile is one entry from repos/{o}/{r}/pulls/{n}/files -- the files-changed list with +/- counts, plus GitHub's own per-file unified diff ("patch"). GitHub omits Patch for binary files, for files over its size threshold, and for pure renames with no content change -- see Omission, which the diff view uses to render the placeholder matching the actual reason instead of an empty pane.

func (PullFile) IsBinary

func (f PullFile) IsBinary() bool

IsBinary reports content GitHub cannot express as a text diff -- the diff view's binary-file placeholder case.

func (PullFile) IsDiffTooLarge

func (f PullFile) IsDiffTooLarge() bool

IsDiffTooLarge reports a text diff GitHub withheld rather than one that does not exist -- the diff view labels this "diff not loaded", not "binary".

func (PullFile) IsPureRename

func (f PullFile) IsPureRename() bool

IsPureRename reports a rename with no content change -- GitHub omits Patch for these (distinct from a binary file, which also omits Patch).

func (PullFile) IsRenamed

func (f PullFile) IsRenamed() bool

IsRenamed reports whether this entry is a rename (with or without content changes).

func (PullFile) Omission

func (f PullFile) Omission() PatchOmission

Omission classifies this entry per PatchOmission. The line counts carry the signal: GitHub reports 0/0/0 for a file it can't diff textually, and real +/- counts for a text diff it merely withheld.

type Release

type Release struct {
	TagName     string    `json:"tag_name"`
	Name        string    `json:"name"`
	Body        string    `json:"body"`
	PublishedAt time.Time `json:"published_at"`
	Prerelease  bool      `json:"prerelease"`
	Draft       bool      `json:"draft"`
	Assets      []Asset   `json:"assets"`
}

Release is the subset of repos/{o}/{r}/releases the UI shows.

type RepoMeta

type RepoMeta struct {
	FullName        string   `json:"full_name"`
	Description     string   `json:"description"`
	StargazersCount int      `json:"stargazers_count"`
	DefaultBranch   string   `json:"default_branch"`
	Topics          []string `json:"topics"`
	OpenIssuesCount int      `json:"open_issues_count"`
	HTMLURL         string   `json:"html_url"`
	Fork            bool     `json:"fork"`
	License         *struct {
		SPDXID string `json:"spdx_id"`
	} `json:"license"`
	Parent *struct {
		FullName string `json:"full_name"`
	} `json:"parent"`
}

RepoMeta is the subset of GET repos/{owner}/{repo} the UI shows.

func (RepoMeta) LicenseSPDX

func (r RepoMeta) LicenseSPDX() string

LicenseSPDX returns the license's SPDX identifier, or "" if the repo has no license.

func (RepoMeta) ParentFullName

func (r RepoMeta) ParentFullName() string

ParentFullName returns the parent repo's "owner/name" if this repo is a fork, or "" otherwise.

type ReviewComment

type ReviewComment struct {
	ID           int64     `json:"id"`
	Path         string    `json:"path"`
	Line         int       `json:"line"`          // current-side line; 0 if the comment anchors to a since-removed/outdated line
	OriginalLine int       `json:"original_line"` // stable even after a force-push moves Line
	Side         string    `json:"side"`          // "RIGHT" (new file, the common case) or "LEFT" (old file); "" treated as RIGHT
	Body         string    `json:"body"`
	User         issueUser `json:"user"`
	CreatedAt    time.Time `json:"created_at"`
}

ReviewComment is one entry from repos/{o}/{r}/pulls/{n}/comments -- a line-anchored review comment, distinct from the issue-style top-level comments IssueDetail fetches (BUILD.md extension: "review threads grouped by file:line").

func (ReviewComment) AnchorLine

func (c ReviewComment) AnchorLine() int

AnchorLine returns the line this comment groups under: Line when present, else OriginalLine -- a comment on a line since edited or removed from the live diff still needs a stable grouping key.

func (ReviewComment) Author

func (c ReviewComment) Author() string

Author returns the comment author's login, or "" if the API omitted the user object.

func (ReviewComment) IsLeftSide

func (c ReviewComment) IsLeftSide() bool

IsLeftSide reports whether this comment anchors to the old (removed) side of the diff rather than the new (added/context) side.

type SearchIssue

type SearchIssue struct {
	Number        int             `json:"number"`
	Title         string          `json:"title"`
	State         string          `json:"state"`
	User          issueUser       `json:"user"`
	CreatedAt     time.Time       `json:"created_at"`
	UpdatedAt     time.Time       `json:"updated_at"`
	Comments      int             `json:"comments"`
	Body          string          `json:"body"`
	PullRequest   json.RawMessage `json:"pull_request,omitempty"`
	RepositoryURL string          `json:"repository_url"`
}

SearchIssue is one entry from GitHub's search/issues endpoint -- the shape ghab's only cross-repo list ("my PRs", issue #11) fetches. It carries everything Issue does plus RepositoryURL, since search results span repos and the UI needs to know which repo each row belongs to.

func MergeSearchIssues

func MergeSearchIssues(pages [][]SearchIssue) []SearchIssue

MergeSearchIssues concatenates several queries' result pages into one list: de-duplicated by repo+number (a PR matching two queries appears once) and ordered most-recently-updated first, so the merged list reads like a single ranked list rather than one query's page followed by another's. Pure and unit-testable without a network round trip.

func (SearchIssue) Author

func (i SearchIssue) Author() string

Author returns the PR author's login, or "" if the API omitted the user object.

func (SearchIssue) RepoFullName

func (i SearchIssue) RepoFullName() string

RepoFullName extracts "owner/repo" from RepositoryURL ("https://api.github.com/repos/{owner}/{repo}") -- "" if the field is missing or doesn't carry the expected "/repos/" marker.

type TooLargeError

type TooLargeError struct {
	Path string
	Size int64
}

TooLargeError marks a file whose reported size exceeds maxFileSize; the blob is refused before any network request, per BUILD.md's M2 spec.

func (*TooLargeError) Error

func (e *TooLargeError) Error() string

type Tree

type Tree struct {
	Entries   []TreeEntry `json:"tree"`
	Truncated bool        `json:"truncated"`
}

Tree is the result of a recursive tree fetch.

type TreeEntry

type TreeEntry struct {
	Path string `json:"path"`
	Type string `json:"type"` // "blob" (file), "tree" (dir), or "commit" (submodule)
	Size int64  `json:"size"`
	SHA  string `json:"sha"`
}

TreeEntry is one entry from repos/{o}/{r}/git/trees/{branch}?recursive=1.

func (TreeEntry) IsDir

func (e TreeEntry) IsDir() bool

IsDir reports whether the entry is a directory.

type User

type User struct {
	Login       string `json:"login"`
	Name        string `json:"name"`
	Bio         string `json:"bio"`
	Location    string `json:"location"`
	Company     string `json:"company"`
	Blog        string `json:"blog"`
	Followers   int    `json:"followers"`
	Following   int    `json:"following"`
	PublicRepos int    `json:"public_repos"`
	HTMLURL     string `json:"html_url"`
}

User is the subset of GET users/{login} the profile screen shows.

type UserRepo

type UserRepo struct {
	Name            string    `json:"name"`
	FullName        string    `json:"full_name"`
	Description     string    `json:"description"`
	StargazersCount int       `json:"stargazers_count"`
	Language        string    `json:"language"`
	Fork            bool      `json:"fork"`
	Archived        bool      `json:"archived"`
	UpdatedAt       time.Time `json:"updated_at"`
	HTMLURL         string    `json:"html_url"`
}

UserRepo is one repo row on the profile screen (and, since the search endpoint returns the same shape, one search result row).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL