refactoring

This commit is contained in:
2026-02-19 20:44:08 +01:00
parent 75aa3027cf
commit ea75f07421
7 changed files with 441 additions and 356 deletions

81
domain/app.go Normal file
View File

@@ -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))
}

38
domain/config.go Normal file
View File

@@ -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
}

74
domain/model/issue.go Normal file
View File

@@ -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
}

67
domain/model/node.go Normal file
View File

@@ -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)
}
}

100
domain/model/project.go Normal file
View File

@@ -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
}

78
domain/ui.go Normal file
View File

@@ -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 <project-id>\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)
}

359
main.go
View File

@@ -1,362 +1,9 @@
package main package main
import ( import (
"encoding/json" "redmine_tree/domain"
"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() { func main() {
// Load .env file domain.Run()
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))
}