* feat(go): make go fmt git-aware by default - By default, only check changed Go files (modified, staged, untracked) - Add --all flag to check all files (previous behaviour) - Reduces noise when running fmt on large codebases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): minimal output by default, add missing i18n - Default output now shows single line: "Success Built N artifacts (dir)" - Add --verbose/-v flag to show full detailed output - Add all missing i18n translations for build commands - Errors still show failure reason in minimal mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add root-level `core git` command - Create pkg/gitcmd with git workflow commands as root menu - Export command builders from pkg/dev (AddCommitCommand, etc.) - Commands available under both `core git` and `core dev` for compatibility - Git commands: health, commit, push, pull, work, sync, apply - GitHub orchestration stays in dev: issues, reviews, ci, impact Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(qa): add docblock coverage checking Implement docblock/docstring coverage analysis for Go code: - New `core qa docblock` command to check coverage - Shows compact file:line list when under threshold - Integrate with `core go qa` as a default check - Add --docblock-threshold flag (default 80%) The checker uses Go AST parsing to find exported symbols (functions, types, consts, vars) without documentation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - Fix doc comment: "status" → "health" in gitcmd package - Implement --check flag for `core go fmt` (exits non-zero if files need formatting) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add docstrings for 100% coverage Add documentation comments to all exported symbols: - pkg/build: ProjectType constants - pkg/cli: LogLevel, RenderStyle, TableStyle - pkg/framework: ServiceFor, MustServiceFor, Core.Core - pkg/git: GitError.Error, GitError.Unwrap - pkg/i18n: Handler Match/Handle methods - pkg/log: Level constants - pkg/mcp: Tool input/output types - pkg/php: Service constants, QA types, service methods - pkg/process: ServiceError.Error - pkg/repos: RepoType constants - pkg/setup: ChangeType, ChangeCategory constants - pkg/workspace: AddWorkspaceCommands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: standardize line endings to LF Add .gitattributes to enforce LF line endings for all text files. Normalize all existing files to use Unix-style line endings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - cmd_format.go: validate --check/--fix mutual exclusivity, capture stderr - cmd_docblock.go: return error instead of os.Exit(1) for proper error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback (round 2) - linuxkit.go: propagate state update errors, handle cmd.Wait() errors in waitForExit - mcp.go: guard against empty old_string in editDiff to prevent runaway edits - cmd_docblock.go: log parse errors instead of silently skipping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
302 lines
9.3 KiB
Go
302 lines
9.3 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Repo represents a repository from the GitHub API.
|
|
type Repo struct {
|
|
CloneURL string `json:"clone_url"` // The URL to clone the repository.
|
|
}
|
|
|
|
// ReleaseAsset represents a single asset from a GitHub release.
|
|
type ReleaseAsset struct {
|
|
Name string `json:"name"` // The name of the asset.
|
|
DownloadURL string `json:"browser_download_url"` // The URL to download the asset.
|
|
}
|
|
|
|
// Release represents a GitHub release.
|
|
type Release struct {
|
|
TagName string `json:"tag_name"` // The name of the tag for the release.
|
|
PreRelease bool `json:"prerelease"` // Indicates if the release is a pre-release.
|
|
Assets []ReleaseAsset `json:"assets"` // A list of assets associated with the release.
|
|
}
|
|
|
|
// GithubClient defines the interface for interacting with the GitHub API.
|
|
// This allows for mocking the client in tests.
|
|
type GithubClient interface {
|
|
// GetPublicRepos fetches the public repositories for a user or organization.
|
|
GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error)
|
|
// GetLatestRelease fetches the latest release for a given repository and channel.
|
|
GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error)
|
|
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
|
|
GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
|
|
}
|
|
|
|
type githubClient struct{}
|
|
|
|
// NewAuthenticatedClient creates a new HTTP client that authenticates with the GitHub API.
|
|
// It uses the GITHUB_TOKEN environment variable for authentication.
|
|
// If the token is not set, it returns the default HTTP client.
|
|
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
|
|
token := os.Getenv("GITHUB_TOKEN")
|
|
if token == "" {
|
|
return http.DefaultClient
|
|
}
|
|
ts := oauth2.StaticTokenSource(
|
|
&oauth2.Token{AccessToken: token},
|
|
)
|
|
return oauth2.NewClient(ctx, ts)
|
|
}
|
|
|
|
func (g *githubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
|
|
return g.getPublicReposWithAPIURL(ctx, "https://api.github.com", userOrOrg)
|
|
}
|
|
|
|
func (g *githubClient) getPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) {
|
|
client := NewAuthenticatedClient(ctx)
|
|
var allCloneURLs []string
|
|
url := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg)
|
|
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
// Try organization endpoint
|
|
url = fmt.Sprintf("%s/orgs/%s/repos", apiURL, userOrOrg)
|
|
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
|
resp, err = client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("failed to fetch repos: %s", resp.Status)
|
|
}
|
|
|
|
var repos []Repo
|
|
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
|
|
resp.Body.Close()
|
|
return nil, err
|
|
}
|
|
resp.Body.Close()
|
|
|
|
for _, repo := range repos {
|
|
allCloneURLs = append(allCloneURLs, repo.CloneURL)
|
|
}
|
|
|
|
linkHeader := resp.Header.Get("Link")
|
|
if linkHeader == "" {
|
|
break
|
|
}
|
|
nextURL := g.findNextURL(linkHeader)
|
|
if nextURL == "" {
|
|
break
|
|
}
|
|
url = nextURL
|
|
}
|
|
|
|
return allCloneURLs, nil
|
|
}
|
|
|
|
func (g *githubClient) findNextURL(linkHeader string) string {
|
|
links := strings.Split(linkHeader, ",")
|
|
for _, link := range links {
|
|
parts := strings.Split(link, ";")
|
|
if len(parts) == 2 && strings.TrimSpace(parts[1]) == `rel="next"` {
|
|
return strings.Trim(strings.TrimSpace(parts[0]), "<>")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetLatestRelease fetches the latest release for a given repository and channel.
|
|
// The channel can be "stable", "beta", or "alpha".
|
|
func (g *githubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
|
|
client := NewAuthenticatedClient(ctx)
|
|
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
|
|
}
|
|
|
|
var releases []Release
|
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return filterReleases(releases, channel), nil
|
|
}
|
|
|
|
// filterReleases filters releases based on the specified channel.
|
|
func filterReleases(releases []Release, channel string) *Release {
|
|
for _, release := range releases {
|
|
releaseChannel := determineChannel(release.TagName, release.PreRelease)
|
|
if releaseChannel == channel {
|
|
return &release
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// determineChannel determines the stability channel of a release based on its tag and PreRelease flag.
|
|
func determineChannel(tagName string, isPreRelease bool) string {
|
|
tagLower := strings.ToLower(tagName)
|
|
if strings.Contains(tagLower, "alpha") {
|
|
return "alpha"
|
|
}
|
|
if strings.Contains(tagLower, "beta") {
|
|
return "beta"
|
|
}
|
|
if isPreRelease { // A pre-release without alpha/beta is treated as beta
|
|
return "beta"
|
|
}
|
|
return "stable"
|
|
}
|
|
|
|
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
|
|
func (g *githubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
|
|
client := NewAuthenticatedClient(ctx)
|
|
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Borg-Data-Collector")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
|
|
}
|
|
|
|
var releases []Release
|
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The pr number is included in the tag name with the format `vX.Y.Z-alpha.pr.123` or `vX.Y.Z-beta.pr.123`
|
|
prTagSuffix := fmt.Sprintf(".pr.%d", prNumber)
|
|
for _, release := range releases {
|
|
if strings.Contains(release.TagName, prTagSuffix) {
|
|
return &release, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil // No release found for the given PR number
|
|
}
|
|
|
|
// GetDownloadURL finds the appropriate download URL for the current operating system and architecture.
|
|
//
|
|
// It supports two modes of operation:
|
|
// 1. Using a 'releaseURLFormat' template: If 'releaseURLFormat' is provided,
|
|
// it will be used to construct the download URL. The template can contain
|
|
// placeholders for the release tag '{tag}', operating system '{os}', and
|
|
// architecture '{arch}'.
|
|
// 2. Automatic detection: If 'releaseURLFormat' is empty, the function will
|
|
// inspect the assets of the release to find a suitable download URL. It
|
|
// searches for an asset name that contains both the current OS and architecture
|
|
// (e.g., "my-app-linux-amd64"). If no match is found, it falls back to
|
|
// matching only the OS.
|
|
//
|
|
// Example with releaseURLFormat:
|
|
//
|
|
// release := &updater.Release{TagName: "v1.2.3"}
|
|
// url, err := updater.GetDownloadURL(release, "https://example.com/downloads/{tag}/{os}/{arch}")
|
|
// if err != nil {
|
|
// // handle error
|
|
// }
|
|
// fmt.Println(url) // "https://example.com/downloads/v1.2.3/linux/amd64" (on a Linux AMD64 system)
|
|
//
|
|
// Example with automatic detection:
|
|
//
|
|
// release := &updater.Release{
|
|
// Assets: []updater.ReleaseAsset{
|
|
// {Name: "my-app-linux-amd64", DownloadURL: "https://example.com/download/linux-amd64"},
|
|
// {Name: "my-app-windows-amd64", DownloadURL: "https://example.com/download/windows-amd64"},
|
|
// },
|
|
// }
|
|
// url, err := updater.GetDownloadURL(release, "")
|
|
// if err != nil {
|
|
// // handle error
|
|
// }
|
|
// fmt.Println(url) // "https://example.com/download/linux-amd64" (on a Linux AMD64 system)
|
|
func GetDownloadURL(release *Release, releaseURLFormat string) (string, error) {
|
|
if release == nil {
|
|
return "", fmt.Errorf("no release provided")
|
|
}
|
|
|
|
if releaseURLFormat != "" {
|
|
// Replace {tag}, {os}, and {arch} placeholders
|
|
r := strings.NewReplacer(
|
|
"{tag}", release.TagName,
|
|
"{os}", runtime.GOOS,
|
|
"{arch}", runtime.GOARCH,
|
|
)
|
|
return r.Replace(releaseURLFormat), nil
|
|
}
|
|
|
|
osName := runtime.GOOS
|
|
archName := runtime.GOARCH
|
|
|
|
for _, asset := range release.Assets {
|
|
assetNameLower := strings.ToLower(asset.Name)
|
|
// Match asset that contains both OS and architecture
|
|
if strings.Contains(assetNameLower, osName) && strings.Contains(assetNameLower, archName) {
|
|
return asset.DownloadURL, nil
|
|
}
|
|
}
|
|
|
|
// Fallback for OS only if no asset matched both OS and arch
|
|
for _, asset := range release.Assets {
|
|
assetNameLower := strings.ToLower(asset.Name)
|
|
if strings.Contains(assetNameLower, osName) {
|
|
return asset.DownloadURL, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no suitable download asset found for %s/%s", osName, archName)
|
|
}
|