Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
abea3dc
feat: add web GUI report editor with Go + Templ + HTMX
Mar 23, 2026
3c206a7
fix: address critical review findings from 5-agent PR review
Mar 23, 2026
4bb6024
fix: stub all deps in TestGenerateReport_StartsJob to prevent nil db …
Mar 23, 2026
66ba38f
feat: add Docker Compose with Caddy HTTPS proxy for web UI
Mar 23, 2026
0621e2f
fix: add loading indicators for Preview and Generate buttons
Mar 23, 2026
e4e05f0
feat: render markdown preview as formatted HTML with wider panel
Mar 23, 2026
82928b6
feat: make preview panel horizontally resizable
Mar 23, 2026
e4d456d
feat: separate view from classify — no LLM calls on page load
Mar 23, 2026
4948365
feat: apply data-dense dashboard design system
Mar 23, 2026
3ec2dde
fix: use POST for delete instead of DELETE method (CSRF compatibility)
Mar 23, 2026
8dfe75a
fix: move CSRF script to body so document.body exists when listener a…
Mar 24, 2026
ac3b887
fix: truncate long URLs in item meta, reload page after delete
Mar 24, 2026
26ec477
chore: sync deployment config (Caddyfile manual cert, port adjustments)
Mar 24, 2026
57056df
fix: cancel edit fetches item row instead of full page (prevents nest…
Mar 24, 2026
f311586
feat: preserve section ordering + add new category support
Mar 24, 2026
1fc1604
feat: add category rename (pencil icon in section header, inline form)
Mar 24, 2026
8976a8a
fix: reclassify inserts classification_history record so change shows…
Mar 24, 2026
7d1534e
chore: add testdata/ to gitignore
Mar 24, 2026
ccf810f
fix: replace gorilla/csrf with custom double-submit cookie CSRF
Mar 24, 2026
05ed68c
feat: add draggable vertical divider between sections and preview panel
Mar 24, 2026
85bd1a3
fix: category form cancel button clears form without network request
Mar 24, 2026
2e9fbcb
fix: remove hx-target=body from category/rename forms (use HX-Redirec…
Mar 24, 2026
6171bec
fix: use hex CSRF tokens + add server-side debug logging
Mar 24, 2026
3a4ff4c
fix: show empty custom categories as visible section cards after crea…
Mar 24, 2026
7866aeb
fix: show report file path in generate success message
Mar 24, 2026
e78ff2f
feat: eliminate full-page reloads — use HTMX partial swaps
Mar 24, 2026
1e13643
fix: reclassify dropdown removes item in-place instead of full page r…
Mar 24, 2026
c90cdb6
feat: hide parent categories from dropdown when subcategories exist
Mar 24, 2026
b703810
fix: show 'Classifying...' feedback on Classify/Re-classify button click
Mar 24, 2026
d598467
chore: update gitignore
Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ docker-compose-*
coverage.out
coverage.txt
coverage.html
testdata/
.gstack/
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ docker run -d --name reportbot \
-v /path/to/config.yaml:/app/config.yaml:ro \
-v reportbot-data:/app/data \
reportbot

# Docker Compose with Web UI (HTTPS via Caddy)
export WEB_HOST=https://192.168.1.100
export WEB_CLIENT_SECRET=xxx
export WEB_SESSION_SECRET=$(openssl rand -hex 32)
docker-compose --project-name reportbot up -d
```

## Configuration
Expand All @@ -36,14 +42,15 @@ Configuration is layered: `config.yaml` is loaded first, then environment variab
- **Report**: `report_private` (bool, when true `/generate-report` DMs the report to the caller instead of posting to the channel; default false)
- **Network**: `tls_skip_verify` (bool, skip TLS cert verification for internal/corporate CAs; default false)
- **Team**: `team_name` (used in report header and filename)
- **Web UI**: `web_enabled` (bool), `web_port` (int, default 8080), `web_client_id`, `web_client_secret` (Slack OAuth), `web_session_secret` (cookie HMAC key), `web_base_url` (OAuth redirect base)

See `config.yaml` and `README.md` for full reference.

## Architecture

The application uses a cmd/internal layout with the executable under `cmd/reportbot` and core logic split by domain under `internal/*` packages:

- **cmd/reportbot/main.go** — Entry point: loads config, initializes DB, creates Slack client, starts nudge and auto-fetch schedulers, starts Socket Mode bot
- **cmd/reportbot/main.go** — Entry point: loads config, initializes DB, creates Slack client, starts nudge and auto-fetch schedulers, optionally starts web UI server, starts Socket Mode bot
- **internal/config/config.go** — Config struct, YAML + env loading with validation, `IsManagerID()` permission check
- **internal/domain/models.go** — Core types (`WorkItem`, `GitLabMR`, `GitHubPR`, `ReportSection`) and `CurrentWeekRange()` calendar week calculator
- **internal/storage/sqlite/db.go** — SQLite schema and CRUD: `work_items`, `classification_history`, `classification_corrections` tables
Expand All @@ -58,6 +65,13 @@ The application uses a cmd/internal layout with the executable under `cmd/report
- **internal/report/report_builder.go** — Template parsing, LLM classification pipeline, merge logic, status ordering, markdown rendering (team + boss modes)
- **internal/report/report.go** — Report file writing (markdown `.md` and email draft `.eml`) to disk
- **internal/nudge/nudge.go** — Scheduled weekly reminder and DM sender (`sendNudges` also used by `/check` nudge buttons)
- **internal/web/handlers/server.go** — Web UI: chi router, CSRF middleware, Slack OAuth, report editor (Go + Templ + HTMX)
- **internal/web/handlers/report.go** — Report editor handlers: editor page, reclassify, edit, delete, preview, generate with polling
- **internal/web/handlers/auth.go** — Slack OAuth login/callback/logout handlers
- **internal/web/middleware/auth.go** — Session cookie auth middleware, role derivation per-request via `IsManagerID()`
- **internal/web/deps.go** — Package-level function vars wrapping sqlite/report/config calls (same pattern as slack/deps.go)
- **internal/web/auth.go** — HMAC-signed session cookie create/validate, OAuth state generation
- **internal/web/templates/*.templ** — Templ templates: layout, login, report editor, item rows, section groups, preview, edit form

## Key Flows

Expand Down
4 changes: 4 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
:443 {
tls /certs/cert.pem /certs/key.pem
reverse_proxy reportbot:8088
}
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ WORKDIR /app
COPY --from=builder /app/reportbot .
RUN mkdir -p /app/reports

EXPOSE 8088

ENTRYPOINT ["./reportbot"]
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ report_channel_id: "C01234567"
external_http_timeout_seconds: 90 # optional: timeout for GitLab/GitHub/LLM HTTP calls
tls_skip_verify: false # optional: skip TLS cert verification for internal/corporate CAs

# Web UI (optional)
web_enabled: false
web_port: 8080
web_client_id: "" # Slack OAuth client ID
web_client_secret: "" # Slack OAuth client secret
web_session_secret: "" # Random secret for cookie signing (openssl rand -hex 32)
web_base_url: "http://localhost:8080"
```

Set `CONFIG_PATH` env var to load from a different path (default: `./config.yaml`).
Expand Down Expand Up @@ -206,6 +213,12 @@ export REPORT_CHANNEL_ID=C01234567
export EXTERNAL_HTTP_TIMEOUT_SECONDS=90 # Optional: timeout for external API HTTP calls
export TLS_SKIP_VERIFY=true # Optional: skip TLS cert verification
export AUTO_FETCH_SCHEDULE="0 9 * * 1-5" # Optional: cron schedule for auto-fetch
export WEB_ENABLED=true # Optional: enable web UI
export WEB_PORT=8080
export WEB_CLIENT_ID=your-slack-client-id # Slack OAuth client ID
export WEB_CLIENT_SECRET=your-slack-client-secret
export WEB_SESSION_SECRET=$(openssl rand -hex 32)
export WEB_BASE_URL=http://localhost:8080
export MONDAY_CUTOFF_TIME=12:00
export TIMEZONE=America/Los_Angeles
```
Expand All @@ -227,6 +240,7 @@ Set `llm_critic_enabled` / `LLM_CRITIC_ENABLED` to enable a second LLM pass that
Set `openai_base_url` / `OPENAI_BASE_URL` when `llm_provider=openai` and you want to use an OpenAI-compatible endpoint instead of `api.openai.com` (for example a lab-hosted `gpt-oss-120b` server).
Set `external_http_timeout_seconds` / `EXTERNAL_HTTP_TIMEOUT_SECONDS` to tune timeout limits for GitLab/GitHub/LLM API requests.
Set `tls_skip_verify` / `TLS_SKIP_VERIFY` to skip TLS certificate verification when connecting to internal or corporate API servers with self-signed or internal CA certificates.
Set `web_enabled` / `WEB_ENABLED` to serve the report editor web UI alongside the Slack bot. Configure `web_client_id`, `web_client_secret` with your Slack app's OAuth credentials and set `web_session_secret` to a random 32-byte hex string for cookie signing. The web UI uses Slack OAuth for authentication and the same `manager_slack_ids` for permissions.

Glossary example (`llm_glossary.yaml`):

Expand Down Expand Up @@ -293,6 +307,41 @@ docker run -d --name reportbot \

The volume persists the SQLite database and generated reports across restarts.

#### Option C: Docker Compose with Web UI (HTTPS via Caddy)

For running the web report editor alongside the Slack bot, use Docker Compose with
the included Caddy reverse proxy for automatic HTTPS (self-signed cert, works with
IP addresses on internal networks):

```bash
# 1. Set environment variables
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_APP_TOKEN=xapp-...
export GITLAB_TOKEN=glpat-...
export OPENAI_API_KEY=sk-...
export WEB_HOST=https://192.168.1.100 # your server IP or domain
export WEB_CLIENT_SECRET=your-slack-secret # from Slack app Basic Information
export WEB_SESSION_SECRET=$(openssl rand -hex 32)

# 2. Configure config.yaml with web settings
# web_enabled: true
# web_port: 8088
# web_client_id: "your-slack-client-id"
# web_base_url: "https://192.168.1.100"

# 3. Add OAuth redirect URL in Slack app settings:
# https://192.168.1.100/auth/slack/callback

# 4. Build and run
docker build -t reportbot .
docker-compose --project-name reportbot up -d
```

Caddy handles TLS termination with a self-signed certificate (no internet or domain
required). Your browser will show a certificate warning on first visit — accept it
once. The Slack OAuth flow works because the redirect happens in the browser, not
server-to-server.

## Usage

### Reporting Work Items
Expand Down
11 changes: 11 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,14 @@ timezone: "America/Los_Angeles"

# Team name used in report titles and filenames
team_name: "My Team"

# Web UI (optional — set web_enabled: true to serve the report editor at localhost:8080)
web_enabled: false
web_port: 8080
# Slack OAuth credentials for "Sign in with Slack" (from your Slack app settings)
web_client_id: ""
web_client_secret: ""
# Random secret for signing session cookies (generate with: openssl rand -hex 32)
web_session_secret: ""
# Base URL for OAuth redirect (must match the redirect URL in Slack app settings)
web_base_url: "http://localhost:8080"
24 changes: 24 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
# export SLACK_APP_TOKEN=xxxx
# export GITLAB_TOKEN=xxxx
# export OPENAI_API_KEY=xxxx
# export WEB_HOST=https://192.168.1.100 # IP or domain for HTTPS
# export WEB_CLIENT_SECRET=xxxx # Slack OAuth client secret
# export WEB_SESSION_SECRET=xxxx # openssl rand -hex 32
#
# docker-compose --project-name reportbot up
# docker-compose --project-name reportbot down
#
Expand All @@ -12,17 +16,37 @@
# - ./my-custom-config.yaml:/app/config.yaml:ro
# - ./my-custom-glossary.yaml:/app/llm_glossary.yaml
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./certs:/certs:ro
- caddy-data:/data
- caddy-config:/config

reportbot:
container_name: reportbot
image: reportbot
expose:
- "8088"
environment:
SLACK_BOT_TOKEN: "${SLACK_BOT_TOKEN}"
SLACK_APP_TOKEN: "${SLACK_APP_TOKEN}"
GITLAB_TOKEN: "${GITLAB_TOKEN}"
OPENAI_API_KEY: "${OPENAI_API_KEY}"
WEB_CLIENT_SECRET: "${WEB_CLIENT_SECRET}"
WEB_SESSION_SECRET: "${WEB_SESSION_SECRET}"
volumes:
- ./reportbot-data:/app/data
- ./reportbot-reports:/app/reports
- ./llm_glossary.yaml:/app/llm_glossary.yaml
- ./llm_classification_guide.md:/app/llm_classification_guide.md
- ./config.yaml:/app/config.yaml:ro

volumes:
caddy-data:
caddy-config:
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ require (
)

require (
github.com/a-h/templ v0.3.1001 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/gorilla/csrf v1.7.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/anthropics/anthropic-sdk-go v1.22.0 h1:sgo4Ob5pC5InKCi/5Ukn5t9EjPJ7KTMaKm5beOYt6rM=
github.com/anthropics/anthropic-sdk-go v1.22.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
Expand Down
46 changes: 45 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
package app

import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"reportbot/internal/config"
"reportbot/internal/fetch"
"reportbot/internal/httpx"
slackbot "reportbot/internal/integrations/slack"
"reportbot/internal/nudge"
"reportbot/internal/storage/sqlite"
"reportbot/internal/web/handlers"

"github.com/slack-go/slack"
)
Expand Down Expand Up @@ -38,7 +47,9 @@ func Main() {
log.Printf("Database initialized at %s", cfg.DBPath)
defer db.Close()

os.MkdirAll(cfg.ReportOutputDir, 0755)
if err := os.MkdirAll(cfg.ReportOutputDir, 0755); err != nil {
log.Fatalf("Failed to create report output directory %s: %v", cfg.ReportOutputDir, err)
}
log.Printf("Report output dir: %s", cfg.ReportOutputDir)

api := slack.New(
Expand All @@ -49,6 +60,39 @@ func Main() {
nudge.StartNudgeScheduler(cfg, db, api)
fetch.StartAutoFetchScheduler(cfg, db, api)

// Start web UI if enabled — verify bind before proceeding
var webSrv *http.Server
if cfg.WebEnabled {
webSrv = handlers.NewServer(cfg, db)
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WebPort))
if err != nil {
log.Fatalf("Web server bind failed on port %d: %v", cfg.WebPort, err)
}
log.Printf("Web UI listening on :%d", cfg.WebPort)
go func() {
if err := webSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("Web server error: %v", err)
}
}()
}

// App-level signal handler for graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
log.Println("Shutting down...")
if webSrv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := webSrv.Shutdown(ctx); err != nil {
log.Printf("Web server shutdown error: %v", err)
}
}
db.Close()
os.Exit(0)
}()

log.Println("Starting Engineering Report Bot...")
if err := slackbot.StartSlackBot(cfg, db, api); err != nil {
log.Fatalf("Slack bot error: %v", err)
Expand Down
32 changes: 32 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ type Config struct {
Timezone string `yaml:"timezone"`
TeamName string `yaml:"team_name"`

// Web UI
WebEnabled bool `yaml:"web_enabled"`
WebPort int `yaml:"web_port"`
WebClientID string `yaml:"web_client_id"`
WebClientSecret string `yaml:"web_client_secret"`
WebSessionSecret string `yaml:"web_session_secret"`
WebBaseURL string `yaml:"web_base_url"`

Location *time.Location `yaml:"-"` // computed from Timezone, not from YAML
}

Expand Down Expand Up @@ -115,6 +123,12 @@ func LoadConfig() Config {
envOverride(&cfg.AutoFetchSchedule, "AUTO_FETCH_SCHEDULE")
envOverride(&cfg.MondayCutoffTime, "MONDAY_CUTOFF_TIME")
envOverride(&cfg.Timezone, "TIMEZONE")
envOverrideBool(&cfg.WebEnabled, "WEB_ENABLED")
envOverrideInt(&cfg.WebPort, "WEB_PORT")
envOverride(&cfg.WebClientID, "WEB_CLIENT_ID")
envOverride(&cfg.WebClientSecret, "WEB_CLIENT_SECRET")
envOverride(&cfg.WebSessionSecret, "WEB_SESSION_SECRET")
envOverride(&cfg.WebBaseURL, "WEB_BASE_URL")

if ids := os.Getenv("MANAGER_SLACK_IDS"); ids != "" {
cfg.ManagerSlackIDs = nil
Expand Down Expand Up @@ -171,6 +185,12 @@ func LoadConfig() Config {
if cfg.TeamName == "" {
cfg.TeamName = "My Team"
}
if cfg.WebPort == 0 {
cfg.WebPort = 8080
}
if cfg.WebBaseURL == "" {
cfg.WebBaseURL = fmt.Sprintf("http://localhost:%d", cfg.WebPort)
}
if cfg.Timezone == "" {
cfg.Timezone = "Local"
}
Expand Down Expand Up @@ -225,6 +245,18 @@ func LoadConfig() Config {
log.Fatalf("llm_provider must be 'anthropic' or 'openai', got '%s'", cfg.LLMProvider)
}

if cfg.WebEnabled {
if cfg.WebSessionSecret == "" {
log.Fatalf("web_session_secret is required when web_enabled=true (generate with: openssl rand -hex 32)")
}
if cfg.WebClientID == "" {
log.Fatalf("web_client_id is required when web_enabled=true")
}
if cfg.WebClientSecret == "" {
log.Fatalf("web_client_secret is required when web_enabled=true")
}
}

if strings.EqualFold(cfg.Timezone, "Local") {
cfg.Location = time.Local
} else {
Expand Down
Loading
Loading