Skip to content

fix(jira): stop reporting Jira failures as missing issues - #1123

Closed
mbevc1 wants to merge 7 commits into
mainfrom
20260821_jira_errors
Closed

fix(jira): stop reporting Jira failures as missing issues#1123
mbevc1 wants to merge 7 commits into
mainfrom
20260821_jira_errors

Conversation

@mbevc1

@mbevc1 mbevc1 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

GetJiraIssueInfo treated anything short of a non-404 HTTP response as "the issue
does not exist".

When go-jira returns an error with no response at all - DNS failure, TLS error,
timeout, connection refused - that guard is false, issue is nil, and the function
returns IssueExists=false with a nil error. A network blip was therefore reported
to the user as a missing Jira issue, and under attest jira --assert it failed
their pipeline with a message pointing at their commit's Jira references rather
than at the network.

Classify the outcome instead. A 404 is the only status that answers the question,
and stays a missing issue. A nil response is a reachability error naming the base
URL; 401 and 403 name the credential in play, since "check the API token of user
x" is the actionable part and go-jira's own message carries neither; any other
status keeps the error go-jira returned.

Tests cover each branch against a stubbed Jira, including the transport failure
that returned no error at all before this change.

Follow-up from #1118 troubleshooting issues

Checklist

mbevc1 added 2 commits August 21, 2026 16:57
GetJiraIssueInfo treated anything short of a non-404 HTTP response as "the issue
does not exist".

When go-jira returns an error with no response at all - DNS failure, TLS error,
timeout, connection refused - that guard is false, issue is nil, and the function
returns IssueExists=false with a nil error. A network blip was therefore reported
to the user as a missing Jira issue, and under `attest jira --assert` it failed
their pipeline with a message pointing at their commit's Jira references rather
than at the network.

Classify the outcome instead. A 404 is the only status that answers the question,
and stays a missing issue. A nil response is a reachability error naming the base
URL; 401 and 403 name the credential in play, since "check the API token of user
x" is the actionable part and go-jira's own message carries neither; any other
status keeps the error go-jira returned.

Tests cover each branch against a stubbed Jira, including the transport failure
that returned no error at all before this change.
Jira will not confirm that an issue exists to a caller who may not see it, so the
issue endpoint answers 404 both for an issue that is absent and for a credential
that has expired or lost access. `attest jira` read that 404 as "issue not found"
either way, so when the shared Atlassian API token needed recycling, CI
failed. This sends the reader looking for a deleted Jira issue instead of a dead token.
Customers hit the same message on their own pipelines whenever a token lapses, and
Atlassian API tokens now expire by default.

Ask Jira who we are before believing the 404. `VerifyCredentials` calls the identity
endpoint, where a rejected credential does get a straight answer, and names the
credential to check in the error. attest jira consults it only when some reference
was not found - where every reference resolved, the credential has already proved
itself - and fails before reporting, since issue_exists=false is not a fact we
established and does not belong in a compliance trail.
@github-actions github-actions Bot added the fix label Aug 21, 2026
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @mbevc1's task in 4m 56s —— View job


Review — PR #1123

  • Static analysis (go build, go vet, go mod tidy)
  • Read the current diff against origin/main
  • Review behaviour, edge cases, tests
  • Post review (5 inline comments)

Static checks

Check Result
go build ./... pass
go vet ./... pass
go mod tidy + git diff --exit-code go.mod go.sum clean

I could not run go test — the sandbox denied it, so the new tests are reviewed by reading, not by execution. (make test_integration needs Docker + a server on :8001 anyway; the four new standalone tests are designed to run without either, which is the point of them.)

Where the change has landed

Across seven commits this has converged on a defensible split, and it's worth stating it plainly because the PR body no longer describes it:

Jira answers Outcome
200 issue found
404 on the issue issue not found, /myself consulted, warning if that says anything
401 / 403 / 5xx on the issue command fails (unchanged from main)
nothing at all issue not found + warning, exit code unchanged (was silent on main)
anything on /myself warning only, never affects the exit code

The retreat from CredentialRejectedError to a diagnostic-only check was the right call, and the reasoning in each commit message is unusually good. LookupFailure as a field on the result rather than a returned error is the neat part: it keeps "we learned nothing" distinct from "the issue is absent" without handing a Jira outage the power to fail a run that used to pass.

Findings

  1. Help text contradicts itself (attestJira.go:72-82) — paragraph one says wrong credentials are reported as non-existing issues, paragraph four says a rejected credential fails the command. Both are true, for different Jira deployments, and the help gives the reader no way to tell which applies to them. Keying the text to the status rather than the cause fixes it. (inline, with a suggestion)
  2. --quiet deletes the entire diagnosis (attestJira.go:333, :361) — logger.Warn is a no-op under QuietEnabled, and both new signals go only through it. attest jira --assert --quiet with an expired token reproduces the original incident output exactly; the unreachable case goes fully silent again. Now that the check is diagnostic-only, the warning is the feature, and it's on the one channel CI users routinely turn off. issueLog always reaches the user — carry it there too. (inline)
  3. The credential check still fires when a lookup already succeeded (attestJira.go:359) — answeredNotFound > 0 is narrower than before but still triggers on the mixed case (NOTE-1 404 + PROJ-42 200) that this command's own help calls out as a common false positive. A 200 already proves the credential, so the extra call can only add noise. The comment above already states the stronger rule; issueFoundCount == 0 && answeredNotFound > 0 makes the code agree with it. (inline, with a suggestion)
  4. The trail still records what stderr retracts (jira.go:27-34) — an unanswered lookup POSTs issue_exists: false, so the durable artifact says the issue is missing while only the ephemeral warning says nobody asked. Not a regression, and possibly blocked on the server schema — but worth a sentence in that doc comment, since "reported rather than returned as an error" currently implies a caller receives it. (inline, non-blocking)
  5. Test helper nit: newStubKosliAttestation takes t only for t.Helper() while every caller writes its own defer Close(); t.Cleanup(fake.Close) earns the parameter. (inline, non-blocking)

What's good

  • The four standalone tests are the strongest part of this. Moving them out of the secret-gated suite means the regressions are pinned everywhere rather than only in the environment that has KOSLI_JIRA_*, and the atomic.Int32 counter on the stubbed attestation endpoint makes "the attestation was reported" a fact about what was sent rather than an inference from output text — including the case that previously would have written a bogus issue_exists=false into the live attest-jira flow on a bad day.
  • http://127.0.0.1:0 instead of start-a-server-then-close-it removes a real flake source under -p=8.
  • assert.ErrorAs(&net.OpError{}) on the wrapped transport errors is the right way to pin %w — it fails if a later change flattens the cause back to %s, which is exactly the door this PR wanted left open.
  • Case 09's conversion to goldenRegex is correctly reasoned, and the comment says why the assertion is deliberately weaker.
  • Every non-404 error message now names the Jira and the issue. failed to look up Jira issue EX-1 at https://… : request failed … Status code: 500 is a large improvement over go-jira's unattributable original.

None of the five findings is a blocker on the classification logic itself, which reads correct. (1) and (2) are the two I'd want addressed before merge — the help currently misleads half its readers, and --quiet silently undoes the fix for the exact users most likely to hit it.
· branch 20260821_jira_errors

Comment thread cmd/kosli/attestJira.go Outdated
Comment thread internal/jira/jira.go Outdated
Comment thread cmd/kosli/attestJira_test.go Outdated
Comment thread internal/jira/jira_test.go Outdated
Comment thread internal/jira/jira.go
Review of #1123 caught that the credential check replaced one way for a
third-party blip to fail a pipeline with another. VerifyCredentials returned an
opaque error for three different outcomes - rejected, unreachable, 5xx - and the
caller aborted before reporting on all of them. A commit referencing a genuinely
absent PROJ-9999 plus one failed /myself call now exited non-zero and recorded
nothing, where before it reported the issue as non-existing and exited 0.

Only a negative answer is worth failing on. VerifyCredentials returns
*CredentialNotVerifiedError when Jira refuses the credential (401/403) or answers
in a way that cannot be read as verification (404); anything else stays a plain
error. The caller matches with errors.As: a negative answer aborts, and a check
that did not complete is logged as a warning and the attestation still reported.

A type rather than a sentinel because the wording has to differ. A 404 also comes
back from a base URL that is not a Jira, so pointing only at the credential there
would send the reader off to rotate a token that was never the problem: that
message names both candidates. Its comment no longer asserts that Jira answers
/myself with 404 for rejected credentials - we have not observed that, and the
branch is defensive.

Also fixes the help text, which still described the old behaviour ("the
attestation is reported in all cases", wrong credentials "reported as non existing
Jira issue"), and replaces the start-a-server-then-close-it trick in the
unreachable-Jira tests with port 0, which cannot be claimed by another package's
tests mid-run.

The two CLI-level cases move out of AttestJiraCommandTestSuite, whose SetupTest
skips without the shared Jira secrets: both stub Jira, one stubs the attestation
endpoint too, so they need neither the secrets nor the local Kosli server and run
wherever `go test` does.
Comment thread internal/jira/jira.go Outdated
Comment thread cmd/kosli/attestJira_test.go
Comment thread cmd/kosli/attestJira.go Outdated
Second review round caught that the 404 branch broke this PR's own rule. "Only a
rejected credential stops the attestation" was applied to the one status that is
not a rejection, and the one that had never been observed: a 404 hard-failed the
run while a real, observed infrastructure failure - 503, unreachable - only warned.

Everything that realistically answers /myself with 404 is shaped like a
misconfiguration rather than a credential: a base URL that is not a Jira, one
missing a Data Center context path, a proxy or SSO gateway that 404s paths it does
not recognise. None of them say anything about the credential, which is the
criterion the other branches use, and a proxy that started 404ing would fail
pipelines over issues that really are missing - the failure this PR removed,
re-entering through its most speculative branch.

So 404 joins the plain-error bucket: the caller warns, names --jira-base-url as a
candidate, and still reports the 404 the issue endpoint gave. Only 401 and 403,
where Jira looked at the credential and said no, stop the attestation. The type is
CredentialRejectedError accordingly - it now means what its name says, rather than
"not verified", which was covering two outcomes that want opposite treatment.

Also from the round:

- The warning leads with the actionable part: "could not verify the Jira
  credential (%s); issues Jira did not return are reported as not found".
- Suite case 09 references a non-existent issue, so it takes the verification path
  with an exact golden; a rate-limited /myself would prepend the warning and fail
  it. Matched rather than compared exactly now, like cases 07 and 25.
- A third stub test covers the path most at risk from the new call: credential
  verifies, issue genuinely missing, attestation still reported and --assert still
  failing on it. The suite cases for that skip without the Jira secrets.
- Help text follows the behaviour: refusal fails, anything else warns.
Comment thread cmd/kosli/attestJira.go Outdated
Comment thread internal/jira/jira.go Outdated
Comment thread cmd/kosli/attestJira_test.go Outdated
claude added 2 commits August 21, 2026 16:59
The check was still allowed to decide an outcome: a refused credential aborted
before the attestation was reported. That is the one change a user would notice,
and it is not worth making - a token that lapses overnight would fail pipelines
that had been passing, for a diagnosis the run can simply state.

It is now diagnostic only. Every outcome of VerifyCredentials becomes one warning
on stderr and nothing else: the attestation is reported as before, the exit code is
untouched, and --assert still fails on the issues that were not found - with the
warning explaining why they were not. So the fix keeps what it was for, telling a
stale credential apart from a missing issue, and gives up the part that could
break a working pipeline.

CredentialRejectedError goes with it. With no caller branching on the outcome the
type had no consumer, and the statuses now differ only in wording: 401 and 403 name
the credential, a 404 names --jira-base-url alongside it, and the rest name the
Jira. That also retires the question of which bucket a 404 belongs in, since
nothing turns on it any more.

Help text leads with the behaviour rather than the mechanism, and says the warning
goes to stderr and does not change the exit code.

Review of efb1a5f, also addressed:

- GetJiraIssueInfo's default branch wrapped go-jira's message, which names neither
  the Jira nor the issue: on a 5xx it read as an unattributable "request failed".
  It now says which call it was, and the transport branch names the issue too.
- The three fmt.Errorf sites use %w rather than %s, so a caller can still reach a
  net.Error or context.DeadlineExceeded underneath "failed to reach Jira". Tested
  by unwrapping to *net.OpError.
- The stale-credential test used the real local Kosli server as its host while its
  comment claimed it needed no server, so "nothing was POSTed" rested on a string
  check: a regression moving the POST above the check would have attested a bogus
  issue_exists=false into the live attest-jira flow. All three tests now share a
  counting stub of the attestation endpoint and assert the request count, so what
  was sent is checked structurally.
The help described the credential warning in detail but not the change a user is
more likely to hit: since the first commit, a Jira that cannot be reached, or that
answers the issue lookup with anything other than a 404, fails the command instead
of recording the issue as non existing. That is deliberate - an unanswered lookup
is not evidence that an issue is missing - but it means `attest jira` can exit
non-zero on a Jira outage even without `--assert`, which it could not do before,
and nothing in --help said so.
Comment thread cmd/kosli/attestJira.go Outdated
Comment thread cmd/kosli/attestJira.go
Comment thread cmd/kosli/attestJira.go Outdated
Comment thread cmd/kosli/attestJira_test.go
Exit codes now match what they were before this branch, in every case. The first
commit had made one outcome newly fatal: a Jira that never answers the issue
lookup. Before, that came back as issue_exists=false and the run passed; after, it
aborted with no attestation. Nothing else moved - a 401, 403 or 5xx on the lookup
failed the command before this branch too, and still does.

So the transport failure goes back to being non-fatal, and stops being silent,
which was the actual defect. JiraIssueInfo carries LookupFailure: set when Jira
never answered, excluded from the payload, and reported by the caller as a warning
on stderr. IssueExists stays false and the issue still counts as not found for
--assert, exactly as it always has.

Keeping it a field rather than an error is the point: there is no way for a caller
to accidentally turn a Jira outage into a failed pipeline, and no classification to
get wrong. The cause stays wrapped, so a caller can still tell a timeout from a
refused connection.

Two things follow:

- The credential check now runs only when Jira actually answered "not found" for
  something. Where nothing was answered there is no ambiguous 404 to explain, and
  asking again only produced a second way of saying Jira could not be reached.
- The help text says which outcome does what, and no longer claims the command
  fails when Jira cannot be reached.

TestAttestJiraUnreachable covers it end to end against port 0: no error, the
attestation still reported (asserted on the stub's request count), the lookup
warning on stderr, and no credential warning.
Comment thread cmd/kosli/attestJira.go
Comment on lines +72 to +82
If your Jira credentials are wrong, or ^--jira-base-url^ does not point at your Jira, the
issues are reported as non existing. This is because Jira returns the same 404 whether an
issue does not exist or your credentials may not see it.
When that happens a warning naming the likely cause is written to stderr, so a run whose
issues all came back missing says whether the credentials were the reason. The warning does
not change the exit code; ^--assert^ still fails on the issues that were not found.

If Jira cannot be reached at all, the issues it did not answer for are reported as non
existing, as before, with a warning on stderr saying that the lookup never got an answer.
If Jira answers the lookup with an error instead - a rejected credential, a server error -
the command fails, as it has always done for those.

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.

These two paragraphs give opposite answers to the same question.

Line 72: "If your Jira credentials are wrong … the issues are reported as non existing."
Line 81: "If Jira answers the lookup with an error instead - a rejected credential, a server error - the command fails."

Both describe wrong credentials, and they promise different outcomes. Which one a user gets depends on something the help never mentions and they can't control: whether their Jira answers the issue lookup with 404 (Cloud, and the case that motivated this PR) or 401/403 (self-hosted, or an SSO gateway in front). A reader with a Data Center Jira follows paragraph one and is surprised by a non-zero exit; a reader with Cloud follows paragraph four and wonders why nothing failed.

The rest of the change is careful to key behaviour off the status rather than off a guess at the cause — the help should do the same:

Suggested change
If your Jira credentials are wrong, or ^--jira-base-url^ does not point at your Jira, the
issues are reported as non existing. This is because Jira returns the same 404 whether an
issue does not exist or your credentials may not see it.
When that happens a warning naming the likely cause is written to stderr, so a run whose
issues all came back missing says whether the credentials were the reason. The warning does
not change the exit code; ^--assert^ still fails on the issues that were not found.
If Jira cannot be reached at all, the issues it did not answer for are reported as non
existing, as before, with a warning on stderr saying that the lookup never got an answer.
If Jira answers the lookup with an error instead - a rejected credential, a server error -
the command fails, as it has always done for those.
The found issue references will be checked against Jira to confirm their existence.
The attestation is reported in all cases, and its compliance status depends on referencing
existing Jira issues.
Jira returns the same 404 whether an issue does not exist or your credentials may not see
it - it will not confirm that an issue exists to a caller who may not see it - so wrong
credentials, or a ^--jira-base-url^ that does not point at your Jira, show up as issues
reported non existing. When that happens a warning naming the likely cause is written to
stderr, so a run whose issues all came back missing says whether the credentials were the
reason. The warning does not change the exit code; ^--assert^ still fails on the issues
that were not found.
If Jira cannot be reached at all, the issues it did not answer for are reported as non
existing, as before, with a warning on stderr saying that the lookup never got an answer.
If Jira answers the lookup with any other error - 401, 403, or a server error - the command
fails, as it has always done for those.

Fix this →

Comment thread cmd/kosli/attestJira.go
// did before - with the warning now explaining why they were not.
if answeredNotFound > 0 {
if err := jc.VerifyCredentials(); err != nil {
logger.Warn("%s. Issues Jira did not return are reported as not found", err)

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.

Now that the diagnosis is the only thing this change produces, --quiet deletes the whole feature.

logger.Warn returns early when QuietEnabled (internal/logger/logger.go:56-58, set from global.Quiet && !global.Debug). Both new signals go through it — this line and the per-issue LookupFailure warning at line 333 — and neither has a fallback.

So kosli attest jira --assert --quiet (a very ordinary CI invocation; -q is one character) with an expired token fails with exactly the pre-PR output:

Error: missing Jira issues from references found in commit message or branch name
	EX-1: issue not found

That's the incident this PR was written for, reproduced in full. And in the unreachable case with --quiet there is now no output at all about Jira being down — the run passes and silently attests issue_exists=false, which is the behaviour the first commit set out to stop being silent about.

--quiet is documented as suppressing non-critical warnings, and "the reason your compliance data is wrong" isn't that. issueLog is built a few lines up and always reaches the user when --assert fails, so carrying the diagnosis into it costs little and survives -q:

credentialNote := ""
if answeredNotFound > 0 {
    if err := jc.VerifyCredentials(); err != nil {
        logger.Warn("%s. Issues Jira did not return are reported as not found", err)
        credentialNote = fmt.Sprintf("\n\t%s", err)
    }
}

…appended to issueLog in the assert branch at line 403. The same applies to LookupFailure: fmt.Sprintf("\n\t%s: %s", result.IssueID, issueExistLog) could say issue not found (lookup unanswered) rather than relying solely on a suppressible warning.

Fix this →

Comment thread cmd/kosli/attestJira.go
// unchanged, so a stale token cannot fail a pipeline that would have passed, and neither
// can a blip on this call. --assert still fails on the issues that were not found, as it
// did before - with the warning now explaining why they were not.
if answeredNotFound > 0 {

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.

answeredNotFound > 0 narrows the guard usefully — the unreachable case no longer asks twice — but it still fires when a lookup already came back 200, and a single 200 proves the credential exactly as conclusively as ten do.

The mixed case isn't hypothetical; it's the false-positive shape this command's own help calls out at lines 55–62. see note-1 for context, fixes PROJ-42 looks up NOTE-1 and PROJ-42: PROJ-42 → 200, NOTE-1 → 404, and the CLI still spends a round-trip on /myself. Since the credential just demonstrably worked, the call can only return nil (no value) or blip — and a blip prints "could not check the Jira credential … Issues Jira did not return are reported as not found", casting doubt on a result the 200 already settled.

Suggested change
if answeredNotFound > 0 {
if issueFoundCount == 0 && answeredNotFound > 0 {

Note the comment above already states the stronger rule — "where every reference resolved the credential has already proved itself" — so this is the code catching up with the reasoning. A stub test with one 200 and one 404 asserting no credential warning would pin it; TestAttestJiraVerifiedCredentialMissingIssue is nearly that test already.

Fix this →

Comment thread internal/jira/jira.go
Comment on lines +27 to +34
// LookupFailure is set when Jira never answered the lookup at all, so IssueExists is
// false because nothing was learned rather than because the issue is absent. It is
// reported rather than returned as an error, so that an unreachable Jira cannot fail a
// run that would otherwise have passed - the exit code stays what it was before this
// distinction existed. Excluded from the payload: it describes this attempt, not the
// issue, and the cause stays wrapped so a caller can still tell a timeout from a
// refused connection.
LookupFailure error `json:"-"`

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.

Worth naming the residual gap the json:"-" creates, since it's the one place the PR title's promise doesn't reach.

An unanswered lookup still POSTs issue_exists: false to the trail, so the durable record — the thing someone reads back in six months, and the thing the compliance status is computed from — still says "this commit references an issue that does not exist", when in fact nobody ever asked. The correction lives only in a stderr warning that the CI log rotates away (and that --quiet drops outright).

Keeping the exit code unchanged is clearly right. But "don't fail the run" and "record a fact we didn't establish" are separable, and the second is the more permanent of the two. If the attestation schema has room for it — a lookup_failed alongside issue_exists, or omitting the unanswered entries from JiraResults — that would close the loop. If it doesn't, that's a fine answer too; it's a server-side conversation rather than something to hold this PR for. Either way the tradeoff deserves a sentence in this comment, because the current wording ("reported rather than returned as an error") reads as though the caller receives it, and the only caller that does is stderr.

// would quietly attest a bogus issue_exists=false into the real attest-jira flow, and only
// then fail on a missing string.
func newStubKosliAttestation(t *testing.T) (*httpfake.HTTPFake, *atomic.Int32) {
t.Helper()

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.

Nit, carried over from the last round: t is taken only for t.Helper(), and all four callers still write their own defer kosliStub.Close(). t.Cleanup(fake.Close) here would earn the parameter and drop a line from each caller.

Also the org/flow/trail are spelled out here and rebuilt in every caller's --flow/--org/--trail args. It fails loudly if they drift (the POST 404s, reported stays 0), so it's not a correctness risk — but a shared const would keep the two definitions honest.

@mbevc1 mbevc1 added the go Pull requests that update go code label Aug 21, 2026
@mbevc1
mbevc1 marked this pull request as draft August 22, 2026 13:07
@mbevc1 mbevc1 closed this Aug 22, 2026
@mbevc1
mbevc1 deleted the 20260821_jira_errors branch August 22, 2026 13:53
@mbevc1

mbevc1 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Closed in favour of #1125

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

Labels

fix go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants