fix(jira): differentiate rejected token from a missing issue - #1125
fix(jira): differentiate rejected token from a missing issue#1125mbevc1 wants to merge 9 commits into
Conversation
Jira answers 404 both for an issue that does not exist and for one the caller may not view, and a caller whose credentials Jira rejected may not view any issue. GetJiraIssueInfo looked only at the status code, so an expired Atlassian API token came back as IssueExists=false with no error and was reported as a missing issue. A transport failure - DNS, proxy, TLS or timeout - reached the same outcome through the response==nil case, because the guard it fell through required a response to be present. Classify the outcome in LookupStatus instead, with LookupReason carrying a message for the caller to report. An error is still returned in exactly the cases it was returned in before, so callers keep stopping where they used to stop, and both new fields carry json:"-" so the attestation payload is unchanged. A 404 is only reclassified when the response carries positive evidence that the credentials were rejected. That evidence is Seraph-era, so its absence proves nothing and leaves the lookup classified exactly as it was. The status code and those two headers are now logged at debug level, which is what a run against an expired token needs to show whether the header is present on Jira Cloud.
An expired Jira API token made Jira answer 404 for every issue, and the attestation reported that as "issue not found" - so users went looking for a missing issue instead of renewing a token. Warn when a lookup could not be confirmed, and list the reason for it rather than calling it missing. Nothing about the outcome changes: the issue still counts as not found, so the payload, the compliance status, the abort paths and the --assert exit code are exactly what they were. A lookup that Jira answered normally is untouched, warning included, so a genuine 404 reads as it always has. Also amend errorDetail to use the message Jira itself returned, instead of go-jira's generic sentence that only repeats the status we already print. Verified end to end against a fake Jira in dry-run mode: a 404 with X-AUSERNAME: anonymous warns and still reports the attestation, a 404 with the credentials accepted behaves exactly as before, and a 401 still stops before the attestation is reported.
|
Claude finished @mbevc1's task in 4m 23s —— View job Review — PR #1125
Static analysis
I could not run AssessmentThe core of this is right, and the reasoning is unusually well documented. Three things stand out:
Test quality is high: exact-string assertions where FindingsTwo inline comments, both low severity, neither blocking:
Nit
One question, not a code issueWith a provably-rejected token the command now warns, but still reports the attestation with |
The per-issue lines said "issue not confirmed", but the first line of the failure - the line users actually read - still announced missing Jira issues, so it kept pointing at the wrong cause. Name what actually happened, and keep the wording this command has always used when every lookup was answered, which is the only case the existing tests assert. Review follow-ups in the same area: - set IssueExists from the same condition as IssueFound, instead of relying on go-jira returning a nil issue on every error. - logLookup tolerates a nil logger, since the parameter is new on an exported function and nil is the easy mistake. - correct the response==nil comment: go-jira also returns no response when it cannot build the request, so a base URL that parses but cannot be joined lands here too. The message no longer claims Jira was unreachable. - say in authDescription that including the username is deliberate: these messages reach CI logs, and the account is what the reader has to fix. - trim the comments that restated the code, keeping the Seraph caveat. Tests: the abort cases now assert the returned status too, a nil logger is covered, and the warning is covered end to end against a fake Jira in dry-run mode, which needs neither Jira credentials nor a Kosli server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01431hxZBDzGP7uvZ9183NJC
An expired token makes every issue key in the commit message report the same run-level cause, so warning per issue printed one sentence up to five times, and the --assert error then repeated each reason again. Collect the unconfirmed issues instead and warn once, naming them all, with the distinct reasons stated once; the per-issue lines say only "issue not confirmed", and the --assert error lists the reasons after the issues. Also set LookupReason on the abort path, so LookupUnverified can never carry an empty reason. Today's caller stops on the error and never reads the result, which is what made this unreachable rather than harmless; the returned error is now that same string. Lint: fix the errcheck failure this branch introduced - the deferred os.RemoveAll in the new test dropped its error. Verified with golangci-lint 2.5.0 built against the repo's Go version (the version usually on PATH refuses a 1.26.6 target): 0 issues over ./... Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01431hxZBDzGP7uvZ9183NJC
…r unwrappable Three follow-ups in GetJiraIssueInfo: - NewJiraClient failing returned a result carrying LookupUnverified with no reason, the same gap the abort path had. Unreachable from the command, which validates the credential flags first, but the field doc and jiraIssueLogLine both read as unconditional, so close it rather than rely on the caller. - the returned error is a lookupError instead of errors.New, so errors.Is/As reach what Jira's client returned. %w would have appended the wrapped text and undone the flattening errorDetail and oneLine do, which is why this needs a type rather than a verb. - a 2xx whose body cannot be decoded is described as an answer that could not be read, instead of "returned 200 OK: invalid character ...". Jira did answer in that case; only the wording changes, the abort does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01431hxZBDzGP7uvZ9183NJC
Follow-ups on this branch's reporting, all message-only:
- a failure that produced no response reports the cause inside the HTTP
client's *url.Error rather than its text, which embeds the per-issue
request URL. Every issue in a run reported a different reason for one
run-level cause, so the deduplication never matched and a DNS, proxy or
TLS failure on a three-key commit repeated the same sentence three
times.
- go-jira's generic "request failed ... Status code: N" sentence comes off
in both renderings: behind "<status>: <body>: " for a body it could not
parse, and bare when it parsed a body carrying no messages, which
{"errorMessages":[],"errors":{}} and {} both produce. Only the JSON-with-
messages shape was handled before, and that is the shape a rejected
token does not answer with. The closed-body error go-jira appends to a
decode failure - it reads the body again, after Do has closed it - comes
off through the error chain, since net/http keeps it unexported.
- when nothing is left to quote, the message ends at the status it already
named, instead of repeating it with a dangling separator.
- the --assert reasons are prefixed, so they do not read as another entry
in the issue list they follow.
The three abort cases now pin the whole message rather than substrings,
which is what let the duplication through, and the comments this branch
added are thinned to the ones carrying something the code does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01431hxZBDzGP7uvZ9183NJC
21bfc1b to
2bd6de4
Compare
For a non-2xx answered with Content-Type: application/json and a body that does not parse - an empty body qualifies - go-jira wraps its generic sentence around "could not parse JSON" and returns that wrapped around the json error, so the sentence leads the text instead of trailing it. The suffix trim missed it and the message carried the status three times. Trim it at either end. The separator is already left out of it, so the TrimLeft that follows removes what the prefix form leaves behind, and the other shapes are untouched. Verified with the CLI against a fake Jira for each body shape: an empty body, an unparseable JSON body, empty and parseable JSON, an HTML login page, errorMessages, and a fields error. The first and fourth end at the status, the rest quote what Jira said and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01431hxZBDzGP7uvZ9183NJC
Every 404 row set at least one of the two headers, so the shape Jira Cloud is likeliest to answer with - neither header present - went uncovered, and that is the regression that would cost the most: every genuinely missing issue reported as unverified instead. The new row asserts the debug line as well as the classification, since %q renders an absent header as "", which pins that neither was sent rather than assuming it. Checked it can fail: treating an empty X-AUSERNAME as anonymous fails this row and no other. Also renames the neighbouring row, which sets headers showing the credentials were accepted, so the two read as the distinct cases they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01431hxZBDzGP7uvZ9183NJC
| var jiraErr *jira.Error | ||
| if errors.As(err, &jiraErr) && len(jiraErr.ErrorMessages) > 0 { | ||
| return oneLine(strings.Join(jiraErr.ErrorMessages, "; ")) | ||
| } |
There was a problem hiding this comment.
errorDetail special-cases ErrorMessages but leaves the errors map to the text-trimming fallback, and go-jira renders that map by returning inside a range (error.go:56-60):
if len(e.Errors) > 0 {
for key, value := range e.Errors {
return fmt.Sprintf("%s - %s: %v", key, value, e.HTTPError)
}
}So for a body carrying more than one field error — {"errors":{"issuekey":"Issue does not exist","project":"No permission"}}, which is the documented shape — the message reports one randomly chosen field and silently drops the rest. Map iteration order is randomized per run, so the same 403 produces a different message each time; anything asserting on it (a golden file, a support ticket comparing two runs) sees a message that changes for no reason.
The struct is already in hand, so reading it directly is both complete and deterministic:
if errors.As(err, &jiraErr) {
if len(jiraErr.ErrorMessages) > 0 {
return oneLine(strings.Join(jiraErr.ErrorMessages, "; "))
}
if len(jiraErr.Errors) > 0 {
// go-jira renders this map by returning from inside a range, so it reports one
// random entry; sorted keys report all of them, the same way every run
fields := make([]string, 0, len(jiraErr.Errors))
for key := range jiraErr.Errors {
fields = append(fields, key)
}
sort.Strings(fields)
for i, key := range fields {
fields[i] = key + " - " + jiraErr.Errors[key]
}
return oneLine(strings.Join(fields, "; "))
}
}sort is already imported. The existing "a 403 reporting a field error quotes it" case keeps its exact expectation (issuekey - Issue does not exist); worth a second case with two entries to pin the ordering.
| // a decode failure comes back alongside the 2xx it could not read, so Jira did answer | ||
| result.LookupReason = withDetail(fmt.Sprintf("could not read Jira's answer for issue %s at %s (status %s)", | ||
| issueID, jc.BaseURL, response.Status), errorDetail(err, response)) | ||
| return result, &lookupError{message: result.LookupReason, cause: err} |
There was a problem hiding this comment.
Enhancement, not a defect: credentialsRejected is consulted only on the 404 branch, but this branch is the other place where a rejected credential can arrive disguised as something else.
A Jira Server / Data Center instance behind SSO answers an unauthenticated API call with a 302 to a login page; http.Client follows it, so what lands here is a 200 with HTML, and the message becomes:
could not read Jira's answer for issue EX-1 at https://jira.example.com (status 200 OK): invalid character '<' looking for beginning of value
which reads as a Jira bug rather than "your PAT expired" — the same misdirection the 404 branch now fixes. The evidence is on the response already, and the header check is free:
case response.StatusCode >= 200 && response.StatusCode <= 299:
// a decode failure comes back alongside the 2xx it could not read, so Jira did answer
message := fmt.Sprintf("could not read Jira's answer for issue %s at %s (status %s)",
issueID, jc.BaseURL, response.Status)
if reason, rejected := credentialsRejected(response.Header); rejected {
// an SSO login page arrives as a 2xx the client followed a redirect to
message += fmt.Sprintf("; Jira did not accept the %s (%s), so this may be a login page",
jc.authDescription(), reason)
}
result.LookupReason = withDetail(message, errorDetail(err, response))Same "presence is trustworthy, absence proves nothing" rule as the 404 branch, so it changes no outcome where the headers are absent — and it stays an abort either way.
Jira answers 404 both for an issue that does not exist and for one the
caller may not view, and a caller whose credentials Jira rejected may not
view any issue. GetJiraIssueInfo looked only at the status code, so an
expired Atlassian API token came back as IssueExists=false with no error
and was reported as a missing issue. A transport failure - DNS, proxy, TLS
or timeout - reached the same outcome through the response==nil case,
because the guard it fell through required a response to be present.
Classify the outcome in LookupStatus instead, with LookupReason carrying a
message for the caller to report. An error is still returned in exactly the
cases it was returned in before, so callers keep stopping where they used
to stop, and both new fields carry json:"-" so the attestation payload is
unchanged.
A 404 is only reclassified when the response carries positive evidence that
the credentials were rejected. That evidence is Seraph-era, so its absence
proves nothing and leaves the lookup classified exactly as it was.
The status code and those two headers are now logged at debug level, which
is what a run against an expired token needs to show whether the header is
present on Jira Cloud.
Also adjust GHA label filter.
Checklist
charts/k8s-reporter/) updated, if needed. Note: these changes live in a separate PR