package github
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/shaunmolloy/bugbox/internal/issues"
"github.com/shaunmolloy/bugbox/internal/logging"
"github.com/shaunmolloy/bugbox/internal/storage/config"
"github.com/shaunmolloy/bugbox/internal/types"
)
const baseURL = "https://api.github.com"
func FetchAllIssues(fetchAll bool, client issues.HttpClient) error {
conf, _ := config.LoadConfig()
// Load existing issues
issuesConf, err := config.LoadIssues()
if err != nil {
issuesConf = config.Issues{} // Initialize an empty Issues map
logging.Error(fmt.Sprintf("Error loading existing issues: %v", err))
}
for _, org := range conf.Orgs {
issues, err := FetchIssues(org, fetchAll, client)
if err != nil {
logging.Error(fmt.Sprintf("Error fetching issues for org %s: %v", org, err))
continue
}
// Process and store issues by org/repo/number
for _, issue := range issues {
issue.Repo = parseRepo(issue.URL)
// Ensure maps exist for this org and repo
if _, ok := issuesConf[issue.Org]; !ok {
issuesConf[issue.Org] = make(map[string]map[int]types.Issue)
}
if _, ok := issuesConf[issue.Org][issue.Repo]; !ok {
issuesConf[issue.Org][issue.Repo] = make(map[int]types.Issue)
}
// Check if issue already exists to preserve Read status
if existingIssue, exists := issuesConf[issue.Org][issue.Repo][issue.ID]; exists {
issue.Read = existingIssue.Read
}
// Remove issue from conf if state is closed
if issue.State == types.StateClosed {
delete(issuesConf[issue.Org][issue.Repo], issue.ID)
if len(issuesConf[issue.Org][issue.Repo]) == 0 {
delete(issuesConf[issue.Org], issue.Repo)
}
continue
}
// Store the issue in the hierarchical structure
issuesConf[issue.Org][issue.Repo][issue.ID] = issue
}
}
if err := config.SaveIssues(issuesConf); err != nil {
logging.Error(fmt.Sprintf("Error saving issues: %v", err))
return err
}
return nil
}
func FetchIssues(owner string, fetchAll bool, client issues.HttpClient) ([]types.Issue, error) {
logging.Info(fmt.Sprintf("Searching GitHub issues in org: %s", owner))
conf, _ := config.LoadConfig()
query := fmt.Sprintf("org:%s is:issue sort:created-desc", owner)
encodedQuery := url.QueryEscape(query)
page := 1
var allIssues []types.Issue
for {
api := fmt.Sprintf("%s/search/issues?q=%s&per_page=100&page=%d", baseURL, encodedQuery, page)
logging.Debug(fmt.Sprintf("Fetching %s", api))
req, err := http.NewRequest("GET", api, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+conf.GitHubToken)
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API error: %s", resp.Status)
}
var result IssueResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Set Repo & Org for each issue
for i := range result.Items {
result.Items[i].Org = owner
result.Items[i].Repo = parseRepo(result.Items[i].URL)
result.Items[i].Read = false
}
allIssues = append(allIssues, result.Items...)
if !fetchAll || len(result.Items) == 0 || page == 10 {
break
}
page++
}
logging.Info(fmt.Sprintf("Found %d issues in org: %s", len(allIssues), owner))
return allIssues, nil
}
package github
import (
"strings"
)
// parseRepo extracts the repository name from html_url.
func parseRepo(url string) string {
// https://github.com/{org}/{repo}/issues/{id}
url = strings.Replace(url, "https://github.com", "", 1)
parts := strings.Split(url, "/")
if len(parts) >= 2 {
return parts[2]
}
return ""
}
package issues
import "net/http"
type ClientMock struct {
DoFunc func(req *http.Request) (*http.Response, error)
}
func (c *ClientMock) Do(req *http.Request) (*http.Response, error) {
return c.DoFunc(req)
}
package logging
import (
"log"
"os"
"path/filepath"
)
var Logger *log.Logger
var LogPath = filepath.Join(os.Getenv("HOME"), ".local", "share", "bugbox", "bugbox.log")
// SetupLogger sets up loggers to write to bugbox.log
func SetupLogger() error {
dir := filepath.Dir(LogPath)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
file, err := os.OpenFile(LogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
Logger = log.New(file, "[BugBox] ", log.Ldate|log.Ltime)
return nil
}
// Info logs an info-level message
func Info(message string) {
if Logger != nil {
Logger.Println("[INFO] " + message)
}
}
// Debug logs a debug-level message
func Debug(message string) {
if Logger != nil {
Logger.Println("[DEBUG] " + message)
}
}
// Error logs an error-level message
func Error(message string) {
if Logger != nil {
Logger.Println("[ERROR] " + message)
}
}
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
var ConfigPath = filepath.Join(os.Getenv("HOME"), ".config", "bugbox", "config.json")
// Validate checks config.json exists with expected structure
func Validate() error {
file, err := os.Open(ConfigPath)
if err != nil {
return fmt.Errorf("File not found")
}
defer file.Close()
var config Config
if err := json.NewDecoder(file).Decode(&config); err != nil {
return fmt.Errorf("Invalid JSON format")
}
if config.GitHubToken == "" {
return fmt.Errorf("Missing github_token")
}
if len(config.Orgs) == 0 {
return fmt.Errorf("Missing orgs")
}
return nil
}
// SaveConfig saves the config to config.json
func SaveConfig(cfg Config) error {
return SaveToFile(ConfigPath, cfg)
}
// LoadFromFile loads the config from config.json
func LoadConfig() (Config, error) {
var cfg Config
err := LoadFromFile(ConfigPath, &cfg)
return cfg, err
}
package config
import (
"os"
"path/filepath"
)
var IssuesPath = filepath.Join(os.Getenv("HOME"), ".config", "bugbox", "issues.json")
// SaveIssues saves issues to a file
func SaveIssues(cfg Issues) error {
return SaveToFile(IssuesPath, cfg)
}
// LoadIssues loads issues from a file
func LoadIssues() (Issues, error) {
var cfg Issues
err := LoadFromFile(IssuesPath, &cfg)
return cfg, err
}
// PruneInvalidOrgs removes orgs from issues config no longer in main config
func PruneInvalidOrgs() error {
// Load the current issues configuration
issuesConf, err := LoadIssues()
if err != nil {
return err
}
// Load the current main configuration
mainConf, err := LoadConfig()
if err != nil {
return err
}
validOrgs := make(map[string]struct{}, len(mainConf.Orgs))
for _, org := range mainConf.Orgs {
validOrgs[org] = struct{}{}
}
// Iterate over the issues configuration and remove invalid orgs
for org := range issuesConf {
if _, exists := validOrgs[org]; !exists {
delete(issuesConf, org)
}
}
return SaveIssues(issuesConf)
}
package config
import (
"encoding/json"
"os"
"path/filepath"
"github.com/shaunmolloy/bugbox/internal/types"
)
// IsExist returns true if path exists
func IsExist(path string) (bool, error) {
_, err := os.Stat(path)
return !os.IsNotExist(err), err
}
// LoadFromFile loads config from a file
func LoadFromFile(path string, conf any) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(file)
return decoder.Decode(conf)
}
// SaveToFile saves config to a file
func SaveToFile(path string, data any) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
// FlattenIssues converts the hierarchical issue structure to a flat slice
func FlattenIssues(issues Issues) []types.Issue {
var flat []types.Issue
for _, repoMap := range issues {
for _, issueMap := range repoMap {
for _, issue := range issueMap {
flat = append(flat, issue)
}
}
}
return flat
}
package types
import (
"encoding/json"
"strings"
)
type State int
const (
StateOpen State = iota
StateClosed
)
func (s State) String() string {
switch s {
case StateClosed:
return "closed"
default:
return "open"
}
}
func (s State) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
func (s *State) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
switch strings.ToLower(str) {
case "closed":
*s = StateClosed
default:
*s = StateOpen
}
return nil
}
package utils
import (
"fmt"
"time"
)
// RelativeTime returns a human-readable relative time string like "2 hours ago".
func RelativeTime(t time.Time) string {
duration := time.Since(t)
switch {
case duration < time.Minute:
return "just now"
case duration < time.Hour:
minutes := int(duration.Minutes())
return fmt.Sprintf("%d minute%s ago", minutes, plural(minutes))
case duration < 24*time.Hour:
hours := int(duration.Hours())
return fmt.Sprintf("%d hour%s ago", hours, plural(hours))
case duration < 30*24*time.Hour:
days := int(duration.Hours() / 24)
return fmt.Sprintf("%d day%s ago", days, plural(days))
default:
months := int(duration.Hours() / (24 * 30))
return fmt.Sprintf("%d month%s ago", months, plural(months))
}
}
func plural(n int) string {
if n != 1 {
return "s"
}
return ""
}