From 87b797a116c4e45ab92ee58cf4eae157e92a8176 Mon Sep 17 00:00:00 2001 From: Christopher Maher Date: Fri, 28 Nov 2025 23:30:35 -0800 Subject: [PATCH] feat: add CI/CD with GitHub Actions, GoReleaser, and Release Please - Add test workflow (runs on PRs and main) - Add lint workflow with golangci-lint - Add release-please for automated versioning and changelogs - Add goreleaser config for cross-platform builds (linux/darwin x amd64/arm64) - Add Docker image builds to ghcr.io/defilan/issueparser - Fix lint issues (errcheck, gofmt, line length) - Update to Go 1.23 --- .github/workflows/lint.yml | 25 ++++ .github/workflows/release-please.yml | 73 +++++++++++ .github/workflows/test.yml | 25 ++++ .gitignore | 3 + .golangci.yml | 39 ++++++ .goreleaser.yaml | 178 +++++++++++++++++++++++++++ .release-please-manifest.json | 3 + CLAUDE.md | 49 ++++++++ Dockerfile.goreleaser | 11 ++ cmd/issueparser/main.go | 6 +- go.mod | 2 +- internal/analyzer/analyzer.go | 22 ++-- internal/github/client.go | 4 +- internal/llm/client.go | 12 +- internal/report/report.go | 3 +- release-please-config.json | 34 +++++ 16 files changed, 466 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/test.yml create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 .release-please-manifest.json create mode 100644 CLAUDE.md create mode 100644 Dockerfile.goreleaser create mode 100644 release-please-config.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..320b4f5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + name: Run on Ubuntu + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run linter + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1.6 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..7a42ecc --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,73 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + packages: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Run Release Please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + goreleaser: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch all tags + run: git fetch --force --tags + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9b4dec4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Run on Ubuntu + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Running Tests + run: | + go mod tidy + make test diff --git a/.gitignore b/.gitignore index 0668b7c..082ea43 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ go.work.sum issue-analysis-report.md *.tar.gz +# GoReleaser +dist/ + # Local configuration .env .envrc diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..87104bb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,39 @@ +version: "2" +run: + allow-parallel-runners: true +linters: + default: none + enable: + - copyloopvar + - dupl + - errcheck + - goconst + - gocyclo + - govet + - ineffassign + - lll + - misspell + - nakedret + - prealloc + - revive + - staticcheck + - unconvert + - unparam + - unused + settings: + lll: + line-length: 140 + revive: + rules: + - name: comment-spacings + - name: import-shadowing + exclusions: + rules: + # Allow longer lines in analyzer (LLM prompts) + - linters: + - lll + path: internal/analyzer/ +formatters: + enable: + - gofmt + - goimports diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..95e8fe1 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,178 @@ +# GoReleaser configuration for IssueParser +# https://goreleaser.com/customization/ + +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: issueparser + main: ./cmd/issueparser + binary: issueparser + + # Build for Linux and macOS + goos: + - linux + - darwin + + goarch: + - amd64 + - arm64 + + env: + - CGO_ENABLED=0 + + ldflags: + - -s -w + +archives: + - id: issueparser + builds: + - issueparser + + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + + files: + - LICENSE + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +dockers: + - id: issueparser-amd64 + goos: linux + goarch: amd64 + ids: + - issueparser + image_templates: + - "ghcr.io/defilan/issueparser:{{ .Version }}-amd64" + - "ghcr.io/defilan/issueparser:latest-amd64" + dockerfile: Dockerfile.goreleaser + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source={{.GitURL}}" + + - id: issueparser-arm64 + goos: linux + goarch: arm64 + ids: + - issueparser + image_templates: + - "ghcr.io/defilan/issueparser:{{ .Version }}-arm64" + - "ghcr.io/defilan/issueparser:latest-arm64" + dockerfile: Dockerfile.goreleaser + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source={{.GitURL}}" + +docker_manifests: + - name_template: "ghcr.io/defilan/issueparser:{{ .Version }}" + image_templates: + - "ghcr.io/defilan/issueparser:{{ .Version }}-amd64" + - "ghcr.io/defilan/issueparser:{{ .Version }}-arm64" + + - name_template: "ghcr.io/defilan/issueparser:latest" + image_templates: + - "ghcr.io/defilan/issueparser:latest-amd64" + - "ghcr.io/defilan/issueparser:latest-arm64" + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" + - "merge conflict" + - Merge pull request + - Merge remote-tracking branch + - Merge branch + + groups: + - title: "New Features" + regexp: "^feat:" + order: 0 + - title: "Bug Fixes" + regexp: "^fix:" + order: 1 + - title: "Performance Improvements" + regexp: "^perf:" + order: 2 + - title: "Other Changes" + order: 999 + +release: + github: + owner: defilan + name: issueparser + + name_template: "v{{.Version}}" + + draft: false + + replace_existing_draft: true + + # Keep Release Please's changelog, just add assets + mode: keep-existing + + footer: | + --- + + ## Installation + + ### Docker + ```bash + docker pull ghcr.io/defilan/issueparser:{{ .Version }} + ``` + + ### Manual Download + + **macOS** + ```bash + # ARM64 (Apple Silicon) + curl -L https://github.com/defilan/issueparser/releases/download/v{{ .Version }}/issueparser_{{ .Version }}_darwin_arm64.tar.gz | tar xz + sudo mv issueparser /usr/local/bin/ + + # AMD64 + curl -L https://github.com/defilan/issueparser/releases/download/v{{ .Version }}/issueparser_{{ .Version }}_darwin_amd64.tar.gz | tar xz + sudo mv issueparser /usr/local/bin/ + ``` + + **Linux** + ```bash + # AMD64 + curl -L https://github.com/defilan/issueparser/releases/download/v{{ .Version }}/issueparser_{{ .Version }}_linux_amd64.tar.gz | tar xz + sudo mv issueparser /usr/local/bin/ + + # ARM64 + curl -L https://github.com/defilan/issueparser/releases/download/v{{ .Version }}/issueparser_{{ .Version }}_linux_arm64.tar.gz | tar xz + sudo mv issueparser /usr/local/bin/ + ``` + + ### Verify Installation + ```bash + issueparser --help + ``` + +metadata: + mod_timestamp: "{{ .CommitTimestamp }}" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..57d87e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +make build # Build binary to bin/issueparser +make test # Run all tests +make run # Build and run with defaults (requires LLM endpoint at localhost:8080) +make run-k8s # Run with port-forwarded LLMKube service +make docker-build # Build Docker image for AMD64 +make deploy # Deploy LLMKube model and job to Kubernetes +make get-results # Copy report from completed K8s job +``` + +## Architecture + +IssueParser is a Go CLI tool that analyzes GitHub issues using an LLM to identify recurring themes and pain points. + +**Data Flow:** +1. `cmd/issueparser/main.go` - CLI entry point, parses flags, orchestrates workflow +2. `internal/github/client.go` - Fetches issues via GitHub REST/Search API +3. `internal/analyzer/analyzer.go` - Batches issues (20 per batch), sends to LLM, synthesizes results +4. `internal/llm/client.go` - OpenAI-compatible `/v1/chat/completions` client +5. `internal/report/report.go` - Generates Markdown report with themes, quotes, severity badges + +**Key Design Decisions:** +- Pure Go with no external dependencies (standard library only) +- Works with any OpenAI-compatible endpoint (LLMKube, Ollama, OpenAI) +- Batch processing to handle LLM context limits +- LLM responses are parsed as JSON; falls back to raw text if parsing fails + +## Environment Variables + +- `GITHUB_TOKEN` - Optional GitHub PAT for higher API rate limits (60 → 5000 req/hr) + +## CI/CD + +- **Tests & Lint** - Run on every PR and push to main +- **Release Please** - Automates version bumps and changelogs based on conventional commits +- **GoReleaser** - Builds cross-platform binaries (linux/darwin × amd64/arm64) and Docker images on release +- Docker images published to `ghcr.io/defilan/issueparser` + +## Conventions + +- Follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` +- Branch naming: `feat/*`, `fix/*`, `docs/*`, `refactor/*` +- Error handling: wrap errors with context using `fmt.Errorf("context: %w", err)` diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..5cb2d11 --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,11 @@ +# Dockerfile for GoReleaser builds +# This Dockerfile expects a pre-built binary in the build context +# and simply packages it into a minimal container image. + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR / +COPY issueparser /usr/local/bin/issueparser +USER 65532:65532 + +ENTRYPOINT ["/usr/local/bin/issueparser"] diff --git a/cmd/issueparser/main.go b/cmd/issueparser/main.go index 2d041b5..27f11ad 100644 --- a/cmd/issueparser/main.go +++ b/cmd/issueparser/main.go @@ -26,9 +26,11 @@ func main() { verbose bool ) - flag.StringVar(&repos, "repos", "ollama/ollama,vllm-project/vllm", "Comma-separated list of repos (owner/repo). Examples: ollama/ollama, vllm-project/vllm, ggerganov/llama.cpp") + flag.StringVar(&repos, "repos", "ollama/ollama,vllm-project/vllm", + "Comma-separated repos (owner/repo)") flag.StringVar(&labels, "labels", "", "Filter by labels (comma-separated)") - flag.StringVar(&keywords, "keywords", "multi-gpu,scale,concurrency,production,performance", "Keywords to search for in issues") + flag.StringVar(&keywords, "keywords", "multi-gpu,scale,concurrency,production,performance", + "Keywords to search for in issues") flag.IntVar(&maxIssues, "max-issues", 100, "Maximum issues to fetch per repo") flag.StringVar(&llmEndpoint, "llm-endpoint", "http://qwen-14b-issueparser-service:8080", "LLMKube service endpoint") flag.StringVar(&llmModel, "llm-model", "qwen-2.5-14b", "Model name for API calls") diff --git a/go.mod b/go.mod index 3cce661..8ce734b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/defilan/issueparser -go 1.21 +go 1.23 diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 3c4c5dc..4cf3071 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -20,11 +20,11 @@ type Options struct { } type Analysis struct { - Themes []Theme `json:"themes"` - KeyInsights []string `json:"key_insights"` - Quotes []Quote `json:"quotes"` - ActionItems []string `json:"action_items"` - RawIssueCount int `json:"raw_issue_count"` + Themes []Theme `json:"themes"` + KeyInsights []string `json:"key_insights"` + Quotes []Quote `json:"quotes"` + ActionItems []string `json:"action_items"` + RawIssueCount int `json:"raw_issue_count"` } type Theme struct { @@ -182,12 +182,12 @@ func (a *Analyzer) parseAnalysis(response string, issueURLs map[int]string, issu // Try to parse the JSON var rawAnalysis struct { Themes []struct { - Name string `json:"name"` - Description string `json:"description"` - IssueNumbers []int `json:"issue_numbers"` - IssueCount int `json:"issue_count"` - Severity string `json:"severity"` - Examples []string `json:"examples"` + Name string `json:"name"` + Description string `json:"description"` + IssueNumbers []int `json:"issue_numbers"` + IssueCount int `json:"issue_count"` + Severity string `json:"severity"` + Examples []string `json:"examples"` ExampleQuotes []string `json:"example_quotes"` } `json:"themes"` KeyInsights []string `json:"key_insights"` diff --git a/internal/github/client.go b/internal/github/client.go index f051ba1..b48762c 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -185,7 +185,7 @@ func (c *Client) fetchSearchPage(ctx context.Context, endpoint string) (*searchR if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Check rate limit headers if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" { @@ -228,7 +228,7 @@ func (c *Client) fetchPage(ctx context.Context, endpoint string) ([]Issue, error if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Check rate limit headers if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" { diff --git a/internal/llm/client.go b/internal/llm/client.go index 2ede52c..a709b20 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -63,10 +63,10 @@ func (c *Client) Chat(ctx context.Context, messages []Message, maxTokens int) (* Model: c.model, Messages: messages, MaxTokens: maxTokens, - Temperature: 0.7, // Higher temperature to avoid repetition - TopP: 0.9, // Nucleus sampling - RepeatPenalty: 1.15, // Penalize repetition (llama.cpp parameter) - PresencePenalty: 0.1, // Slight presence penalty + Temperature: 0.7, // Higher temperature to avoid repetition + TopP: 0.9, // Nucleus sampling + RepeatPenalty: 1.15, // Penalize repetition (llama.cpp parameter) + PresencePenalty: 0.1, // Slight presence penalty Stop: []string{"```\n\n", "\n\n\n\n"}, // Stop on repeated newlines } @@ -87,7 +87,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, maxTokens int) (* if err != nil { return nil, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) @@ -131,7 +131,7 @@ func (c *Client) HealthCheck(ctx context.Context) error { if err != nil { return fmt.Errorf("health check failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { return fmt.Errorf("unhealthy status: %d", resp.StatusCode) diff --git a/internal/report/report.go b/internal/report/report.go index a4c2eb3..f707cb5 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -122,7 +122,8 @@ func (r *Report) WriteMarkdown(filename string) error { sb.WriteString("- **IssueParser** - GitHub issue theme analyzer\n") sb.WriteString("- **LLMKube** - Kubernetes-native LLM inference platform\n") sb.WriteString("- **Model:** Qwen 2.5 14B (dual GPU inference)\n\n") - sb.WriteString("Issues were fetched via GitHub REST API, batched, and analyzed for common themes using LLM-powered pattern recognition.\n") + sb.WriteString("Issues were fetched via GitHub REST API, batched, and analyzed ") + sb.WriteString("for common themes using LLM-powered pattern recognition.\n") return os.WriteFile(filename, []byte(sb.String()), 0644) } diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c0c598f --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "packages": { + ".": { + "component": "", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "draft": false, + "prerelease": false, + "include-component-in-tag": false + } + }, + "versioning": "default", + "include-v-in-tag": true, + "include-component-in-tag": false, + "tag-separator": "", + "pull-request-title-pattern": "chore: release ${version}", + "pull-request-header": ":rocket: Release ${version}", + "changelog-sections": [ + {"type": "feat", "section": "Features", "hidden": false}, + {"type": "fix", "section": "Bug Fixes", "hidden": false}, + {"type": "perf", "section": "Performance Improvements", "hidden": false}, + {"type": "revert", "section": "Reverts", "hidden": false}, + {"type": "docs", "section": "Documentation", "hidden": false}, + {"type": "chore", "section": "Miscellaneous", "hidden": true}, + {"type": "ci", "section": "CI/CD", "hidden": true}, + {"type": "test", "section": "Tests", "hidden": true}, + {"type": "refactor", "section": "Code Refactoring", "hidden": true}, + {"type": "style", "section": "Styles", "hidden": true}, + {"type": "build", "section": "Build System", "hidden": true} + ] +}