initial commit

This commit is contained in:
2026-02-19 19:54:30 +01:00
commit 75aa3027cf
7 changed files with 540 additions and 0 deletions

362
main.go Normal file
View File

@@ -0,0 +1,362 @@
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 <project-id>\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))
}