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

14 KiB

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

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:

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 AddTools 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.