Files
zooqueen 5577c95c38 deps: consume gh, xfail, and wire from upstream
These three are generic upstream helper libraries that carry no local
changes: the hashes recorded for them are byte-identical to the upstream
modules, and their pinned go.mod bytes declare the upstream path. A
github.com/hanzoai requirement therefore has nothing to resolve against
from a cold cache. Point go.mod, go.sum, and imports at upstream, which
is what the root module already does for wire.

Pin the local docdb replace at v1.0.0; the prior v2 placeholder failed
go.mod parsing.
2026-08-19 22:53:24 -07:00

241 lines
5.7 KiB
Go

// Copyright 2021 Hanzo AI Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package github provides a GitHub API client with a shared issues cache.
package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"time"
"github.com/FerretDB/gh"
"github.com/google/go-github/v70/github"
"github.com/rogpeppe/go-internal/lockedfile"
)
// IssueStatus represents a known issue status.
type IssueStatus string
// Known issue statuses.
const (
IssueOpen IssueStatus = "open"
IssueClosed IssueStatus = "closed"
IssueNotFound IssueStatus = "not found"
)
// issue represents a single cached issue.
type issue struct {
RefreshedAt time.Time `json:"refreshedAt"`
Status IssueStatus `json:"status"`
}
// cacheFile stores information regarding rate limiting and the status of issues.
type cacheFile struct {
Issues map[string]issue `json:"issues"`
RateLimitReached bool `json:"rateLimitReached"`
}
// Client represent GitHub API Client with shared file cache.
type Client struct {
c *github.Client
cacheFilePath string
logf gh.Printf
cacheDebugF gh.Printf
clientDebugF gh.Printf
token string
}
// NewClient creates a new client for the given cache file path and logging functions.
func NewClient(cacheFilePath string, logf, cacheDebugF, clientDebugf gh.Printf) (*Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if logf == nil {
panic("logf must be set")
}
if cacheDebugF == nil {
panic("vf must be set")
}
if clientDebugf == nil {
panic("debugf must be set")
}
c, err := gh.NewRESTClient(token, clientDebugf)
if err != nil {
return nil, err
}
return &Client{
c: c,
cacheFilePath: cacheFilePath,
token: token,
logf: logf,
cacheDebugF: cacheDebugF,
clientDebugF: clientDebugf,
}, nil
}
// CacheFilePath returns the path to the cache file.
func CacheFilePath() (string, error) {
// This tool is called for multiple packages in parallel,
// with the current working directory set to the package directory.
// To use the same cache file path, we first locate the root of the project by the .git directory
// (or .git gitdir file for submodules).
//
// See also similar code in internal/util/testutil package.
dir, err := filepath.Abs(".")
if err != nil {
return "", err
}
for {
_, err := os.Stat(filepath.Join(dir, ".git"))
if err == nil {
break
}
if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
dir = filepath.Dir(dir)
}
return filepath.Join(dir, "tmp", "githubcache", "cache.json"), nil
}
// IssueStatus returns issue status.
// It uses cache.
//
// Any error means something fatal.
// On rate limit, the error is logged once and (issueOpen, nil) is returned.
func (c *Client) IssueStatus(ctx context.Context, owner, repo string, num int) (IssueStatus, error) {
start := time.Now()
cache := &cacheFile{
Issues: make(map[string]issue),
}
cacheRes := "miss"
var res IssueStatus
url := fmt.Sprintf("https://github.com/%s/%s/issues/%d", owner, repo, num)
// fast path without any locks
data, err := os.ReadFile(c.cacheFilePath)
if err == nil {
_ = json.Unmarshal(data, cache)
res = cache.Issues[url].Status
}
if res != "" {
cacheRes = "fast hit"
} else {
// slow path
noUpdate := fmt.Errorf("no need to update the cache file")
err = lockedfile.Transform(c.cacheFilePath, func(data []byte) ([]byte, error) {
cache.Issues = make(map[string]issue)
if len(data) != 0 {
if err = json.Unmarshal(data, cache); err != nil {
return nil, err
}
}
if res = cache.Issues[url].Status; res != "" {
return nil, noUpdate
}
if res, err = c.checkIssueStatus(ctx, owner, repo, num); err != nil {
var rle *github.RateLimitError
if !errors.As(err, &rle) {
return nil, fmt.Errorf("%s: %s", url, err)
}
if cache.RateLimitReached {
c.clientDebugF("Rate limit already reached: %s", url)
return nil, noUpdate
}
cache.RateLimitReached = true
msg := "Rate limit reached: " + err.Error()
if c.token == "" {
msg += "\nPlease set a GITHUB_TOKEN as described at " +
"https://github.com/hanzoai/docdb/blob/main/CONTRIBUTING.md#setting-a-github_token"
}
c.logf("%s", msg)
}
// unless rate limited
if res != "" {
cache.Issues[url] = issue{
RefreshedAt: time.Now(),
Status: res,
}
}
return json.MarshalIndent(cache, "", " ")
})
if errors.Is(err, noUpdate) {
cacheRes = "slow hit"
err = nil
}
}
c.cacheDebugF("%s: %s (%dms, %s)", url, res, time.Since(start).Milliseconds(), cacheRes)
// when rate limited
if err == nil && res == "" {
res = IssueOpen
}
return res, err
}
// checkIssueStatus checks issue status via GitHub API.
// It does not use cache.
func (c *Client) checkIssueStatus(ctx context.Context, owner, repo string, num int) (IssueStatus, error) {
issue, resp, err := c.c.Issues.Get(ctx, owner, repo, num)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
return IssueNotFound, nil
}
return "", err
}
switch s := IssueStatus(issue.GetState()); s {
case IssueOpen, IssueClosed:
return s, nil
case IssueNotFound:
fallthrough
default:
return "", fmt.Errorf("unknown issue state: %q", s)
}
}