package main import ( "encoding/json" "fmt" "io" "net/http" "os" "github.com/joho/godotenv" ) // --- Redmine API structs --- type IssueRef struct { ID int `json:"id"` Title string `json:"title"` } type Issue struct { ID int `json:"id"` Subject string `json:"subject"` Status IssueRef `json:"status"` Parent *IssueRef `json:"parent"` } type IssuesResponse struct { Issues []Issue `json:"issues"` TotalCount int `json:"total_count"` Offset int `json:"offset"` Limit int `json:"limit"` } // --- Redmine Project API structs --- type Project struct { ID int `json:"id"` Name string `json:"name"` } type ProjectResponse struct { Project Project `json:"project"` } type ProjectsResponse struct { Projects []Project `json:"projects"` TotalCount int `json:"total_count"` Offset int `json:"offset"` Limit int `json:"limit"` } // Config struct for ~/.redmine-tree.json type Config struct { Host string `json:"host"` Token string `json:"token"` } // Load configuration from ~/.redmine-tree.json func loadConfig() (*Config, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("could not get user home directory: %w", err) } configPath := fmt.Sprintf("%s/.redmine-tree.json", homeDir) file, err := os.Open(configPath) if err != nil { if os.IsNotExist(err) { return nil, nil // File does not exist, not an error } return nil, fmt.Errorf("could not open config file: %w", err) } defer file.Close() var config Config if err := json.NewDecoder(file).Decode(&config); err != nil { return nil, fmt.Errorf("could not decode config file: %w", err) } return &config, nil } // Fetch a single project's details func fetchProject(baseURL, apiKey, projectID string) (*Project, error) { client := &http.Client{} url := fmt.Sprintf("%s/projects/%s.json", baseURL, projectID) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("X-Redmine-API-Key", apiKey) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) } var result ProjectResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("JSON decode error: %w", err) } return &result.Project, nil } // Fetch all projects with pagination func fetchAllProjects(baseURL, apiKey string) ([]Project, error) { var all []Project limit := 100 offset := 0 client := &http.Client{} for { url := fmt.Sprintf("%s/projects.json?limit=%d&offset=%d", baseURL, limit, offset) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("X-Redmine-API-Key", apiKey) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) } var result ProjectsResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("JSON decode error: %w", err) } all = append(all, result.Projects...) if offset+limit >= result.TotalCount { break } offset += limit } return all, nil } // --- Tree node --- type Node struct { Issue Issue Children []*Node } // --- Fetch all issues with pagination --- func fetchAllIssues(baseURL, apiKey, projectID string) ([]Issue, error) { var all []Issue limit := 100 offset := 0 client := &http.Client{} for { url := fmt.Sprintf("%s/issues.json?project_id=%s&limit=%d&offset=%d&status_id=*", baseURL, projectID, limit, offset) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("X-Redmine-API-Key", apiKey) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) } var result IssuesResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("JSON decode error: %w", err) } all = append(all, result.Issues...) if offset+limit >= result.TotalCount { break } offset += limit } return all, nil } // --- Build tree --- func buildTree(issues []Issue) []*Node { nodeMap := make(map[int]*Node, len(issues)) for i := range issues { nodeMap[issues[i].ID] = &Node{Issue: issues[i]} } var roots []*Node for _, node := range nodeMap { if node.Issue.Parent == nil { roots = append(roots, node) } else { parent, ok := nodeMap[node.Issue.Parent.ID] if ok { parent.Children = append(parent.Children, node) } else { // Parent not in project scope → treat as root roots = append(roots, node) } } } return roots } // --- Print tree --- func printTree(nodes []*Node, prefix string, isLast bool) { for i, node := range nodes { last := i == len(nodes)-1 connector := "├── " if last { connector = "└── " } parentInfo := "" if node.Issue.Parent != nil { parentInfo = fmt.Sprintf(" [parent: #%d]", node.Issue.Parent.ID) } fmt.Printf("%s%s#%d %s (%s)%s\n", prefix, connector, node.Issue.ID, node.Issue.Subject, node.Issue.Status.Title, parentInfo, ) childPrefix := prefix if last { childPrefix += " " } else { childPrefix += "│ " } printTree(node.Children, childPrefix, isLast) } } // --- Main --- func main() { // Load .env file err := godotenv.Load() if err != nil && !os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "Error loading .env file: %v\n", err) os.Exit(1) } // Load config from ~/.redmine-tree.json config, err := loadConfig() if err != nil { fmt.Fprintf(os.Stderr, "Error loading config file: %v\n", err) os.Exit(1) } baseURL := "" apiKey := "" // Try to get values from config file first if config != nil { baseURL = config.Host apiKey = config.Token } // Environment variables override config file if envBaseURL := os.Getenv("REDMINE_URL"); envBaseURL != "" { baseURL = envBaseURL } if envAPIKey := os.Getenv("REDMINE_TOKEN"); envAPIKey != "" { apiKey = envAPIKey } var projectID string // projectID must be provided as a command-line argument, or list projects if len(os.Args) > 1 { projectID = os.Args[1] } else { fmt.Printf("No project ID provided. Listing all available projects:\n\n") projects, err := fetchAllProjects(baseURL, apiKey) if err != nil { fmt.Fprintf(os.Stderr, "Error fetching projects: %v\n", err) os.Exit(1) } if len(projects) == 0 { fmt.Println("No projects found.") } else { for _, p := range projects { fmt.Printf(" #%d %s\n", p.ID, p.Name) } } os.Exit(0) } if baseURL == "" || apiKey == "" { fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) fmt.Fprintf(os.Stderr, " REDMINE_URL and REDMINE_TOKEN must be set in .env, as environment variables, or in ~/.redmine-tree.json.\n") fmt.Fprintf(os.Stderr, "Example: REDMINE_URL=https://redmine.example.com REDMINE_TOKEN=abc123 %s my-project\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Or: %s my-project (if REDMINE_URL, REDMINE_TOKEN are in .env or ~/.redmine-tree.json)\n", os.Args[0]) os.Exit(1) } project, err := fetchProject(baseURL, apiKey, projectID) if err != nil { fmt.Fprintf(os.Stderr, "Error fetching project details: %v\n", err) os.Exit(1) } fmt.Printf("Fetching issues for project: %s (%s)\n\n", project.Name, projectID) issues, err := fetchAllIssues(baseURL, apiKey, projectID) if err != nil { fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err) os.Exit(1) } fmt.Printf("Total issues fetched: %d\n\n", len(issues)) roots := buildTree(issues) fmt.Printf("Issue tree (%d root issues):\n\n", len(roots)) printTree(roots, "", false) // Print summary fmt.Printf("\n--- Summary ---\n") fmt.Printf("Total issues : %d\n", len(issues)) fmt.Printf("Root issues : %d\n", len(roots)) }