fix(leave): return the created entry from leave create - #73
Merged
Conversation
POST /api/leave/ answers with an empty body, so `tp leave create --json` and the
MCP `CreateLeave` tool both reported a bare `{"success":true}` and callers had to
run `leave list` to find the id.
`LeaveCreateService.ApplyAsync` now re-reads the entry through
`LeaveLookup.FindCreatedAsync` (UPCOMING then PAST for the requesting employee,
matched on start date, end date, leave type and note) and returns
`{"success":true,"leaveId":"<guid>","leave":{...}}`. A missing or ambiguous match
still reports success, with `leave: null` and a warning whose recovery is
`tp leave list` - a second create would duplicate the request. A failed read-back
is never allowed to turn a successful write into an error. Dry-run is untouched:
same validation, same proposed request, no POST.
MCP adopts the same document. Under the wire-shape policy this is a permitted
exception: the only committed `create_leave` goldens were the dry-run preview and
the API-error payload, so no client-visible create result is being replaced -
`Goldens/Mcp/Tools/CreateLeave.created.json` is a new snapshot, generated with
UPDATE_MCP_GOLDENS=1 and reviewed. The new parity row runs both surfaces and
asserts exactly one POST followed by a GET and no PUT; it stays
`ExpectParity = false` only because the CLI omits nulls that MCP writes, the same
declared difference as the timesheet write rows. It is appended to the table
because parity goldens are named by row index.
Review follow-ups: the read-back now swallows every failure except the caller's own
cancellation (a `TimeProConnectionException` previously escaped and made a completed
write look failed, inviting a duplicate); matching also requires the all-day flag and,
for a partial day, the slot to the minute, so two requests on one day cannot be
confused; and the recovery names `tp leave list --filter ALL`, which is correct for
past dates too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jernejk
force-pushed
the
fix/leave-create-returns-entry
branch
from
September 12, 2026 15:19
981310f to
c2cbec2
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved findings remain in matching, connection-failure handling, warning guidance, and test coverage.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Updates leave creation so the CLI and MCP tool return the newly created leave after the API’s empty POST response.
Changes:
- Adds shared result handling and paginated read-back lookup.
- Updates CLI/MCP output while preserving dry-run behavior.
- Adds tests, parity coverage, goldens, and documentation.
The review identified unresolved concerns around cross-filter matching, connection-failure handling, warning guidance, and pagination/date-field coverage.
File summaries
| File | Description |
|---|---|
tests/SSW.TimePro.Cli.Tests/Features/Mcp/LeaveMcpToolsTests.cs |
Updates MCP leave tool test wiring. |
tests/SSW.TimePro.Cli.Tests/Features/Leave/LeaveCreateServiceTests.cs |
Tests create results and read-back outcomes. |
tests/SSW.TimePro.Cli.Tests/Features/Leave/CreateCommandTests.cs |
Tests CLI JSON and dry-run behavior. |
tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs |
Updates integration dependency wiring. |
tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs |
Adds successful create coverage. |
tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs |
Adds CLI/MCP parity coverage. |
tests/SSW.TimePro.Cli.Integration/Mcp/LeaveCreateReadbackTests.cs |
Verifies read-back traffic and no duplicate writes. |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateLeave.created.json |
Adds the created-leave MCP golden. |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.15.mcp.json |
Adds the MCP parity golden. |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateLeave.15.cli.json |
Adds the CLI parity golden. |
src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs |
Serializes the shared create result. |
src/SSW.TimePro.Cli/Features/Leave/LeaveLookup.cs |
Finds created leaves across filters and pages. |
src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs |
Performs creation and read-back result handling. |
src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs |
Outputs the created ID and warnings. |
AGENTS.md |
Documents the create/read-back contract. |
Review details
Suppressed comments (2)
src/SSW.TimePro.Cli/Features/Leave/LeaveLookup.cs:92
- The new offset-free date preference is not exercised by the added tests: all fixtures leave
StartDateLocalandEndDateLocalnull, so only the fallback fields are tested. Add a case with the server's*DateWithoutOffsetvalues populated (and differing from the offset date) to protect the real-server matching path.
private static bool Matches(LeaveEntry entry, CreateLeaveRequest request) =>
SameDay(entry.StartDateLocal ?? entry.StartDate, request.StartDate)
&& SameDay(entry.EndDateLocal ?? entry.EndDate, request.EndDate)
&& entry.LeaveType?.Id == request.LeaveTypeId
&& string.Equals(entry.Note?.Trim() ?? "", request.Note?.Trim() ?? "", StringComparison.Ordinal);
src/SSW.TimePro.Cli/Features/Leave/LeaveLookup.cs:61
- A network failure during this read-back is raised as
TimeProConnectionExceptionbyTimeProApiClient, notApiException. In that case the POST has already succeeded but the exception escapesApplyAsync, so the CLI/MCP reports a failed create instead of the documented success-with-warning outcome; catch connection failures here as well while preserving cancellation behavior.
catch (ApiException)
{
// The create already succeeded; a failed read-back must not turn it into an error.
}
- Files reviewed: 15/15 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return new LeaveCreateResult | ||
| { | ||
| Warning = $"Leave request created, but the new entry could not be identified ({count}). " | ||
| + "Find it with: tp leave list --filter UPCOMING. Do not create it again." |
Comment on lines
+51
to
+56
| foreach (var filter in new[] { LeaveListService.Upcoming, LeaveListService.Past }) | ||
| { | ||
| var matches = await MatchesAsync(filter, request, ct); | ||
| if (matches.Count > 0) | ||
| return matches; | ||
| } |
Comment on lines
+74
to
+84
| while (true) | ||
| { | ||
| var response = await _api.GetLeaveAsync(filter, pageNumber, PageSize, request.RequestedEmpId, ct); | ||
| var page = response?.Leaves; | ||
| if (page is not null) | ||
| matches.AddRange(page.Items.Where(entry => Matches(entry, request))); | ||
|
|
||
| if (page is null || pageNumber >= page.TotalPages) | ||
| return matches; | ||
|
|
||
| pageNumber++; |
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tp leave create --jsonand the MCPCreateLeavetool both answered a bare{"success":true}, becausePOST /api/leave/returns an empty body. Both now re-read the created entry and return it.Closes #48
What changed
LeaveCreateService.ApplyAsyncreturns aLeaveCreateResult(success,leaveId,leave,warning). It calls the newLeaveLookup.FindCreatedAsync, which pagesUPCOMINGthenPASTfor the requesting employee and matches on start date, end date, leave type and note.startDateWithoutOffset/endDateWithoutOffsetvalues and otherwise compare instants shifted into the offset the request was built in.success: truewithleave: nulland awarningpointing attp leave list --filter ALL— never a second create, which would duplicate the request. The read-back swallows every failure except the caller's own cancellation: a completed write is reported as a success, because surfacing it as an error is what invites the duplicate.--json:{"success":true,"leaveId":"<guid>","leave":{...}}(additive;successstays first). Human output prints the id, and the warning goes to stderr.CreateLeaveserialises the same result. Dry-run is untouched on both surfaces: same validation, same proposed request, no POST.Wire-shape decision
The only committed
create_leavegoldens wereCreateLeave.dryRun.jsonandCreateLeave.apiError.json— there was no snapshot of a successful create, so nothing client-visible is being replaced. Per §2 of the wire-shape policy this is the permitted case for adopting the CLI's saved-entry document, soGoldens/Mcp/Tools/CreateLeave.created.jsonis a new snapshot, generated deliberately withUPDATE_MCP_GOLDENS=1and reviewed.The new parity row (
CreateLeave/leave create, real write) asserts exactly onePOST /api/leave/followed by aGET /api/leave/, and forbidsPUT /api/leave/. It staysExpectParity = falsewith five permitted paths only because the CLI omits nulls that MCP writes (cancellationReason,startDateWithoutOffset,endDateWithoutOffset,timeLessOverride, pluswarning) — the same declared difference as the existing timesheet write rows. The row is appended to the table because parity goldens are named by row index; inserting it next to the other leave rows would rename every snapshot below it.How verified
By execution:
dotnet test tests/SSW.TimePro.Cli.Tests/→ 583 passed, 0 failed (+15: thirteenLeaveCreateServiceTests— match, PAST-filter match, no match, ambiguous, read-back failing withApiException/TimeProConnectionException/ a generic exception, offset-free dates preferred when the offset dates fall on another day, morning-vs-afternoon partial days resolving correctly, same-slot partial days staying ambiguous, an all-day request refusing a partial-day entry, a server echoing another offset still matching, prepare-writes-nothing — and twoCreateCommandTestsfor the--jsonshape and for dry-run writing nothing and never reading back).dotnet test tests/SSW.TimePro.Cli.Integration/→ 292 passed, 0 failed (+5: threeLeaveCreateReadbackTestscovering both surfaces, the new tool golden case, the new parity row).ApplyAsync, 3 integration and 4 unit tests fail. With the all-day flag and the partial-day slot dropped from the matcher (dates only, offset values only), 4 unit tests fail. Restored after each, both suites green.The partial-day path was dry-run on staging too (
--half-day --start-time 13:00 --end-time 17:00), returningallDay: falsewith the slot instartDate/endDate, which is the shape the new matcher compares.tp info --json --tenant ssw-stagingconfirmed the non-production host before both runs.By inspection only:
UPCOMINGpage it was submitted for, and how itsstartDateWithoutOffsetis rendered. The matcher prefers the offset-free dates when present and falls back to the date part of the offset dates, which the WireMock fixture exercises, but it has not been run against a real create.FindCreatedAsyncpages untilTotalPages) is unit-tested with a single page only.Risks
GET /api/leave/per create.🤖 Generated with Claude Code