diff --git a/domain/app.go b/domain/app.go new file mode 100644 index 0000000..304d28b --- /dev/null +++ b/domain/app.go @@ -0,0 +1,81 @@ +package domain + +import ( + "os" + + "github.com/joho/godotenv" + + "redmine_tree/domain/model" // Importing the model package +) + +func Run() { + ui := NewUI() // Instantiate UI + + // Load .env file + err := godotenv.Load() + if err != nil && !os.IsNotExist(err) { + ui.PrintError("Error loading .env file: %v\n", err) + } + + // Load config from ~/.redmine-tree.json + config, err := LoadConfig() + if err != nil { + ui.PrintError("Error loading config file: %v\n", err) + } + + 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 { + projects, err := model.FetchAllProjects(baseURL, apiKey) + if err != nil { + ui.PrintError("Error fetching projects: %v\n", err) + } + ui.PrintProjects(projects) + os.Exit(0) + } + + if baseURL == "" || apiKey == "" { + ui.PrintUsage(os.Args[0]) // Pass app name for usage message + } + + project, err := model.FetchProject(baseURL, apiKey, projectID) + if err != nil { + ui.PrintError("Error fetching project details: %v\n", err) + } + + ui.PrintFetchingProjectDetails(project.Name, projectID) + + issues, err := model.FetchAllIssues(baseURL, apiKey, projectID) + if err != nil { + ui.PrintError("Error fetching issues: %v\n", err) + } + + ui.PrintTotalIssuesFetched(len(issues)) + + roots := model.BuildTree(issues) // Corrected call + ui.PrintIssueTreeHeader(len(roots)) + model.PrintTree(roots, "", false) // Corrected call + + // Print summary + ui.PrintSummary(len(issues), len(roots)) +} diff --git a/domain/config.go b/domain/config.go new file mode 100644 index 0000000..b0b2a0f --- /dev/null +++ b/domain/config.go @@ -0,0 +1,38 @@ +package domain + +import ( + "encoding/json" + "fmt" + "os" +) + +// 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 +} diff --git a/domain/model/issue.go b/domain/model/issue.go new file mode 100644 index 0000000..d4b6478 --- /dev/null +++ b/domain/model/issue.go @@ -0,0 +1,74 @@ +package model + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// --- 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"` +} + +// 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 +} \ No newline at end of file diff --git a/domain/model/node.go b/domain/model/node.go new file mode 100644 index 0000000..91b9889 --- /dev/null +++ b/domain/model/node.go @@ -0,0 +1,67 @@ +package model + +import ( + "fmt" +) + +type Node struct { + Issue Issue // Assuming Issue struct is imported or defined in the same package + Children []*Node +} + +// BuildTree builds a hierarchical tree of issues based on parent-child relationships. +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 +} + +// PrintTree prints the issue tree to standard output. +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) + } +} diff --git a/domain/model/project.go b/domain/model/project.go new file mode 100644 index 0000000..a27b30a --- /dev/null +++ b/domain/model/project.go @@ -0,0 +1,100 @@ +package model + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// --- 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"` +} + +// 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 +} diff --git a/domain/ui.go b/domain/ui.go new file mode 100644 index 0000000..541d3a2 --- /dev/null +++ b/domain/ui.go @@ -0,0 +1,78 @@ +package domain + +import ( + "fmt" + "os" + + "redmine_tree/domain/model" +) + +// UI represents the user interface output. +type UI struct { + // You can add fields here for more advanced UI customization if needed, + // e.g., output writer (os.Stdout, os.Stderr), log levels, etc. +} + +// NewUI creates and returns a new UI instance. +func NewUI() *UI { + return &UI{} +} + +// PrintError prints an error message to stderr and exits. +func (ui *UI) PrintError(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, format, a...) + os.Exit(1) +} + +// Printf prints a formatted message to stdout. +func (ui *UI) Printf(format string, a ...interface{}) { + fmt.Printf(format, a...) +} + +// Println prints a message to stdout with a newline. +func (ui *UI) Println(a ...interface{}) { + fmt.Println(a...) +} + +// PrintProjects lists projects to stdout. +func (ui *UI) PrintProjects(projects []model.Project) { + ui.Printf("No project ID provided. Listing all available projects:\n\n") + if len(projects) == 0 { + ui.Println("No projects found.") + } else { + for _, p := range projects { + ui.Printf(" #%d %s\n", p.ID, p.Name) + } + } +} + +// PrintUsage prints the application usage message to stderr and exits. +func (ui *UI) PrintUsage(appName string) { + ui.Printf("Usage: %s \n", appName) + ui.Printf(" REDMINE_URL and REDMINE_TOKEN must be set in .env, as environment variables, or in ~/.redmine-tree.json.\n") + ui.Printf("Example: REDMINE_URL=https://redmine.example.com REDMINE_TOKEN=abc123 %s my-project\n", appName) + ui.Printf("Or: %s my-project (if REDMINE_URL, REDMINE_TOKEN are in .env or ~/.redmine-tree.json)\n", appName) + os.Exit(1) +} + +// PrintFetchingProjectDetails prints the message when fetching project details. +func (ui *UI) PrintFetchingProjectDetails(projectName, projectID string) { + ui.Printf("Fetching issues for project: %s (%s)\n\n", projectName, projectID) +} + +// PrintTotalIssuesFetched prints the total number of issues fetched. +func (ui *UI) PrintTotalIssuesFetched(count int) { + ui.Printf("Total issues fetched: %d\n\n", count) +} + +// PrintIssueTreeHeader prints the header for the issue tree. +func (ui *UI) PrintIssueTreeHeader(rootCount int) { + ui.Printf("Issue tree (%d root issues):\n\n", rootCount) +} + +// PrintSummary prints the summary of issues. +func (ui *UI) PrintSummary(totalIssues, rootIssues int) { + ui.Printf("\n--- Summary ---\n") + ui.Printf("Total issues : %d\n", totalIssues) + ui.Printf("Root issues : %d\n", rootIssues) +} \ No newline at end of file diff --git a/main.go b/main.go index 1fbea00..68a46ea 100644 --- a/main.go +++ b/main.go @@ -1,362 +1,9 @@ - - package main import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - - "github.com/joho/godotenv" + "redmine_tree/domain" ) -// --- 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)) -} + domain.Run() +} \ No newline at end of file