- 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)
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.goregisters 14 issue tools; none touch tracked time or stopwatches.forgejo-sdk/v3@v3.0.0provides:- Tracked time (
issue_tracked_time.go):AddTime,ListIssueTrackedTimes,ListRepoTrackedTimes,GetMyTrackedTimes,ResetIssueTime,DeleteTime. - Stopwatch (
issue_stopwatch.go):StartIssueStopWatch,StopIssueStopWatch,DeleteIssueStopwatch,GetMyStopwatches.
- Tracked time (
pkg/forgejo/forgejo.gosingleton 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
- Add
Float64Ok(v any) (float64, bool)topkg/to/number.go. - Unit test: returns
(0, false)fornil,(n, true)for numeric inputs,(0, false)for non-numeric.
Part 2 — operation/time/time.go
- Declare the 10 tool constants and
mcp.NewTool(...)definitions, matching the style inoperation/issue/issue.go. - Implement each handler as a thin SDK wrapper.
- Implement
RegisterTool(s *server.MCPServer)thatAddTools all 10.
Part 3 — Wire-up
- Add
time.RegisterTool(s)inoperation/operation.go. - Add new parameter descriptions in
pkg/params/params.go. - Add the 10 tool rows to the
README.mdtool table.
Part 4 — Tests
operation/time/time_test.gowith mock SDK calls for:secondsvsdurationvalidation branches (both, neither, negative, malformed).- RFC3339 parsing for
created_at. - Happy path for one
add+ onelist+ onedeleteto 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:
-
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_timeswith--sincefilter for completeness.- Close scratch issue.
- Create scratch issue (
-
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_timesconfirms no new entry. list_my_stopwatches→ empty.- Close scratch issue.
Deliverables
operation/time/time.go+ tests.pkg/to/number.goupdated withFloat64Okhelper + tests.pkg/params/params.goupdated with newTime*constants.operation/operation.goregisterstimetools.README.mdtool table updated.demos/issue-time-tracking.md.demos/issue-stopwatch.md.- CHANGELOG entry under next version.
Acceptance Criteria
./forgejo-mcp --cli add_issue_time --owner goern --repo forgejo-mcp --index 106 --duration 15mreturns aTrackedTimeJSON withtime: 900../forgejo-mcp --cli add_issue_time … --seconds 1800same shape.- Supplying both
secondsandduration, or neither, or a negative value, returns a structured error — not a 500 from the API. list_issue_tracked_timesreturns entries for both issues and PRs (round-tripped by passing a PR index).delete_issue_time_entryremoves exactly one entry;reset_issue_timeremoves all.- Stopwatch
start → stopproduces a tracked-time entry visible vialist_issue_tracked_times;start → cancelproduces none. list_my_stopwatchesreflects currently running stopwatches accurately.make buildandgo test ./...pass.- Both demo files run cleanly end-to-end against a live Forgejo (manual verification).
Open Questions
- Admin-only
user_nameonadd_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). - Stopwatch
stopresponse: the SDK returns only*Response, not the createdTrackedTime. Demo recovers the new entry vialist_issue_tracked_times. Not a blocker, just noted. list_my_tracked_timespagination: 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_timedeletes 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.Userconsistently onlist_issue_tracked_times(SDK allows it on repo-level but not issue-level per theUser 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.