forgejo-mcp/docs/plans/issue-time-tracking.md
Christoph Görn 3d9fd1af50
docs: 📝 add issue/PR time tracking spec
- docs/plans/issue-time-tracking.md: 10 new MCP tools wrapping
  forgejo-sdk/v3 tracked-time and stopwatch APIs (no raw HTTP needed,
  SDK has full coverage)
- Verbs mirror API primitives (no set_issue_time convenience wrapper,
  to avoid non-atomic reset+add on shared state)
- add_issue_time accepts either seconds (int) or duration string
  (parsed via stdlib time.ParseDuration)
- Two Showboat demos planned: issue-time-tracking + issue-stopwatch
- Track in beads (forgejo-mcp-ya6)
2026-04-21 22:40:05 +02:00

257 lines
14 KiB
Markdown

# Issue & PR Time Tracking — Implementation Plan
**Beads**: `forgejo-mcp-ya6`
**Branch**: `feature/issue-time-tracking` *(suggested)*
## Summary
Add MCP tools that expose Forgejo's **tracked time** and **stopwatch** APIs for issues and pull requests. Currently the server has no way to read, add, or remove logged time — agents cannot account for the work they do and users cannot query an issue's time ledger through the MCP.
Unlike the attachments work (see `issue-attachments.md`), **`forgejo-sdk/v3@v3.0.0` ships complete method coverage** for both APIs. No raw HTTP helper is needed; this spec is a straightforward SDK-wrapping job.
## Use Case
> As an agent or user working through `forgejo-mcp`, I want to read the time ledger of any issue or PR, log time against it, clear or remove individual entries, and drive a live stopwatch for timing in-progress work. I want to do this with one set of tools that works transparently for both issues and PRs, because Forgejo shares index space between them.
## Current State
- `operation/issue/issue.go` registers 14 issue tools; none touch tracked time or stopwatches.
- `forgejo-sdk/v3@v3.0.0` provides:
- **Tracked time** (`issue_tracked_time.go`): `AddTime`, `ListIssueTrackedTimes`, `ListRepoTrackedTimes`, `GetMyTrackedTimes`, `ResetIssueTime`, `DeleteTime`.
- **Stopwatch** (`issue_stopwatch.go`): `StartIssueStopWatch`, `StopIssueStopWatch`, `DeleteIssueStopwatch`, `GetMyStopwatches`.
- `pkg/forgejo/forgejo.go` singleton client is already suitable — nothing new needed at the client layer.
## Key Design Decisions
### 1. SDK-wrapping, not raw HTTP
The SDK covers every call. No `rawhttp` helper. Handlers are thin: parse args → call `forgejo.Client().X(...)` → `to.TextResult`.
### 2. Verbs mirror API primitives (no `set_issue_time`)
The Forgejo API has **no "set total" operation**. To change the total for a user on an issue you must reset + add, and `ResetIssueTime` wipes **all** entries including other users'. We deliberately do **not** expose a `set_issue_time` convenience wrapper: the composite is non-atomic (another user could log time between reset and add) and destructive to other users' history. Agents needing "set" semantics compose `reset_issue_time` + `add_issue_time` themselves and accept the risk explicitly.
### 3. One tool set for issues **and** PRs
Forgejo's issue index namespace includes PRs (a PR is just an issue with a PR payload attached). Every tracked-time and stopwatch endpoint is `/repos/{owner}/{repo}/issues/{index}/…`, so a PR index works identically to an issue index. Every tool description must literally state **"issue or pull request index"** so agents don't go looking for a `pr_*` variant.
### 4. Accept both `seconds` and `duration` for `add_issue_time`
The API takes integer seconds. Agents naturally think in minutes or hours. We accept **either** `seconds` (number) **or** `duration` (string parsed via `time.ParseDuration`, e.g. `"15m"`, `"1h30m"`). Exactly one must be provided. Negative values rejected. Parsing uses stdlib only; no extra dependency.
## Target State
### 10 New MCP Tools
**Tracked time (6):**
| Tool | HTTP | SDK method | Returns |
|------|------|------------|---------|
| `list_issue_tracked_times` | GET `/repos/{o}/{r}/issues/{index}/times` | `ListIssueTrackedTimes` | `[]TrackedTime` |
| `list_repo_tracked_times` | GET `/repos/{o}/{r}/times` | `ListRepoTrackedTimes` | `[]TrackedTime` |
| `list_my_tracked_times` | GET `/user/times` | `GetMyTrackedTimes` | `[]TrackedTime` |
| `add_issue_time` | POST `/repos/{o}/{r}/issues/{index}/times` | `AddTime` | `TrackedTime` |
| `reset_issue_time` | DELETE `/repos/{o}/{r}/issues/{index}/times` | `ResetIssueTime` | `"Reset tracked time success"` |
| `delete_issue_time_entry` | DELETE `/repos/{o}/{r}/issues/{index}/times/{id}` | `DeleteTime` | `"Delete time entry success"` |
**Stopwatches (4):**
| Tool | HTTP | SDK method | Returns |
|------|------|------------|---------|
| `start_issue_stopwatch` | POST `/repos/{o}/{r}/issues/{index}/stopwatch/start` | `StartIssueStopWatch` | `"Stopwatch started"` |
| `stop_issue_stopwatch` | POST `/repos/{o}/{r}/issues/{index}/stopwatch/stop` | `StopIssueStopWatch` | `"Stopwatch stopped; elapsed time recorded"` |
| `cancel_issue_stopwatch` | DELETE `/repos/{o}/{r}/issues/{index}/stopwatch/delete` | `DeleteIssueStopwatch` | `"Stopwatch cancelled"` |
| `list_my_stopwatches` | GET `/user/stopwatches` | `GetMyStopwatches` | `[]StopWatch` |
### Tool Parameter Schemas
Shared parameters (added to `pkg/params/params.go`):
```go
TimeID = "Tracked time entry ID"
TimeSeconds = "Time in seconds to log (positive integer). Provide exactly one of seconds or duration."
TimeDuration = "Time to log as a duration string, e.g. \"15m\", \"1h30m\", \"45s\". Provide exactly one of seconds or duration."
TimeCreatedAt = "Optional RFC3339 timestamp for when the work happened (defaults to server time)"
TimeUserName = "Optional username to log time on behalf of (requires admin; omit for self)"
TimeUserFilter = "Filter results to this username (list_repo_tracked_times only)"
```
Reuse existing `params.Since` and `params.Before` for RFC3339 time filters on list tools (same pattern as `list_issue_comments`).
Parameter matrix:
| Tool | `owner` | `repo` | `index` | `time_id` | `seconds` | `duration` | `created_at` | `user_name` | `since` | `before` | `user` |
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `list_issue_tracked_times` | ✓ | ✓ | ✓ | | | | | | opt | opt | — |
| `list_repo_tracked_times` | ✓ | ✓ | | | | | | | opt | opt | opt |
| `list_my_tracked_times` | | | | | | | | | | | |
| `add_issue_time` | ✓ | ✓ | ✓ | | *a* | *a* | opt | opt | | | |
| `reset_issue_time` | ✓ | ✓ | ✓ | | | | | | | | |
| `delete_issue_time_entry` | ✓ | ✓ | ✓ | ✓ | | | | | | | |
| `start_issue_stopwatch` | ✓ | ✓ | ✓ | | | | | | | | |
| `stop_issue_stopwatch` | ✓ | ✓ | ✓ | | | | | | | | |
| `cancel_issue_stopwatch` | ✓ | ✓ | ✓ | | | | | | | | |
| `list_my_stopwatches` | | | | | | | | | | | |
*a* = exactly one of `seconds` or `duration` is required.
The `user` filter is only honored by the repo-level list endpoint; the SDK documents this explicitly (`// User filter is only used by ListRepoTrackedTimes !!!`). We do not expose it on `list_issue_tracked_times` to avoid a silently-ignored parameter that would mislead agents.
### Architecture
```
operation/
├─ issue/ (existing — untouched)
├─ attachment/ (proposed in issue-attachments.md)
└─ time/ ← NEW
└─ time.go (10 tools + handlers)
```
Handlers follow the style used in `operation/issue/issue.go`. Example:
```go
func AddIssueTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, _ := to.Float64(req.GetArguments()["index"])
secsF, hasSecs := to.Float64Ok(req.GetArguments()["seconds"])
durStr, hasDur := req.GetArguments()["duration"].(string)
var seconds int64
switch {
case hasSecs && hasDur:
return to.ErrorResult(fmt.Errorf("provide exactly one of 'seconds' or 'duration'"))
case hasSecs:
if secsF <= 0 {
return to.ErrorResult(fmt.Errorf("seconds must be positive"))
}
seconds = int64(secsF)
case hasDur:
d, err := time.ParseDuration(durStr)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid duration %q: %v", durStr, err))
}
if d <= 0 {
return to.ErrorResult(fmt.Errorf("duration must be positive"))
}
seconds = int64(d / time.Second)
default:
return to.ErrorResult(fmt.Errorf("one of 'seconds' or 'duration' is required"))
}
opt := forgejo_sdk.AddTimeOption{Time: seconds}
if u, ok := req.GetArguments()["user_name"].(string); ok && u != "" {
opt.User = u
}
if ts, ok := req.GetArguments()["created_at"].(string); ok && ts != "" {
parsed, err := time.Parse(time.RFC3339, ts)
if err != nil {
return to.ErrorResult(fmt.Errorf("invalid created_at (RFC3339): %v", err))
}
opt.Created = parsed
}
entry, _, err := forgejo.Client().AddTime(owner, repo, int64(index), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("add issue time err: %v", err))
}
return to.TextResult(entry)
}
```
A small helper `to.Float64Ok(any) (float64, bool)` is added to `pkg/to/number.go` since the current `Float64` returns `(float64, error)` without distinguishing "absent" from "malformed". This helper is required by `add_issue_time` to do the `hasSecs` check cleanly.
## Implementation Steps
### Part 1 — `pkg/to` helper
1. Add `Float64Ok(v any) (float64, bool)` to `pkg/to/number.go`.
2. Unit test: returns `(0, false)` for `nil`, `(n, true)` for numeric inputs, `(0, false)` for non-numeric.
### Part 2 — `operation/time/time.go`
1. Declare the 10 tool constants and `mcp.NewTool(...)` definitions, matching the style in `operation/issue/issue.go`.
2. Implement each handler as a thin SDK wrapper.
3. Implement `RegisterTool(s *server.MCPServer)` that `AddTool`s all 10.
### Part 3 — Wire-up
1. Add `time.RegisterTool(s)` in `operation/operation.go`.
2. Add new parameter descriptions in `pkg/params/params.go`.
3. Add the 10 tool rows to the `README.md` tool table.
### Part 4 — Tests
- `operation/time/time_test.go` with mock SDK calls for:
- `seconds` vs `duration` validation branches (both, neither, negative, malformed).
- RFC3339 parsing for `created_at`.
- Happy path for one `add` + one `list` + one `delete` to verify wiring.
- No live-API integration tests in CI.
### Part 5 — Demos (Showboat format)
Two files in `demos/` following the `issue-labels.md` format, including the `showboat-id` front-matter header:
1. **`demos/issue-time-tracking.md`** — end-to-end tracked-time lifecycle:
- Create scratch issue (`create_issue`).
- `add_issue_time --duration 15m` → shows returned entry.
- `add_issue_time --seconds 1800` → second entry.
- `list_issue_tracked_times` → shows both entries with IDs and user names.
- `delete_issue_time_entry --time_id <id>` → removes one.
- `list_issue_tracked_times` → confirms only one remains.
- `reset_issue_time` → wipes all.
- `list_issue_tracked_times` → empty.
- `list_my_tracked_times`, `list_repo_tracked_times` with `--since` filter for completeness.
- Close scratch issue.
2. **`demos/issue-stopwatch.md`** — end-to-end stopwatch lifecycle:
- Create scratch issue.
- `start_issue_stopwatch`.
- `list_my_stopwatches` → shows the running stopwatch.
- Short sleep (3 s, just enough to produce a nonzero entry).
- `stop_issue_stopwatch` → converts to tracked-time entry.
- `list_issue_tracked_times` → confirms ~3 s entry appears.
- Second run: `start` → `cancel_issue_stopwatch` → `list_issue_tracked_times` confirms no new entry.
- `list_my_stopwatches` → empty.
- Close scratch issue.
## Deliverables
- [ ] `operation/time/time.go` + tests.
- [ ] `pkg/to/number.go` updated with `Float64Ok` helper + tests.
- [ ] `pkg/params/params.go` updated with new `Time*` constants.
- [ ] `operation/operation.go` registers `time` tools.
- [ ] `README.md` tool table updated.
- [ ] `demos/issue-time-tracking.md`.
- [ ] `demos/issue-stopwatch.md`.
- [ ] CHANGELOG entry under next version.
## Acceptance Criteria
1. `./forgejo-mcp --cli add_issue_time --owner goern --repo forgejo-mcp --index 106 --duration 15m` returns a `TrackedTime` JSON with `time: 900`.
2. `./forgejo-mcp --cli add_issue_time … --seconds 1800` same shape.
3. Supplying both `seconds` and `duration`, or neither, or a negative value, returns a structured error — not a 500 from the API.
4. `list_issue_tracked_times` returns entries for both issues and PRs (round-tripped by passing a PR index).
5. `delete_issue_time_entry` removes exactly one entry; `reset_issue_time` removes all.
6. Stopwatch `start → stop` produces a tracked-time entry visible via `list_issue_tracked_times`; `start → cancel` produces none.
7. `list_my_stopwatches` reflects currently running stopwatches accurately.
8. `make build` and `go test ./...` pass.
9. Both demo files run cleanly end-to-end against a live Forgejo (manual verification).
## Open Questions
1. **Admin-only `user_name` on `add_issue_time`**: Forgejo restricts logging time on behalf of another user to admins. Expose the param with a clear description and let the API return 403 for non-admins (recommendation). Alternative: drop the param entirely until someone needs it (YAGNI).
2. **Stopwatch `stop` response**: the SDK returns only `*Response`, not the created `TrackedTime`. Demo recovers the new entry via `list_issue_tracked_times`. Not a blocker, just noted.
3. **`list_my_tracked_times` pagination**: the SDK method takes no options. If Forgejo paginates, the first page is all we get. Accept as a known limitation; revisit if SDK adds options.
## Risk Assessment
- **Low risk**: all 10 tools are additive; no existing tool changes behavior.
- **Low risk**: SDK-wrapping means breakage only if the SDK itself breaks, which is centrally addressed at migration time.
- **Medium-low risk**: `reset_issue_time` deletes **all** entries for the issue (including other users'). Tool description must state this explicitly to prevent accidental data loss. Recommend surfacing this in the tool description, not just the parameter docs.
## Follow-ups (not in this spec)
- Expose `ListTrackedTimesOptions.User` consistently on `list_issue_tracked_times` (SDK allows it on repo-level but not issue-level per the `User filter is only used by ListRepoTrackedTimes !!!` comment in the SDK source). File an upstream issue if we want issue-level filtering.
- Project-level time reports (sum across many issues) — out of scope here, would need a new aggregation tool.