Skip to content

fix(jira): differentiate rejected token from a missing issue - #1125

Draft
mbevc1 wants to merge 9 commits into
mainfrom
20260821_jira_msgs
Draft

fix(jira): differentiate rejected token from a missing issue#1125
mbevc1 wants to merge 9 commits into
mainfrom
20260821_jira_msgs

Conversation

@mbevc1

@mbevc1 mbevc1 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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

mbevc1 added 2 commits August 21, 2026 21:57
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.
@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 23s —— View job


Review — PR #1125

  • Static analysis (go build, go vet, go mod tidy)
  • Read the changed files
  • Review for correctness, edge cases, tests
  • Post review

Static analysis

Check Result
go build ./... clean
go vet ./... clean (typechecks the test files too, so the new executeCommandC call site compiles)
go mod tidy + git diff --exit-code go.mod go.sum clean

I could not run go test — the sandbox denied it, so I verified the exact-string assertions in internal/jira/jira_test.go by hand against go-jira@v1.17.0/error.go rather than by execution. Every one of the shapes I traced (JSON errorMessages, JSON errors map, empty non-JSON body, unparseable JSON body, HTML body, 2xx decode failure) produces exactly what the test pins. The Main workflow is still running; that's the authoritative check.

Assessment

The core of this is right, and the reasoning is unusually well documented. Three things stand out:

  • The classification is conservative in the direction that matters. LookupUnverified is the zero value, so no path that never reached Jira can report an issue as found, and a 404 is reclassified only on positive evidence. 443a0c47 pins the absence case — a 404 with neither header stays IssueMissing — which is the regression that would have silently flipped every genuinely-missing issue on Jira Cloud.
  • The blast radius is contained. json:"-" on both new fields keeps the payload byte-identical, and an error is returned in exactly the cases it was before, so --assert exit codes and compliance are unchanged. The change is purely in what the user is told.
  • logger.Warn("%s", jiraUnconfirmedWarning(...)) — passing the reason as an argument rather than as the format string. LookupReason embeds a Jira response body, which can contain %; this is the right call and easy to get wrong.

Test quality is high: exact-string assertions where Contains had previously let leaks through, the nil-logger case, require.NotContains(t, errOut, "secret"), and TestGetJiraIssueInfoTransportReasonIsIssueIndependent — pinning the property the dedup depends on rather than the dedup's output — is the right level to test at.

Findings

Two inline comments, both low severity, neither blocking:

  1. errorDetail ignores jiraErr.Errors — the fallback lets go-jira render the map, and go-jira returns from inside a range, so a multi-field error body reports one randomly chosen field and drops the rest. Same 403, different message each run.
  2. credentialsRejected isn't consulted on the 2xx branch — an SSO login page reached through a followed redirect arrives as a 200 with HTML and reads as invalid character '<' looking for beginning of value, which is the same misdirection the 404 branch fixes.

Nit

internal/jira/jira.go:94-97 still returns nil, err when url.Parse fails — the only path that hands back a nil *JiraIssueInfo, and the only error that carries no explanation now that every other path sets LookupReason. TestGetJiraIssueInfo asserts require.NotNil(t, result) for every case it covers, so the suite already encodes "always returns a result" as the contract. Not reachable in practice (jira.NewClient parses the same URL), and the caller returns on the error either way.

One question, not a code issue

With a provably-rejected token the command now warns, but still reports the attestation with issue_exists: false for every issue and, under --assert, still exits non-zero. So a non-compliant attestation is permanently recorded against a trail on the basis of a lookup that is known to be meaningless — the warning is on stderr and won't be in the Kosli record. The PR describes preserving the error contract as deliberate, and that's a defensible line to hold in a fix. But a 401 aborts before reporting anything, and "Jira told us it rejected these credentials" is evidence of the same strength. Is treating the two differently intentional, or is erroring out on credentialsRejected a follow-up worth having?
· branch 20260821_jira_msgs

@mbevc1 mbevc1 changed the title fix(jira): tell a rejected token apart from a missing issue fix(jira): differentiate rejected token from a missing issue Aug 21, 2026
@mbevc1 mbevc1 added the go Pull requests that update go code label Aug 21, 2026
Comment thread internal/jira/jira.go
Comment thread internal/jira/jira.go
Comment thread cmd/kosli/attestJira.go
@mbevc1
mbevc1 marked this pull request as draft August 22, 2026 13:07
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
@github-actions github-actions Bot removed the go Pull requests that update go code label Aug 22, 2026
Comment thread internal/jira/jira.go Outdated
Comment thread cmd/kosli/attestJira.go Outdated
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
Comment thread internal/jira/jira.go Outdated
Comment thread cmd/kosli/attestJira.go
claude and others added 2 commits August 22, 2026 13:43
…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
Comment thread cmd/kosli/attestJira.go
@github-actions github-actions Bot added go Pull requests that update go code github-actions labels Aug 22, 2026
Comment thread internal/jira/jira.go Outdated
Comment thread internal/jira/jira.go Outdated
Comment thread internal/jira/jira.go Outdated
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
@mbevc1
mbevc1 force-pushed the 20260821_jira_msgs branch from 21bfc1b to 2bd6de4 Compare August 22, 2026 14:35
Comment thread internal/jira/jira.go Outdated
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
Comment thread internal/jira/jira.go
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
Comment thread internal/jira/jira.go
var jiraErr *jira.Error
if errors.As(err, &jiraErr) && len(jiraErr.ErrorMessages) > 0 {
return oneLine(strings.Join(jiraErr.ErrorMessages, "; "))
}

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.

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.

Fix this →

Comment thread internal/jira/jira.go
// 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}

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.

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.

Fix this →

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

Labels

fix github-actions go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants