Skip to content

fix: make account creation idempotent and surface Linear API errors - #62

Open
russellhaering wants to merge 3 commits into
mainfrom
russell_h/create-account-existing-user
Open

russellhaering wants to merge 3 commits into
mainfrom
russell_h/create-account-existing-user

Conversation

@russellhaering

Copy link
Copy Markdown
Contributor

Summary

Account provisioning always sent a workspace invite. Linear rejects an invite when the user is already a member or already has a pending invite, so a retried or repeated create failed with organizationInviteCreate returned success=false and no detail.

Changes

  • Existing users: CreateAccount looks the user up by email (case-insensitive, including suspended users) before inviting. An active user is returned as AlreadyExistsResult. A suspended user returns an error explaining that the account must be reactivated.
  • Pending invites: an unaccepted, unexpired invite for the email returns ActionRequiredResult instead of sending a duplicate.
  • Error details: when the invite mutation fails, the error now includes Linear's GraphQL error message. Non-rate-limit HTTP errors also carry the message.
  • Rate limiting: a 400 is treated as rate limiting only when Linear marks it as such (RATELIMITED error code, or an exhausted X-RateLimit-*-Remaining header). Previously every 400 was converted into a retryable rate-limit error, so invalid requests were retried until timeout.

Testing

  • go test ./pkg/... passes. New tests cover the existing-user, suspended-user, pending-invite and invite-error cases, plus rate-limit vs. invalid-request 400s and the email lookup.
  • I checked the new queries (users filtered by email, organizationInvites) against the live Linear GraphQL schema with a read-only query.
  • I didn't run golangci-lint locally; my installed binary is older than this module's Go version, so CI covers lint.

CreateAccount always sent a workspace invite, which Linear rejects when
the user is already a member or already has a pending invite. Retries
of a create that had already succeeded therefore failed with an opaque
"success=false" error.

- Look up the user by email first and return AlreadyExists for active
  users; fail with a clear message for suspended users.
- Return ActionRequired when an unexpired invite is already pending.
- Include Linear's GraphQL error message when an invite fails.
- Only treat a 400 as rate limiting when Linear says so (RATELIMITED
  error code or an exhausted rate-limit header), so invalid requests
  are no longer retried as if they were rate limited.
@russellhaering
russellhaering requested a review from a team October 27, 2026 17:59
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit f8cd61163262

Connector PR Review: fix: make account creation idempotent and surface Linear API errors

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 852e86113e3d.
Review mode: full
View review run

Review Summary

Before inviting, CreateAccount now looks the email up in two places:

  • Users, case-insensitively and including suspended users. An active user returns AlreadyExistsResult with the org as parent; a suspended user returns an error.
  • Workspace invites, paginated. An unaccepted, unexpired invite returns ActionRequiredResult.

Two error-handling changes come with it:

  • CreateOrganizationInvite now includes Linear's GraphQL error when it gets success=false on a 200.
  • doRequest now treats a 400 as a retryable Unavailable rate limit only when Linear sends RATELIMITED or a X-RateLimit-*-Remaining: 0 header. Other 400s come back as non-retryable errors.

I scanned the full PR diff for security and correctness and checked it against the vendored uhttp behavior. The repo-local criteria loaded:

  • Provisioning and error-code mapping (E2): applied. 429 and rate-limited 400s map to Unavailable, which is correct.
  • JSON nullability (J3): applied. A null acceptedAt decodes to "", which is handled.
  • Sync-path rules (S/R7) and log-level rules (A): not relevant. The PR touches no sync methods and adds no logging.

No resource types, entitlements, IDs or scopes changed, so there are no breaking-change concerns. No dependency changes.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • New (medium confidence), pkg/linear/client.go:603-620: GetUserByEmail decodes into GraphQLUsersResponse, which has no errors field. A 200 response carrying GraphQL errors looks like "user not found", so CreateAccount sends a duplicate invite and the new idempotency check is skipped. GetPendingOrganizationInvite (:626-685) has the same gap.
  • New (low confidence), pkg/linear/client.go:683: the invite pagination loop has no guard for hasNextPage: true with an empty endCursor, which would refetch page 1 until the context is cancelled. Every CreateAccount also pages through the workspace's entire invite history.
  • New (medium confidence), pkg/linear/client.go:1197-1199: uhttp.WithErrorResponse already puts resource.Message() in the error (vendor pkg/uhttp/wrapper.go:561). Appending gqlErr.Message() again prints Linear's message twice.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/linear/client.go`:
- Around line 603-620 (GetUserByEmail) and 626-685 (GetPendingOrganizationInvite): neither response struct captures GraphQL `errors` returned with HTTP 200, so a failed lookup looks like "not found" and CreateAccount sends a duplicate invite. Embed `GraphQLError` in both response structs (as CreateOrganizationInvite does) and return an error when `res.HasErrors()` is true.
- Around line 683: if `hasNextPage` is true but `endCursor` is empty, the loop restarts from page 1 forever. Return an error when the next cursor is empty or the same as the current one.
- Around line 1197-1199 (doRequest): uhttp.WithErrorResponse already includes `gqlErr.Message()` in `err` ("Request failed with status N: <message>"). Remove the extra `fmt.Errorf("%w: %s", err, gqlErr.Message())` wrap so the message is not duplicated, or confirm it adds information.

Comment thread pkg/linear/client.go
return nil, err
}

if len(res.Data.Users.Nodes) == 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.

🟡 Suggestion (medium confidence): GraphQLUsersResponse has no errors field. If Linear answers this lookup with HTTP 200 plus GraphQL errors (for example a resolver or permission error), nodes comes back empty. The user is then reported as not found, and CreateAccount sends a duplicate invite, which skips the idempotency check this PR adds. GetPendingOrganizationInvite has the same gap. Consider embedding GraphQLError in both lookup responses and returning an error when HasErrors() is true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 675b981: both lookups now embed GraphQLError and return an error when HasErrors() is true, so a failed lookup no longer looks like "not found". Added tests for each.

Comment thread pkg/linear/client.go Outdated
if !res.Data.OrganizationInvites.PageInfo.HasNextPage {
return nil, nil
}
after = res.Data.OrganizationInvites.PageInfo.EndCursor

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.

🟡 Suggestion (low confidence): if Linear ever returns hasNextPage: true with an empty endCursor, after resets to "" and the loop refetches page 1 until the context is cancelled. Guard with if after == "" { return nil, error }. Also note that every CreateAccount now pages through the workspace's entire invite history, accepted invites included. That cost grows over time, so a server-side filter would help if the API supports one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 675b981: pagination now returns an error if endCursor is empty or unchanged. On cost: Linear's organizationInvites query takes no filter argument, so a client-side scan is the only option. It runs only on account creation, not during sync.

Comment thread pkg/linear/client.go Outdated
return resp, rlData, uhttp.WrapErrorsWithRateLimitInfo(codes.Unavailable, resp, err)
}

if gqlErr.HasErrors() {

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.

🟡 Suggestion (medium confidence): uhttp.WithErrorResponse (vendor pkg/uhttp/wrapper.go:531-563) already returns "Request failed with status %d: " + resource.Message(), and Do carries that into err. Appending gqlErr.Message() here prints the message twice, e.g. ...status 400: Argument Validation Error: Argument Validation Error. Consider dropping this append; non-rate-limit HTTP errors already included Linear's message before this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 675b981: removed the extra wrap, since WithErrorResponse already includes the message. The existing TestDoRequestBadRequest still checks that Linear's message appears in the error.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 675b9811d9d4

Connector PR Review: fix: make account creation idempotent and surface Linear API errors

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 852e86113e3d.
Review mode: full
View review run

Review Summary

Before inviting, CreateAccount now looks the email up in two places:

  • Users, case-insensitively and including suspended users. An active user returns AlreadyExistsResult with the org as parent; a suspended user returns an error.
  • Workspace invites, paginated. An unaccepted, unexpired invite returns ActionRequiredResult.

Two error-handling changes come with it:

  • CreateOrganizationInvite now includes Linear's GraphQL error when it gets success=false on a 200.
  • doRequest now treats a 400 as a retryable Unavailable rate limit only when Linear sends RATELIMITED or a X-RateLimit-*-Remaining: 0 header. Other 400s come back as non-retryable errors.

I scanned the full PR diff for security and correctness and checked it against the vendored uhttp behavior. The repo-local criteria loaded:

  • Provisioning and error-code mapping (E2): applied. 429 and rate-limited 400s map to Unavailable, which is correct.
  • JSON nullability (J3): applied. A null acceptedAt decodes to "", which is handled.
  • Sync-path rules (S/R7) and log-level rules (A): not relevant. The PR touches no sync methods and adds no logging.

No resource types, entitlements, IDs or scopes changed, so there are no breaking-change concerns. No dependency changes.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • New (medium confidence), pkg/linear/client.go:603-620: GetUserByEmail decodes into GraphQLUsersResponse, which has no errors field. A 200 response carrying GraphQL errors looks like "user not found", so CreateAccount sends a duplicate invite and the new idempotency check is skipped. GetPendingOrganizationInvite (:626-685) has the same gap.
  • New (low confidence), pkg/linear/client.go:683: the invite pagination loop has no guard for hasNextPage: true with an empty endCursor, which would refetch page 1 until the context is cancelled. Every CreateAccount also pages through the workspace's entire invite history.
  • New (medium confidence), pkg/linear/client.go:1197-1199: uhttp.WithErrorResponse already puts resource.Message() in the error (vendor pkg/uhttp/wrapper.go:561). Appending gqlErr.Message() again prints Linear's message twice.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/linear/client.go`:
- Around line 603-620 (GetUserByEmail) and 626-685 (GetPendingOrganizationInvite): neither response struct captures GraphQL `errors` returned with HTTP 200, so a failed lookup looks like "not found" and CreateAccount sends a duplicate invite. Embed `GraphQLError` in both response structs (as CreateOrganizationInvite does) and return an error when `res.HasErrors()` is true.
- Around line 683: if `hasNextPage` is true but `endCursor` is empty, the loop restarts from page 1 forever. Return an error when the next cursor is empty or the same as the current one.
- Around line 1197-1199 (doRequest): uhttp.WithErrorResponse already includes `gqlErr.Message()` in `err` ("Request failed with status N: <message>"). Remove the extra `fmt.Errorf("%w: %s", err, gqlErr.Message())` wrap so the message is not duplicated, or confirm it adds information.

Reviewed commit: f8cd61163262

@github-actions github-actions Bot 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.

No blocking issues found — see the full review report

…ation

Return an error when the user or invite lookup carries GraphQL errors
instead of treating it as not found, stop invite pagination if the cursor
does not advance, and drop the duplicated error message in doRequest.
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 675b9811d9d4

Connector PR Review: fix: make account creation idempotent and surface Linear API errors

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 852e86113e3d.
Review mode: incremental since f8cd611
View review run

Review Summary

Before sending an invite, CreateAccount now looks up the email among workspace users (including suspended users) and then among pending invites. It returns AlreadyExistsResult for an active user, an error for a suspended user, and ActionRequiredResult for a pending invite. doRequest now retries a 400 only when Linear marks it as a rate limit. The new commit makes two changes:

  • GetUserByEmail and GetPendingOrganizationInvite now capture GraphQL errors returned with HTTP 200, and the invite pagination stops with an error if the cursor doesn't advance.
  • The extra message wrap in doRequest is removed.

I scanned the full PR diff (5 files) for security and correctness problems; the incremental diff was complete, with nothing dropped or truncated. I applied the repo criteria where they fit:

  • E2 (error-code mapping): rate-limited 400s and 429s map to Unavailable.
  • J3 (nullable JSON): a null acceptedAt decodes to "", which the code handles.
  • P4/E3 (provisioning error codes): one gap, listed below.
  • S/R7 (empty sync results): not applicable, because no List, Entitlements or Grants method changed.
  • B10 (API spec): the two new read-only queries are new endpoints rather than changed ones, and the repo has no spec/openapi.json.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • New (medium confidence), pkg/connector/user.go:182: the suspended-user error is a plain fmt.Errorf, so it reaches C1 as Unknown. Under P4/E3, a provisioning failure that retrying can't fix should carry codes.FailedPrecondition.

Resolved prior findings

  • Fixed: GraphQL errors on a 200 response were read as "not found". Both lookups now embed GraphQLError and return an error when HasErrors() is true (pkg/linear/client.go:610-621 and :660-675). TestGetUserByEmail and TestGetPendingOrganizationInvite both have a "graphql error" case.
  • Fixed: the invite pagination could loop forever. It now returns an error when endCursor is empty or the same as the previous cursor (pkg/linear/client.go:693-697). The "cursor does not advance" test covers this.
  • Fixed: the Linear error message appeared twice. The extra wrap in doRequest is gone (pkg/linear/client.go:1197-1211). uhttp.WithErrorResponse still includes the message (vendor wrapper.go:555), and the TestDoRequestBadRequest case for an invalid request checks that it does.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/user.go`:
- Around line 182: CreateAccount returns a plain fmt.Errorf when the email belongs to a suspended Linear user, so the error reaches C1 as codes.Unknown. Return status.Errorf(codes.FailedPrecondition, "baton-linear: %s already has a suspended Linear account, which must be reactivated rather than invited", email) so the provisioning failure has a meaningful gRPC code. Retrying won't help until the account is reactivated.

Comment thread pkg/connector/user.go Outdated
}
if existing != nil {
if !existing.Active {
return nil, nil, nil, fmt.Errorf("baton-linear: %s already has a suspended Linear account, which must be reactivated rather than invited", email)

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.

🟡 Suggestion (medium confidence): this provisioning error has no gRPC status code, so it comes through as Unknown (criteria P4/E3). Retrying won't fix it until someone reactivates the account, so status.Errorf(codes.FailedPrecondition, ...) would show the failure correctly in C1.

@russellhaering russellhaering Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1fe4c2a: now returns codes.FailedPrecondition, and TestUserCreateAccount_SuspendedUser asserts the code.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 1fe4c2a68b48

Connector PR Review: fix: make account creation idempotent and surface Linear API errors

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 852e86113e3d.
Review mode: incremental since f8cd611
View review run

Review Summary

Before sending an invite, CreateAccount now looks up the email among workspace users (including suspended users) and then among pending invites. It returns AlreadyExistsResult for an active user, an error for a suspended user, and ActionRequiredResult for a pending invite. doRequest now retries a 400 only when Linear marks it as a rate limit. The new commit makes two changes:

  • GetUserByEmail and GetPendingOrganizationInvite now capture GraphQL errors returned with HTTP 200, and the invite pagination stops with an error if the cursor doesn't advance.
  • The extra message wrap in doRequest is removed.

I scanned the full PR diff (5 files) for security and correctness problems; the incremental diff was complete, with nothing dropped or truncated. I applied the repo criteria where they fit:

  • E2 (error-code mapping): rate-limited 400s and 429s map to Unavailable.
  • J3 (nullable JSON): a null acceptedAt decodes to "", which the code handles.
  • P4/E3 (provisioning error codes): one gap, listed below.
  • S/R7 (empty sync results): not applicable, because no List, Entitlements or Grants method changed.
  • B10 (API spec): the two new read-only queries are new endpoints rather than changed ones, and the repo has no spec/openapi.json.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • New (medium confidence), pkg/connector/user.go:182: the suspended-user error is a plain fmt.Errorf, so it reaches C1 as Unknown. Under P4/E3, a provisioning failure that retrying can't fix should carry codes.FailedPrecondition.

Resolved prior findings

  • Fixed: GraphQL errors on a 200 response were read as "not found". Both lookups now embed GraphQLError and return an error when HasErrors() is true (pkg/linear/client.go:610-621 and :660-675). TestGetUserByEmail and TestGetPendingOrganizationInvite both have a "graphql error" case.
  • Fixed: the invite pagination could loop forever. It now returns an error when endCursor is empty or the same as the previous cursor (pkg/linear/client.go:693-697). The "cursor does not advance" test covers this.
  • Fixed: the Linear error message appeared twice. The extra wrap in doRequest is gone (pkg/linear/client.go:1197-1211). uhttp.WithErrorResponse still includes the message (vendor wrapper.go:555), and the TestDoRequestBadRequest case for an invalid request checks that it does.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/user.go`:
- Around line 182: CreateAccount returns a plain fmt.Errorf when the email belongs to a suspended Linear user, so the error reaches C1 as codes.Unknown. Return status.Errorf(codes.FailedPrecondition, "baton-linear: %s already has a suspended Linear account, which must be reactivated rather than invited", email) so the provisioning failure has a meaningful gRPC code. Retrying won't help until the account is reactivated.

Reviewed commit: 675b9811d9d4

@github-actions github-actions Bot 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.

No blocking issues found — see the full review report

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor
Superseded — see the current review report for commit 1fe4c2a68b48

Connector PR Review: fix: make account creation idempotent and surface Linear API errors

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 852e86113e3d.
Review mode: incremental since 675b981
View review run

Review Summary

Before inviting, CreateAccount now looks the user up by email. An active user returns AlreadyExistsResult, a suspended user returns an error, and a pending, unexpired invite returns ActionRequiredResult. GraphQL error messages from Linear are now included in the returned errors. A 400 is retried as a rate limit only when Linear marks it (RATELIMITED error code or an exhausted X-RateLimit-*-Remaining header).

The new commit (1fe4c2a) makes the suspended-user error a status.Errorf(codes.FailedPrecondition, ...) at pkg/connector/user.go:184 and adds a status-code assertion to TestUserCreateAccount_SuspendedUser. It also splits one long test fixture onto several lines for lint, with the same content.

I rescanned the whole PR diff for security and correctness. In doRequest, resp is nil-checked before the status code is read. defer closeResponse is nil-safe. The invite pagination stops if the cursor doesn't advance and passes ctx on every request.

I applied the repo criteria:

  • E2: rate limits map to Unavailable.
  • P3/P4: account creation is idempotent, and the suspended case now carries a gRPC code.
  • J3: a null acceptedAt/expiresAt decodes to "", which the code handles.
  • S/R7 and F2: do not apply, because no List/Entitlements/Grants method changed. The invite pagination runs only during account creation, not during a sync.
  • B10: the two read-only queries are new rather than changed, and the repo has no spec/openapi.json.

The incremental diff was complete (partial: false). There are no coverage gaps.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Resolved prior findings

  • Fixed: the suspended-user error reached C1 as Unknown (P4/E3). It now returns codes.FailedPrecondition (pkg/connector/user.go:184), and pkg/connector/user_test.go:273-275 checks the code.
  • Earlier fixes are still in place: GraphQL errors on a 200 response are now returned instead of treated as "not found", the invite pagination stops if the cursor doesn't advance, and the Linear error message no longer appears twice.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Connector PR Review: fix: make account creation idempotent and surface Linear API errors

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 852e86113e3d.
Review mode: incremental since 675b981
View review run

Review Summary

Before inviting, CreateAccount now looks the user up by email. An active user returns AlreadyExistsResult, a suspended user returns an error, and a pending, unexpired invite returns ActionRequiredResult. GraphQL error messages from Linear are now included in the returned errors. A 400 is retried as a rate limit only when Linear marks it (RATELIMITED error code or an exhausted X-RateLimit-*-Remaining header).

The new commit (1fe4c2a) makes the suspended-user error a status.Errorf(codes.FailedPrecondition, ...) at pkg/connector/user.go:184 and adds a status-code assertion to TestUserCreateAccount_SuspendedUser. It also splits one long test fixture onto several lines for lint, with the same content.

I rescanned the whole PR diff for security and correctness. In doRequest, resp is nil-checked before the status code is read. defer closeResponse is nil-safe. The invite pagination stops if the cursor doesn't advance and passes ctx on every request.

I applied the repo criteria:

  • E2: rate limits map to Unavailable.
  • P3/P4: account creation is idempotent, and the suspended case now carries a gRPC code.
  • J3: a null acceptedAt/expiresAt decodes to "", which the code handles.
  • S/R7 and F2: do not apply, because no List/Entitlements/Grants method changed. The invite pagination runs only during account creation, not during a sync.
  • B10: the two read-only queries are new rather than changed, and the repo has no spec/openapi.json.

The incremental diff was complete (partial: false). There are no coverage gaps.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Resolved prior findings

  • Fixed: the suspended-user error reached C1 as Unknown (P4/E3). It now returns codes.FailedPrecondition (pkg/connector/user.go:184), and pkg/connector/user_test.go:273-275 checks the code.
  • Earlier fixes are still in place: GraphQL errors on a 200 response are now returned instead of treated as "not found", the invite pagination stops if the cursor doesn't advance, and the Linear error message no longer appears twice.

Reviewed commit: 1fe4c2a68b48

@github-actions github-actions Bot 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.

No blocking issues found — see the full review report

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.

1 participant