commit 75aa3027cf097aa0322609892ced6d5f75344462 Author: Zsolt Tasnadi Date: Thu Feb 19 19:54:30 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b186df --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +redmine-tree +redmine_tree diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f14138 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +# Makefile for redmine-tree + +.PHONY: all build install clean + +BINARY_NAME := redmine-tree +BUILD_DIR := build +INSTALL_DIR := $(HOME)/.local/bin + +all: build + +build: + @echo "Building $(BINARY_NAME)..." + @go build -o $(BUILD_DIR)/$(BINARY_NAME) . + +install: build +ifeq ($(OS),Windows_NT) + @echo "Detected Windows. Executable built to $(BUILD_DIR)/$(BINARY_NAME).exe" + @echo "Please add $(abspath $(BUILD_DIR)) to your PATH manually if you wish to run it from anywhere." +else + @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." + @mkdir -p $(INSTALL_DIR) + @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/ + @echo "Installation complete. Make sure $(INSTALL_DIR) is in your PATH." +endif + +clean: + @echo "Cleaning build directory..." + @rm -rf $(BUILD_DIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6e73b8 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Redmine Issue Tree Generator + +This Go application fetches issues from a Redmine instance for a specified project and displays them in a tree-like structure, reflecting their parent-child relationships. + +## Features + +- Fetches issues from Redmine API for a given project and displays the project name. +- Organizes issues into a hierarchical tree based on parent-child relationships. +- Displays issue ID, subject, and status. +- **Lists all available Redmine projects (ID and Name) if no `project-id` is provided.** +- Supports configuration via environment variables, a `.env` file, or a `~/.redmine-tree.json` configuration file. + +## Setup + +1. **Install Go dependencies:** + ```bash + go mod tidy + ``` + +## Configuration + +The application retrieves configuration in the following order of precedence: + +1. **Command-line arguments**: Primarily for `project-id`. +2. **Config file (`~/.redmine-tree.json`)**: For `host` and `token`. +3. **Environment Variables**: `REDMINE_URL` and `REDMINE_TOKEN` (these will override values from the config file if set). + +### Config File (`~/.redmine-tree.json`) + +You can create a JSON configuration file named `.redmine-tree.json` in your home directory (`~`). This file can contain the `host` and `token` for your Redmine instance. + +Example `~/.redmine-tree.json`: + +```json +{ + "host": "https://your-redmine.example.com", + "token": "your_redmine_api_key" +} +``` + +- **`host`**: The base URL of your Redmine instance. +- **`token`**: Your Redmine API access key. + +### Environment Variables + +You can set `REDMINE_URL` and `REDMINE_TOKEN` as environment variables. These values will override any corresponding settings found in `~/.redmine-tree.json`. +For convenience, you can create a file named `.env` in the root directory of the project. You can use `env-example` as a template: + +```bash +cp env-example .env +``` + +Edit the `.env` file with your Redmine instance details: + +```ini +REDMINE_URL=https://your-redmine.example.com +REDMINE_TOKEN=your_redmine_api_key +``` + +- **`REDMINE_URL`**: The base URL of your Redmine instance (e.g., `https://redmine.example.com`). +- **`REDMINE_TOKEN`**: Your Redmine API access key. + +### Project ID + +The `project-id` is provided as a command-line argument. If no `project-id` is provided, the application will list all available projects. + +## Obtaining your Redmine API Token + +To get your Redmine API access key: + +1. Log in to your Redmine instance. +2. Click on "My Account" in the top right corner. +3. On the "My Account" page, you will find an "API access key" section. +4. Copy the key displayed there. If no key is present, you might need to enable "Enable REST API" in Redmine's administration settings (`Administration -> Settings -> Authentication`). + +## Building and Installing + +Use the provided `Makefile` to build and install the application. + +### Build + +To build the executable: + +```bash +make build +``` + +This will create an executable named `redmine-tree` (or `redmine-tree.exe` on Windows) in the `build/` directory. + +### Install + +To install the executable: + +```bash +make install +``` + +- **On Linux/macOS:** This will install the `redmine-tree` executable to `~/.local/bin/`. Ensure `~/.local/bin/` is in your system's PATH to run the command directly from any directory. +- **On Windows:** The executable will be built to `build/redmine-tree.exe`. The `install` command will inform you of its location and advise you to manually add its directory to your system's PATH if you wish to run it from any location. + +## Usage + +After building (and optionally installing), you can run the application: + +- **To list all projects:** + + ```bash + # Using the installed executable (Linux/macOS) + redmine-tree + + # From the build directory (Linux/macOS) + ./build/redmine-tree + + # On Windows (from build directory) + .\build\redmine-tree.exe + ``` + +- **To show the issue tree for a specific project:** + + ```bash + # Using the installed executable (Linux/macOS) + redmine-tree my-project-id + + # From the build directory (Linux/macOS) + ./build/redmine-tree my-project-id + + # On Windows (from build directory) + .\build\redmine-tree.exe my-project-id + ``` + +## Cleaning + +To remove the `build/` directory and compiled executables: + +```bash +make clean +``` diff --git a/env-example b/env-example new file mode 100644 index 0000000..084bc0e --- /dev/null +++ b/env-example @@ -0,0 +1,3 @@ +REDMINE_URL=https://your-redmine.example.com +REDMINE_TOKEN=your_redmine_api_key +REDMINE_PROJECT_ID=your_project_identifier diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ecd7dac --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module redmine_tree + +go 1.25.0 + +require github.com/joho/godotenv v1.5.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1fbea00 --- /dev/null +++ b/main.go @@ -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 \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)) +}