Skip to content

CLI-1086 Propagate Result through the list projects command handler - #807

Merged
charafsalmi merged 17 commits into
masterfrom
task/cs/CLI-1086-propagate-result-list-projects
Sep 11, 2026
Merged

charafsalmi merged 17 commits into
masterfrom
task/cs/CLI-1086-propagate-result-list-projects

Conversation

@charafsalmi

@charafsalmi charafsalmi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

TL;DR

One command (sonar list projects) converted to the Result-propagation shape CLI-1086 describes, so the shape can be argued over before the other ~40 handlers follow it. No behaviour change: exit codes stay 2 for a bad option and 1 for an API failure, both asserted by the integration spec. Also adds the toBeOkWith()/toBeErrWith() matchers the ticket asks for, used by this command's own tests.

What to challenge

The handler's error channel is Error, not a narrower union and not CliError. No caller discriminates the members: the only consumer is authenticatedAction(), and runCommand() already maps any error to a message, an exit code and a hint. An earlier revision of this branch wrapped the domain failure in a CommandFailedError so the signature could read CliError; that wrap turned out to be a runtime no-op, and would have cost a ceremonial line in each of the other ~40 handlers.

The rail collapses in authenticatedAction(), not in runCommand() as the ticket sketches. It is the smaller step, and it stays reversible once more commands are converted.

Already settled, no need to re-raise

  • The ~85 other .orThrow()-based test assertions are deliberately not migrated. The ticket treats that as gradual.
  • neverthrow/must-use-result is scoped to the three converted files rather than repo-wide: the rule cannot recognise this repo's own orThrow() as consuming a Result, so a wider scope would report correct code as broken. The reasoning lives in eslint.config.js next to the rule.

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 9, 2026

Copy link
Copy Markdown

CLI-1086

Comment thread src/core/result.ts
Comment thread tests/_common/result-matchers.ts
Comment thread tests/_common/result-matchers.ts Outdated
Base automatically changed from task/cs/CLI-844-http-client-result-monad to master September 9, 2026 15:12
@charafsalmi
charafsalmi requested review from a team as code owners September 9, 2026 15:12
@netlify

netlify Bot commented Sep 9, 2026

Copy link
Copy Markdown

Deploy Preview for sonarqube-cli canceled.

Name Link
🔨 Latest commit e5822ef
🔍 Latest deploy log https://app.netlify.com/projects/sonarqube-cli/deploys/6aa4006b08060b0008c00773

Convert listProjects() to return ResultAsync<void, InvalidOptionError |
HttpClientError> instead of throwing internally, chaining validation and
the domain-client call with errAsync()/map() end to end. authenticatedAction()
now accepts either a plain Promise<void> handler (unchanged for every other
command) or a Result-returning one, collapsing the latter via match() at the
same single point runCommand()'s try/catch already handles thrown errors -
orThrow() can't be called directly on the resolved Ok<T, Error> | Err<T, Error>
union since TypeScript can't unify the two branches' polymorphic this.

Scoped to this one command on purpose, to see what the end-to-end shape looks
like before converting the other ~40 handlers CLI-1086 covers.
Registers a bun test preload (tests/_common/result-matchers.ts, wired in
bunfig.toml alongside the two existing preloads) so any spec can assert
directly on an already-resolved Result without unwrapping it by hand first -
.orThrow() throws inside the test, so a failure reports as an uncaught
exception instead of a clear expected/received diff.

Decoupled from migrating the ~85 other .orThrow()-based test call sites
CLI-1086 also covers: the ticket treats that migration as gradual ("where
doing so reads more clearly"), so this only adds the matchers and dogfoods
them in the one test file this PR already touches (projects-command.test.ts),
plus a small dedicated test for the matchers' own pass/fail logic.
- Reuse isResult() from src/core/result.ts instead of re-implementing an
  equivalent (and already slightly diverging) duck-type check locally.
- Branch each matcher's equality/predicate message on this.isNot, so a
  negated assertion that actually fails (e.g. .not.toBeOkWith(x) when the
  value does equal x) reports why instead of the self-contradictory
  "expected Ok(x) but got Ok(x)". Add tests that exercise that path plus
  the non-Result guard throw, since the existing suite only ever hit
  message() through already-pass:false branches.
Enables neverthrow/must-use-result (catches a Result built but never
mapped/chained/matched/collapsed) on src/core/result.ts,
src/core/commands/sonar-command.ts, and src/commands/list/projects.ts only
- the files this spike PR actually converted.

The commonly-recommended eslint-plugin-neverthrow hasn't published since
2021 and hard-crashes under this repo's ESLint 10 + typescript-eslint 8 (it
reads context.parserServices, an API removed since); using the
@ninoseki/eslint-plugin-neverthrow fork instead, which keeps the same rule
working for current ESLint.

Not enabled repo-wide: doing so surfaces ~17 pre-existing unconsumed Results
in files outside this PR's scope (CLI-844's own conversion of the
domain-client layer). That rollout, and fixing those sites, is its own
follow-up once more command handlers migrate under CLI-1086.
@charafsalmi
charafsalmi force-pushed the task/cs/CLI-1086-propagate-result-list-projects branch from c2ecc5f to 968cfac Compare September 9, 2026 15:30
Comment thread eslint.config.js Outdated
charafsalmi and others added 4 commits September 9, 2026 17:40
The rule's handled-method list (match/unwrapOr/_unsafeUnwrap) is hardcoded
with no options (schema: []), so it doesn't recognize orThrow() - this
repo's actual collapse method - as consuming a Result. The previous comment
called the pre-existing sites a repo-wide rollout would flag "unconsumed
Results", implying real defects; most are correctly-collapsed .orThrow()
chains the rule can't see. Corrected the rationale and shrunk the comment
to the durable why.
listProjects() returned ResultAsync<void, InvalidOptionError | HttpClientError>, mixing a CliError (exitCode/remediationHint) with the raw domain-client error union that carries neither. mapErr() now wraps the domain-client failure into a CommandFailedError, so the whole rail speaks one vocabulary (ResultAsync<void, CliError>) - same message, exit code, and remediation hint as before, since CommandFailedError's constructor already derives the hint from the wrapped cause exactly as runCommand()'s catch did directly.

Also made toBeErrWith()'s no-argument branch branch its message on this.isNot like every other branch in the file, instead of hardcoding one string.
…t-list-projects' into task/cs/CLI-1086-propagate-result-list-projects
Comment thread tests/_common/result-matchers.ts
Comment thread src/commands/list/projects.ts Outdated
@charafsalmi
charafsalmi marked this pull request as draft September 11, 2026 09:40
…ess wrap

The previous commit wrapped the domain failure in a CommandFailedError to make the signature read CliError. That wrap added nothing: same message, same exit code 1, and the same remediation hint, since CliError re-derives it from cause. Its only effect was cosmetic on the type, and replicated across the other ~40 handlers it would be a line of ceremony each.

The repo's actual convention (import/index.ts) wraps in CommandFailedError when it has something to add - context in the message, an explicit hint. Declaring Error instead matches exactly what authenticatedAction() accepts, keeps runCommand() as the single place an error becomes an exit code, and leaves a real wrap available where a command genuinely has context to add. Exit codes are unchanged and covered by the integration spec (2 for InvalidOptionError, 1 for an API failure).

Also adds the missing spec for the negated no-argument toBeErrWith() message, and notes in eslint.config.js that the neverthrow rule's file list is hand-maintained, so the next converted handler gets added rather than silently escaping the rule.
Three related simplifications, all stemming from the middle one.

The collapse read match(() => undefined, (error) => { throw error }) - a roundabout way to say "rethrow if it failed". Narrowing with isErr() and throwing directly says it in one line.

That in turn removes the reason for most of the JSDoc: the paragraph existed to justify why match() was used over orThrow(), a choice no longer being made. What is left is the durable why - the error is rethrown rather than handled so runCommand() stays the single collapse point.

The handler's return type moves to a named AuthenticatedCommandResult alias beside the existing CommandResult one, so the signature fits on a line and mirrors anonymousAction directly above it instead of inlining a union.

Also refreshes isResult()'s own doc, which described the caller's match() choice and would have gone stale again.
The added JSDoc argued the error-channel choice against the alternatives it rejected, which is review material, not something a future reader of this handler needs - and the rationale already lives in the pull request. The signature says ResultAsync<void, Error>; that is the whole contract.

Also corrects the file's header comment, which said "Issues command - search for SonarQube issues" on the projects handler.
No behaviour change; comments, naming and shape only.

result-matchers.ts: the two matchers each carried an identical five-line guard whose user-facing message had to stay in sync by hand, now one assertResult() helper. Header comment trimmed to the durable why and stripped of the ticket reference. The eslint-disable justification now cites TS2428, which is checkable, instead of asserting the constraint.

sonar-command.test.ts: dropped "(CLI-1086)" from two test names. No sibling test in that block references a ticket, and the key tells a future reader nothing the name does not already say.

eslint.config.js: comment restructured to lead with the two constraints that actually shape the block, and the files array now lists one path per line like the sibling block twenty lines below instead of one 103-column line.

Verified the scoped rule still fires by planting an unconsumed Result in a covered file, and re-ran the integration spec for the exit-code contract.
@sonarqubecloud

Copy link
Copy Markdown

@charafsalmi
charafsalmi marked this pull request as ready for review September 11, 2026 12:20

@damien-urruty-sonarsource damien-urruty-sonarsource left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Comment thread tests/unit/commands/list/projects-command.test.ts Outdated
@charafsalmi
charafsalmi merged commit 6e3afe8 into master Sep 11, 2026
16 checks passed
@charafsalmi
charafsalmi deleted the task/cs/CLI-1086-propagate-result-list-projects branch September 11, 2026 13:26
@gitar-bot

gitar-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 6 resolved / 6 findings

Converts the list projects command handler to Result-propagation shape as a scoped validation of the pattern before migrating the remaining ~40 handlers. Adds custom toBeOkWith()/toBeErrWith() test matchers and enables neverthrow/must-use-result on converted files. Four findings were resolved: isResult() JSDoc corrected to reference match() instead of orThrow(), config comment fixed to accurately describe result consumption, negated matcher message branch tested, and PR description return type aligned with implementation. No issues remain.

✅ 6 resolved
Quality: isResult() doc says orThrow(), the only caller must use match()

📄 src/core/result.ts:33-45 📄 src/core/commands/sonar-command.ts:479-493
The JSDoc added for isResult() says callers use it to decide "whether there is anything left to collapse via orThrow()", but the sole caller (authenticatedAction, sonar-command.ts:506-513) explicitly cannot use orThrow() on the resolved Ok | Err union and collapses with match() instead — as the comment at sonar-command.ts:482-484 states. A reader of result.ts is pointed at an API that does not work at that call site (the Ok/Err orThrow() overloads only apply to an already-narrowed branch). Align the doc with the implementation, or narrow with isErr() and throw directly so the two comments agree.

Quality: Config comment mislabels orThrow() sites as "unconsumed Results"

📄 eslint.config.js:76-90 📄 src/core/result.ts:53-67
The rule this PR enables hardcodes its handled-method list to ['match','unwrapOr','_unsafeUnwrap'] (plus an exemption when the Result is returned or is an arrow body) and declares schema: [], so it is not configurable and cannot be taught about this repo's canonical collapse method orThrow() (src/core/result.ts:53-77). Chains that terminate in .orThrow() outside a return position — e.g. if (!(await new ComponentsClient(client).componentExists(projectKey).orThrow())) (quality-gate/status/index.ts:131), const result = await issuesClient.searchIssues(params).orThrow() (list/issues.ts:196), gitlab/index.ts:119,227 — are correctly consumed yet will be reported, which almost certainly accounts for the "~17 pre-existing unconsumed Results ... (CLI-844's own conversion)" the comment attributes to real defects. As written the comment points the next engineer at a follow-up that would mean rewriting ~25 correct .orThrow() call sites; state instead that the rule does not recognise orThrow() and that a repo-wide rollout needs that gap closed (e.g. patching the rule or a wrapper) first. While editing, the 9-line PR-specific narrative can shrink to the durable "why" per the repo's comment guidance.

Quality: New negated toBeErrWith() message branch has no test

📄 tests/_common/result-matchers.ts:85-91 📄 tests/unit/_common/result-matchers.test.ts:54-56 📄 tests/unit/_common/result-matchers.test.ts:78-88
The delta adds a this.isNot branch for the no-argument toBeErrWith() case ('expected the result not to be Err'), but the matcher spec has no case exercising it — the three sibling negation-aware branches each have one (/not to be Ok/, /not to be "boom"/, /not to satisfy the given predicate/), while expect(err(...)).not.toBeErrWith() is never asserted, so a regression in that message would go unnoticed. Add the missing case alongside the existing negation tests.

Quality: PR description states a return type the code no longer has

📄 src/commands/list/projects.ts:45 📄 src/commands/list/projects.ts:88 🔗 PR description
The description's "What changed" section states listProjects()'s return type is ResultAsync<void, InvalidOptionError | HttpClientError>, but this commit changed it to ResultAsync<void, CliError> and added .mapErr((error) => new CommandFailedError(error.message, { cause: error })), which is the shape reviewers of this spike are being asked to agree on. Since the whole point of the PR is to settle the error-rail shape, update the description to state the CliError rail and the CommandFailedError normalization (behaviour is otherwise unchanged: message is preserved, exit code stays 1, and remediation hints survive because CliError derives them from cause).

Bug: Matcher messages ignore this.isNot, so .not failures print nonsense

📄 tests/_common/result-matchers.ts:77-81 📄 tests/_common/result-matchers.ts:100-107 📄 tests/unit/_common/result-matchers.test.ts:34-40 📄 tests/unit/_common/result-matchers.test.ts:52-54 📄 tests/unit/_common/result-matchers.test.ts:60-62
Both matchers build message() only for the positive-failure case, so a failing negated assertion prints a self-contradictory diff. I ran the same matcher bodies under bun 1.3.14: expect(ok('value')).not.toBeOkWith('value') reports expected Ok("value") but got Ok("value") and expect(err(new Error('boom'))).not.toBeErrWith('boom') reports expected Err message "boom" but got "boom" — exactly the unclear output this helper exists to remove. The new spec never surfaces this because every negative case it exercises is one where pass is already false (so message() is never rendered), and the non-Result guard throw is untested too; branch on this.isNot in message() and add a case that actually renders a failure message.

...and 1 more resolved from earlier reviews

Implementation Status ✅ 2 of 2 objectives covered
CLI-1086 - 2 of 2 objectives covered

This PR covers propagating the Result through command handlers and adding custom test matchers for Result types.

✅ 2 covered here
  • ✅ Propagate Result through command handlers to return ResultAsync instead of Promise
  • ✅ Add custom test matchers to assert directly on Result/ResultAsync values
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Counting what did not apply, without listing it.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants