feat: Add authenticated GitHub access and structured logging

This commit introduces two key improvements to the application:

1.  **Authenticated GitHub API Access:** The GitHub client now uses a personal access token (PAT) from the `GITHUB_TOKEN` environment variable if it is available. This increases the rate limit for GitHub API requests, making the tool more robust for users who need to collect a large number of repositories.

2.  **Structured Logging:** The application now uses the standard library's `slog` package for structured logging. A `--verbose` flag has been added to the root command to control the log level, allowing for more detailed output when needed. This makes the application's output more consistent and easier to parse.
This commit is contained in:
google-labs-jules[bot] 2025-11-02 00:59:46 +00:00
parent 4fc86ee175
commit beaeb04f03
11 changed files with 90 additions and 48 deletions

View file

@ -3,6 +3,7 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"strings" "strings"
@ -19,26 +20,27 @@ var allCmd = &cobra.Command{
Long: `Collect all public repositories from a user or organization and store them in a DataNode.`, Long: `Collect all public repositories from a user or organization and store them in a DataNode.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
log := cmd.Context().Value("logger").(*slog.Logger)
repos, err := github.GetPublicRepos(context.Background(), args[0]) repos, err := github.GetPublicRepos(context.Background(), args[0])
if err != nil { if err != nil {
fmt.Println(err) log.Error("failed to get public repos", "err", err)
return return
} }
outputDir, _ := cmd.Flags().GetString("output") outputDir, _ := cmd.Flags().GetString("output")
for _, repoURL := range repos { for _, repoURL := range repos {
fmt.Printf("Cloning %s...\n", repoURL) log.Info("cloning repository", "url", repoURL)
dn, err := vcs.CloneGitRepository(repoURL) dn, err := vcs.CloneGitRepository(repoURL)
if err != nil { if err != nil {
fmt.Printf("Error cloning %s: %s\n", repoURL, err) log.Error("failed to clone repository", "url", repoURL, "err", err)
continue continue
} }
data, err := dn.ToTar() data, err := dn.ToTar()
if err != nil { if err != nil {
fmt.Printf("Error serializing DataNode for %s: %v\n", repoURL, err) log.Error("failed to serialize datanode", "url", repoURL, "err", err)
continue continue
} }
@ -46,7 +48,7 @@ var allCmd = &cobra.Command{
outputFile := fmt.Sprintf("%s/%s.dat", outputDir, repoName) outputFile := fmt.Sprintf("%s/%s.dat", outputDir, repoName)
err = os.WriteFile(outputFile, data, 0644) err = os.WriteFile(outputFile, data, 0644)
if err != nil { if err != nil {
fmt.Printf("Error writing DataNode for %s to file: %v\n", repoURL, err) log.Error("failed to write datanode to file", "url", repoURL, "err", err)
continue continue
} }
} }
@ -54,6 +56,6 @@ var allCmd = &cobra.Command{
} }
func init() { func init() {
rootCmd.AddCommand(allCmd) RootCmd.AddCommand(allCmd)
allCmd.PersistentFlags().String("output", ".", "Output directory for the DataNodes") allCmd.PersistentFlags().String("output", ".", "Output directory for the DataNodes")
} }

View file

@ -12,5 +12,5 @@ var collectCmd = &cobra.Command{
} }
func init() { func init() {
rootCmd.AddCommand(collectCmd) RootCmd.AddCommand(collectCmd)
} }

View file

@ -2,8 +2,8 @@ package cmd
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -23,6 +23,7 @@ var collectGithubReleaseCmd = &cobra.Command{
Long: `Download the latest release of a file from GitHub releases. If the file or URL has a version number, it will check for a higher version and download it if found.`, Long: `Download the latest release of a file from GitHub releases. If the file or URL has a version number, it will check for a higher version and download it if found.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
log := cmd.Context().Value("logger").(*slog.Logger)
repoURL := args[0] repoURL := args[0]
outputDir, _ := cmd.Flags().GetString("output") outputDir, _ := cmd.Flags().GetString("output")
pack, _ := cmd.Flags().GetBool("pack") pack, _ := cmd.Flags().GetBool("pack")
@ -31,25 +32,25 @@ var collectGithubReleaseCmd = &cobra.Command{
owner, repo, err := borg_github.ParseRepoFromURL(repoURL) owner, repo, err := borg_github.ParseRepoFromURL(repoURL)
if err != nil { if err != nil {
fmt.Printf("Error parsing repository URL: %v\n", err) log.Error("failed to parse repository url", "err", err)
return return
} }
release, err := borg_github.GetLatestRelease(owner, repo) release, err := borg_github.GetLatestRelease(owner, repo)
if err != nil { if err != nil {
fmt.Printf("Error getting latest release: %v\n", err) log.Error("failed to get latest release", "err", err)
return return
} }
fmt.Printf("Found latest release: %s\n", release.GetTagName()) log.Info("found latest release", "tag", release.GetTagName())
if version != "" { if version != "" {
if !semver.IsValid(version) { if !semver.IsValid(version) {
fmt.Printf("Invalid version string: %s\n", version) log.Error("invalid version string", "version", version)
return return
} }
if semver.Compare(release.GetTagName(), version) <= 0 { if semver.Compare(release.GetTagName(), version) <= 0 {
fmt.Printf("Latest release (%s) is not newer than the provided version (%s).\n", release.GetTagName(), version) log.Info("latest release is not newer than the provided version", "latest", release.GetTagName(), "provided", version)
return return
} }
} }
@ -57,24 +58,24 @@ var collectGithubReleaseCmd = &cobra.Command{
if pack { if pack {
dn := datanode.New() dn := datanode.New()
for _, asset := range release.Assets { for _, asset := range release.Assets {
fmt.Printf("Downloading asset: %s\n", asset.GetName()) log.Info("downloading asset", "name", asset.GetName())
resp, err := http.Get(asset.GetBrowserDownloadURL()) resp, err := http.Get(asset.GetBrowserDownloadURL())
if err != nil { if err != nil {
fmt.Printf("Error downloading asset: %v\n", err) log.Error("failed to download asset", "name", asset.GetName(), "err", err)
continue continue
} }
defer resp.Body.Close() defer resp.Body.Close()
var buf bytes.Buffer var buf bytes.Buffer
_, err = io.Copy(&buf, resp.Body) _, err = io.Copy(&buf, resp.Body)
if err != nil { if err != nil {
fmt.Printf("Error reading asset: %v\n", err) log.Error("failed to read asset", "name", asset.GetName(), "err", err)
continue continue
} }
dn.AddData(asset.GetName(), buf.Bytes()) dn.AddData(asset.GetName(), buf.Bytes())
} }
tar, err := dn.ToTar() tar, err := dn.ToTar()
if err != nil { if err != nil {
fmt.Printf("Error creating DataNode: %v\n", err) log.Error("failed to create datanode", "err", err)
return return
} }
outputFile := outputDir outputFile := outputDir
@ -83,13 +84,13 @@ var collectGithubReleaseCmd = &cobra.Command{
} }
err = os.WriteFile(outputFile, tar, 0644) err = os.WriteFile(outputFile, tar, 0644)
if err != nil { if err != nil {
fmt.Printf("Error writing DataNode: %v\n", err) log.Error("failed to write datanode", "err", err)
return return
} }
fmt.Printf("DataNode saved to %s\n", outputFile) log.Info("datanode saved", "path", outputFile)
} else { } else {
if len(release.Assets) == 0 { if len(release.Assets) == 0 {
fmt.Println("No assets found in the latest release.") log.Info("no assets found in the latest release")
return return
} }
var assetToDownload *gh.ReleaseAsset var assetToDownload *gh.ReleaseAsset
@ -101,20 +102,20 @@ var collectGithubReleaseCmd = &cobra.Command{
} }
} }
if assetToDownload == nil { if assetToDownload == nil {
fmt.Printf("Asset '%s' not found in the latest release.\n", file) log.Error("asset not found in the latest release", "asset", file)
return return
} }
} else { } else {
assetToDownload = release.Assets[0] assetToDownload = release.Assets[0]
} }
outputPath := filepath.Join(outputDir, assetToDownload.GetName()) outputPath := filepath.Join(outputDir, assetToDownload.GetName())
fmt.Printf("Downloading asset: %s\n", assetToDownload.GetName()) log.Info("downloading asset", "name", assetToDownload.GetName())
err = borg_github.DownloadReleaseAsset(assetToDownload, outputPath) err = borg_github.DownloadReleaseAsset(assetToDownload, outputPath)
if err != nil { if err != nil {
fmt.Printf("Error downloading asset: %v\n", err) log.Error("failed to download asset", "name", assetToDownload.GetName(), "err", err)
return return
} }
fmt.Printf("Asset downloaded to %s\n", outputPath) log.Info("asset downloaded", "path", outputPath)
} }
}, },
} }

View file

@ -1,18 +1,18 @@
package cmd package cmd
import ( import (
"log/slog"
"os"
"testing" "testing"
) )
func TestExecute(t *testing.T) { func TestExecute(t *testing.T) {
// This test simply checks that the Execute function can be called without error. // This test simply checks that the Execute function can be called without error.
// It doesn't actually test any of the application's functionality. // It doesn't actually test any of the application's functionality.
rootCmd.SetArgs([]string{}) log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
t.Cleanup(func() { Level: slog.LevelDebug,
rootCmd.SetArgs(nil) }))
}) if err := Execute(log); err != nil {
if err := Execute(); err != nil {
t.Errorf("Execute() failed: %v", err) t.Errorf("Execute() failed: %v", err)
} }
} }
}

View file

@ -1,11 +1,14 @@
package cmd package cmd
import ( import (
"context"
"log/slog"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// rootCmd represents the base command when called without any subcommands // RootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
Use: "borg-data-collector", Use: "borg-data-collector",
Short: "A tool for collecting and managing data.", Short: "A tool for collecting and managing data.",
Long: `Borg Data Collector is a command-line tool for cloning Git repositories, Long: `Borg Data Collector is a command-line tool for cloning Git repositories,
@ -14,18 +17,11 @@ packaging their contents into a single file, and managing the data within.`,
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd. // This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() error { func Execute(log *slog.Logger) error {
return rootCmd.Execute() RootCmd.SetContext(context.WithValue(context.Background(), "logger", log))
return RootCmd.Execute()
} }
func init() { func init() {
// Here you will define your flags and configuration settings. RootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging")
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.borg-data-collector.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
} }

View file

@ -44,6 +44,6 @@ var serveCmd = &cobra.Command{
} }
func init() { func init() {
rootCmd.AddCommand(serveCmd) RootCmd.AddCommand(serveCmd)
serveCmd.PersistentFlags().String("port", "8080", "Port to serve the PWA on") serveCmd.PersistentFlags().String("port", "8080", "Port to serve the PWA on")
} }

4
go.mod
View file

@ -9,6 +9,7 @@ require (
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
golang.org/x/mod v0.29.0 golang.org/x/mod v0.29.0
golang.org/x/net v0.46.0 golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
) )
require ( require (
@ -21,6 +22,7 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@ -35,5 +37,7 @@ require (
golang.org/x/crypto v0.43.0 // indirect golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect golang.org/x/term v0.36.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )

6
go.sum
View file

@ -37,6 +37,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -108,6 +110,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -129,7 +132,10 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View file

@ -1,15 +1,17 @@
package main package main
import ( import (
"fmt"
"os" "os"
"github.com/Snider/Borg/cmd" "github.com/Snider/Borg/cmd"
"github.com/Snider/Borg/pkg/logger"
) )
func main() { func main() {
if err := cmd.Execute(); err != nil { verbose, _ := cmd.RootCmd.PersistentFlags().GetBool("verbose")
fmt.Fprintf(os.Stderr, "Error: %v\n", err) log := logger.New(verbose)
if err := cmd.Execute(log); err != nil {
log.Error("fatal error", "err", err)
os.Exit(1) os.Exit(1)
} }
} }

View file

@ -5,7 +5,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings" "strings"
"golang.org/x/oauth2"
) )
type Repo struct { type Repo struct {
@ -16,7 +19,19 @@ func GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
return GetPublicReposWithAPIURL(ctx, "https://api.github.com", userOrOrg) return GetPublicReposWithAPIURL(ctx, "https://api.github.com", userOrOrg)
} }
func newAuthenticatedClient(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 GetPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) { func GetPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) {
client := newAuthenticatedClient(ctx)
var allCloneURLs []string var allCloneURLs []string
url := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg) url := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg)
@ -26,7 +41,7 @@ func GetPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]
return nil, err return nil, err
} }
req.Header.Set("User-Agent", "Borg-Data-Collector") req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := http.DefaultClient.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }

16
pkg/logger/logger.go Normal file
View file

@ -0,0 +1,16 @@
package logger
import (
"log/slog"
"os"
)
func New(verbose bool) *slog.Logger {
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
}))
}