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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
redmine-tree
redmine_tree

28
Makefile Normal file
View File

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

137
README.md Normal file
View File

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

3
env-example Normal file
View File

@@ -0,0 +1,3 @@
REDMINE_URL=https://your-redmine.example.com
REDMINE_TOKEN=your_redmine_api_key
REDMINE_PROJECT_ID=your_project_identifier

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module redmine_tree
go 1.25.0
require github.com/joho/godotenv v1.5.1 // indirect

2
go.sum Normal file
View File

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

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