fix: route pull-request mapping through ToHostValue - #110
Merged
Merged
Conversation
Both providers' pull-request mappers called .As<T>() directly on every host-reported field, violating the invariant that a field a host reported goes through GitProvider.ToHostValue. Because a public hosting method's documented failure surface is the GitHostingException hierarchy, that raised ArgumentException out of GetPullRequestsAsync and CreatePullRequestAsync, escaping past every catch (GitHostingException) a caller wrote. The `?? string.Empty` fallbacks on the Azure DevOps side made it worse rather than softer: GitPullRequestTitle and GitBranchName both carry HasNonWhitespaceContent, so the fallback converted "the host omitted this" into a guaranteed throw of the wrong exception type, on data AzureDevOpsPullRequest.Title itself declares optional. Octokit's side is the same shape for a different reason: its model types are mutable classes with unannotated string members deserialized straight from the response, so a field GitHub omits arrives null however non-nullable the property looks — PullRequest.Head and .Base are now dereferenced conditionally too. Adds GitProvider.ToRequiredHostValue for the four fields GitPullRequest declares required. Number, Title, SourceBranch and TargetBranch stay required and a host omitting one raises GitHostingRequestException; relaxing them to nullable was considered and rejected, because it would push a null check onto every caller for a case no host produces and would turn a host contract violation into a half-populated record that reads as valid. Author and WebURI stay optional and go through ToHostValue. Fixes #104 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1jqeHWdrdhAoKbN9frz4h
|
Contributor
Author
|
On SonarCloud's "8 New issues" above — all eight are They are two suggestions, both flagging conventions these files already use exclusively:
Changing only the new lines would leave these two files the only mixed ones in the suite, which is worse than the smell. Both are worth doing as a suite-wide sweep in their own change if the newer idioms are wanted — not worth widening this PR for. Generated by Claude Code |
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.



Fixes #104
What was wrong
Both providers' pull-request mappers called
.As<T>()directly on every host-reported field, violating the invariantCLAUDE.mdstates as "A field a host reported goes throughGitProvider.ToHostValue, never throughAs<T>()directly." A public hosting method's documented failure surface is theGitHostingExceptionhierarchy, so this raisedArgumentExceptionout ofGetPullRequestsAsyncandCreatePullRequestAsync, escaping past everycatch (GitHostingException)a caller wrote. The siblingToGitRepositoryin each file was already correct, so the gap was specific to the pull-request path.The
?? string.Emptyfallbacks on the Azure DevOps side made it worse rather than softer.GitPullRequestTitleandGitBranchNameboth carry[HasNonWhitespaceContent], so the fallback converted "the host omitted this" into a guaranteed throw of the wrong exception type — on dataAzureDevOpsPullRequest.Titleitself declares optional ("title": null, or a service-principalIdentityRefwhoseuniqueNameis empty).Octokit's side is the same shape for a different reason: its model types are mutable classes with unannotated
stringmembers deserialized straight from the response, so a field GitHub omits arrivesnullhowever non-nullable the property looks.PullRequest.Headand.Baseare now dereferenced conditionally for that reason.The
required-vs-nullable decisionThe issue flagged this as needing a deliberate answer.
Number,Title,SourceBranchandTargetBranchstayrequired, and a host that omits one raisesGitHostingRequestException.Both hosts document all four as part of what a pull request is, and neither has been observed to omit one; Azure DevOps's DTO declares them
string?only because it mirrors the wire shape defensively. Relaxing the model would push a null check onto every caller for a case no host produces, and would turn a host contract violation into a half-populated record that reads as valid.GitProvider.ToRequiredHostValueis the new wrapper that keeps the distinction —ToHostValuefor the genuinely optional fields (Author,WebURI, and everyGitRepositoryfield),ToRequiredHostValuefor these four. No public API shape changes.Changes
GitProvider.ToRequiredHostValue<TSemantic>—ToHostValueplus aGitHostingRequestExceptionwhen the host reported nothing.AzureDevOpsProvider.ToGitPullRequest— every field routed;?? string.Emptyfallbacks dropped.StripRefsHeadsPrefixnow passesnullthrough instead of having an empty string substituted before it, so an omitted ref stays distinguishable from a reported one.GitHubProvider.ToGitPullRequest— every field routed;Head/Basedereferenced conditionally. Now an instance method, sinceToHostValueneedsName.CLAUDE.md— records the decision and why the?? string.Emptyform was not a softening.Verification
GitIntegration.Testpasses in full: 588 / 588, 0 failures.The three new throw-asserting tests were confirmed to fail against the unmodified source — reverting only the three source files and re-running leaves the new tests as the only failures, with exactly the wrong exception types the issue describes:
Assert.ThrowsExactlyis doing the work here: it fails on theArgumentExceptionthe old mapping raised, so these tests cannot pass by accident.Two further tests pin the other side of the rule —
Authorstays optional, so a host reporting no identity at all yieldsnullrather than an exception. Without them, routingAuthorthroughToHostValuecould be "fixed" by making every field required and nothing would object.New fixture cases are substitutions over the existing captured payloads (one field varied, every other key exactly as captured), matching how
SinglePullRequestListResponse/SinglePullRequestArrayalready work. No existing test or fixture was altered.Note:
dotnet testreports "Zero tests ran / exit code 5" in this environment, which is unrelated to this change — running the built test executable directly works and is how the numbers above were produced.🤖 Generated with Claude Code
https://claude.ai/code/session_01C1jqeHWdrdhAoKbN9frz4h
Generated by Claude Code