Skip to content

feat: add experimental Jira poll input driver - #5778

Merged
waynesun09 merged 57 commits into
mainfrom
jira-poll-input-driver
Aug 3, 2026
Merged

feat: add experimental Jira poll input driver#5778
waynesun09 merged 57 commits into
mainfrom
jira-poll-input-driver

Conversation

@ralphbean

@ralphbean ralphbean commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

  • Jira REST API client (internal/forge/jira/) with Basic and Bearer auth
  • Jira poll driver (internal/jirapoll/) implementing ADR 0063's write-then-verify coordination protocol
  • CLI wiring for fullsend poll --input-driver jira-poll
  • Cursor-based pagination for the Jira search/JQL API
  • Mock Jira server and behaviour test infrastructure (pkg/behaviourtest/drivers/jiramock/)
  • Getting-started guide (docs/guides/user/jira-integration.md) with example workflow including dispatch step

The poller converts Jira comments and label changes into NormalizedEvents and routes them through the existing HarnessRouter, so built-in agents (triage, code, review, etc.) work without modification. The example workflow includes a shell-based dispatch step that reads dispatches.json and calls gh workflow run for each record — a follow-up will wire this directly into the poller.

Note: agent pre/post scripts don't understand Jira yet, so dispatched agents won't actually complete successfully. That's a follow-up.

Addresses #5758, #2264, #2265, #2268.
Related to #2269, #4885, #2513, #2266, #2267.

Test plan

  • go test ./internal/forge/jira/... — client unit tests (auth, pagination, error handling, entity properties)
  • go test ./internal/jirapoll/... — poller unit tests (lock contention, stale cleanup, change detection, bot filtering, deduplication, role mapping)
  • go test ./pkg/behaviourtest/... — behaviour test infrastructure (mock server, step definitions, slash command dispatch, label change dispatch)
  • Manual test against a real Jira Cloud instance with personal API token

🤖 Generated with Claude Code

ralphbean and others added 9 commits July 30, 2026 15:47
Implement a Jira poll input driver per ADR 0063, enabling fullsend
agents to be triggered from Jira issue events (comments, label changes,
status transitions) via `fullsend poll --input-driver jira-poll`.

- internal/forge/jira/: Jira Cloud REST API v3 client with dual auth
  (Basic/Bearer), retry with backoff, and project role membership lookup
- internal/jirapoll/: Poll engine with write-then-verify lock
  coordination via Jira entity properties, change detection, ADF text
  extraction, NormalizedEvent conversion per jira-poll-adapter spec,
  and actor role resolution from Jira project roles
- internal/cli/poll.go: CLI wiring with --input-driver jira-poll flag
  and Jira-specific flags (--jira-url, --jira-project, --jql,
  --target-repo)
- docs/plans/jira-poll-input-driver.md: Implementation plan with
  search/jql migration notes and OAuth 2.0 client credentials plan
- e2e/behaviour/features/dispatch/jira-poll-dispatch.feature: Gherkin
  scenarios for mock-based behaviour testing

Known issues documented in plan:
- search/jql API needs cursor pagination (nextPageToken) and expand
  as string not array
- OAuth 2.0 client credentials auth needed for managed Atlassian
  instances that restrict personal API tokens

Signed-off-by: Ralph Bean <rbean@redhat.com>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
Users need an OAuth 2.0 service account (OpenID client credentials)
from their Atlassian org admin — personal API tokens are typically
restricted on managed instances and will not work.

Signed-off-by: Ralph Bean <rbean@redhat.com>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
Add NewOAuth2 constructor and oauth2TokenSource to support OAuth 2.0
two-legged (client credentials) auth as an alternative to Basic/Bearer
auth with API tokens. The token source caches access tokens and
refreshes automatically 5 minutes before expiry.

CLI wiring reads JIRA_AUTH_METHOD to select between oauth2
(JIRA_CLIENT_ID + JIRA_CLIENT_SECRET) and the existing Basic/Bearer
path (JIRA_TOKEN + JIRA_USER_EMAIL). Default behavior is unchanged.

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
The POST /rest/api/3/search/jql endpoint (which replaced the removed
GET /rest/api/3/search) uses cursor-based pagination and different
request conventions. This commit fixes all three breaking changes:

- expand is now a comma-delimited string, not a JSON array
- pagination uses nextPageToken/isLast instead of startAt/total
- fields explicitly requests ["*all"] (default is IDs only)

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
GetEntityProperty returns forge.ErrNotFound (404) when a property
doesn't exist yet, which is the normal state on first poll. readLock
and readLastCheck were treating this as an error, causing all issues
to be skipped as "locked". Now they return nil/zero for 404, correctly
treating missing properties as "unlocked" / "first poll".

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
Jira timestamps have millisecond precision (e.g. 19:23:30.556) but
lastCheck was stored and parsed with time.RFC3339 which truncates to
whole seconds. This caused events at X.556s to pass the
createdAt.After(lastCheck at X.000s) check on every subsequent poll.

Switch to RFC3339Nano for both storage and parsing, with a fallback
to RFC3339 for values written before this change.

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
- Update mock GetEntityProperty to return forge.ErrNotFound for
  missing properties, matching real Jira API behavior
- TestReadLock_NotFound: verifies readLock returns nil (unlocked)
  when property doesn't exist
- TestReadLastCheck_NotFound: verifies readLastCheck returns zero
  time when property doesn't exist
- TestLastCheck_SubSecondPrecision: verifies RFC3339Nano round-trip
  preserves milliseconds and prevents re-dispatch
- TestRunFirstPoll_NoLockProperty: full poll cycle with no prior
  entity properties (first-poll scenario)
- Fix setLastCheck helper to use RFC3339Nano

Assisted-by: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
Add a mock Jira REST API server (httptest-based, stateful) and Gherkin
step definitions for testing the Jira poll input driver's dispatch path.

- jiramock: stateful httptest server implementing search, comments,
  changelog, entity property CRUD, myself, and project role endpoints
- jirapoll steps: Given/When/Then steps for mock setup, issue creation,
  comment and label manipulation, poller execution, and dispatch
  assertions
- World extensions: JiraMockServer, JiraMockState, JiraConfigDir fields
  for per-scenario isolation with cleanup
- Feature file: two scenarios exercising slash-command and label-change
  dispatch paths against the mock server

The tests run the real poller logic (jira.Client → Poller → Router →
dispatch records) against the mock server, validating the full
integration without external dependencies.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
Covers prerequisites, credential setup (Basic auth primary, OAuth 2.0
as untested alternative), scheduled workflow with dispatch step, poll
coordination, and troubleshooting. The example workflow polls Jira on
a cron, writes dispatch records, then triggers agent workflows via
gh workflow run.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@ralphbean ralphbean added the fullsend-fix Enables automatic bot-triggered fix runs on human-authored PRs label Jul 30, 2026
@ralphbean
ralphbean requested a review from a team as a code owner July 30, 2026 20:51
@ralphbean ralphbean added the fullsend-fix Enables automatic bot-triggered fix runs on human-authored PRs label Jul 30, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 30, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 8:52 PM UTC · Completed 9:11 PM UTC
Commit: 291780a · View workflow run →

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Site preview

Preview: https://7db663d7-site.fullsend-ai.workers.dev

Commit: 1223573c32660e43cc9cdb1317ef9695b6500e38

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add Jira poll input driver with Jira REST client, dispatch output, and tests

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add jira-poll driver to translate Jira issue changes into NormalizedEvents for routing.
• Implement Jira REST API v3 client with token/OAuth2 auth, retries, and cursor pagination.
• Add mock Jira behaviour tests and publish a Jira integration guide with Actions workflow example.
Diagram

graph TD
  A["fullsend poll (CLI)"] --> B["JiraPoll Poller"] --> C["Jira REST client"] --> D{{"Jira API"}}
  B --> E["HarnessRouter"] --> F["dispatches.json"] --> G{{"GitHub Actions"}}
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use Jira webhooks instead of polling
  • ➕ Lower latency and less API load than scheduled search + change scanning
  • ➕ Avoids distributed locking/lastCheck state
  • ➖ Requires inbound service + auth validation + delivery retries
  • ➖ Conflicts with ADR 0063 polling-first direction
2. Store coordination state externally (Redis/DB)
  • ➕ Avoids requiring Jira issue property write permissions
  • ➕ Centralized observability and simpler lock semantics
  • ➖ Adds operational dependency and secrets management
  • ➖ Less portable than self-contained Jira entity properties
3. Adopt an existing Jira Go SDK
  • ➕ Less custom HTTP plumbing and fewer corner cases to maintain
  • ➖ SDKs may lag Jira API behavior and still need custom pagination/auth
  • ➖ Adds dependency surface; current client is small and purpose-built

Recommendation: Within ADR 0063’s polling model and the goal of avoiding new infrastructure, using Jira entity properties for write-then-verify locking plus lastCheck is a good fit. The main follow-up is manual validation on real Jira instances (especially OAuth2 client credentials); after that, keep this architecture and treat webhooks/external state as scalability options.

Files changed (24) +5851 / -5

Enhancement (8) +1784 / -4
poll.goAdd '--input-driver jira-poll' and Jira poll execution path +88/-4

Add '--input-driver jira-poll' and Jira poll execution path

• Extends 'fullsend poll' to support a Jira input driver alongside the existing GitLab path. Adds Jira flags/env fallbacks and builds a Jira client (PAT/Basic/Bearer or OAuth2 client credentials) before running the Jira poller and writing dispatch output.

internal/cli/poll.go

client.goIntroduce Jira REST API v3 client (auth, retries, pagination) +620/-0

Introduce Jira REST API v3 client (auth, retries, pagination)

• Adds a Jira client supporting Basic (Cloud), Bearer (PAT/DC), and OAuth2 client-credentials with cached token refresh. Implements retries/backoff, redirect auth stripping, cursor-based JQL pagination, and APIs needed by the poller (comments, changelog, entity properties, role membership, myself).

internal/forge/jira/client.go

types.goAdd Jira API data model types +123/-0

Add Jira API data model types

• Defines structs for issues, comments (ADF or string body), changelog, cursor-paginated search results, entity property wrappers, and project role responses. These types support the client and poller API surface.

internal/forge/jira/types.go

convert.goNormalize Jira events into NormalizedEvent for routing +187/-0

Normalize Jira events into NormalizedEvent for routing

• Converts discovered Jira events into 'dispatch.NormalizedEvent' per the adapter spec, including slash-command extraction, label transitions, and truncation. Resolves actor role from Jira project-role membership and marks bot vs human actors.

internal/jirapoll/convert.go

discover.goDetect comments, label diffs, and status transitions since lastCheck +259/-0

Detect comments, label diffs, and status transitions since lastCheck

• Implements change detection by listing comments and changelog entries and filtering them by parsed Jira timestamps. Adds label diffing logic, status transition mapping, and ADF text extraction for comment bodies.

internal/jirapoll/discover.go

lock.goAdd entity-property locking and lastCheck tracking per ADR 0063 +148/-0

Add entity-property locking and lastCheck tracking per ADR 0063

• Implements write-then-verify lock acquisition with jitter, stale lock detection/cleanup, and per-target-repo entity property keys. Stores and advances 'lastCheck' timestamps to ensure only new changes dispatch.

internal/jirapoll/lock.go

poller.goImplement Jira poll cycle orchestration and dispatch file output +277/-0

Implement Jira poll cycle orchestration and dispatch file output

• Runs a single poll cycle: search candidates (JQL), filter/cleanup locks, select issues, detect changes, deduplicate, filter bots, route via router, and write dispatch records to JSON. Loads project role membership for actor role resolution.

internal/jirapoll/poller.go

types.goDefine JiraClient interface, poller options, and JiraEvent model +82/-0

Define JiraClient interface, poller options, and JiraEvent model

• Introduces the Jira client interface used by the poller, poller configuration options (M/N thresholds, stale lock threshold, JQL/project, output path), and the intermediate JiraEvent/LockValue types used for dedupe and coordination.

internal/jirapoll/types.go

Tests (13) +3300 / -0
jira-poll-dispatch.featureAdd behaviour scenarios for Jira poll → dispatch records +27/-0

Add behaviour scenarios for Jira poll → dispatch records

• Adds Gherkin scenarios that run the real poller against a local mock Jira server. Validates that slash-command comments and label additions produce expected dispatch stages.

e2e/behaviour/features/dispatch/jira-poll-dispatch.feature

poll_test.goTest Jira poll CLI validation and env fallbacks +101/-0

Test Jira poll CLI validation and env fallbacks

• Adds table-driven tests for 'runJiraPoll' validation errors when required Jira settings are missing. Ensures a minimally valid configuration fails with a non-validation error (e.g., connectivity) rather than missing-flag checks.

internal/cli/poll_test.go

client_test.goAdd Jira client unit tests with httptest server +789/-0

Add Jira client unit tests with httptest server

• Verifies auth header modes, JQL cursor pagination request/response handling, and error mapping to forge errors. Exercises endpoint behaviors and request shapes (e.g., 'expand' as a string).

internal/forge/jira/client_test.go

convert_test.goValidate NormalizedEvent conversion against fixtures +576/-0

Validate NormalizedEvent conversion against fixtures

• Adds tests that compare conversion output to a documented JSON fixture and cover command parsing and role mapping. Helps keep code and normative docs aligned.

internal/jirapoll/convert_test.go

poller_test.goAdd extensive poller tests (locking, stale cleanup, dispatch generation) +1018/-0

Add extensive poller tests (locking, stale cleanup, dispatch generation)

• Provides a mock JiraClient and comprehensive tests for lock contention/staleness, lastCheck semantics, deduplication, bot filtering, routing behavior, and error handling. Skips jitter sleeps to keep tests deterministic and fast.

internal/jirapoll/poller_test.go

server.goAdd stateful mock Jira REST server for behaviour tests +374/-0

Add stateful mock Jira REST server for behaviour tests

• Implements an httptest-backed Jira API server with in-memory state for issues, comments, changelog, and entity properties. Exposes helpers to mutate state between requests to drive end-to-end poller scenarios.

pkg/behaviourtest/drivers/jiramock/server.go

server_test.goTest mock Jira server endpoints and concurrency safety +148/-0

Test mock Jira server endpoints and concurrency safety

• Validates that the mock server supports search, comment/changelog listing, entity property CRUD, and project role membership. Includes a concurrent access test to ensure state is protected under parallel operations.

pkg/behaviourtest/drivers/jiramock/server_test.go

cleanup.goClean up Jira mock resources after scenarios +10/-0

Clean up Jira mock resources after scenarios

• Extends behaviour scenario cleanup to close the Jira mock server and remove the temporary config directory created for Jira poll scenarios.

pkg/behaviourtest/steps/cleanup.go

jirapoll.goAdd Godog steps for Jira poll scenarios and assertions +176/-0

Add Godog steps for Jira poll scenarios and assertions

• Adds steps to start the mock Jira server, create issues, add comments/labels, run the poller, and assert dispatch output contains expected stages. Builds a minimal '.fullsend' layout and uses HarnessRouter for realistic routing.

pkg/behaviourtest/steps/jirapoll.go

jirapoll_test.goAdd direct tests for Jira poll behaviour steps +70/-0

Add direct tests for Jira poll behaviour steps

• Adds Go tests exercising the step helpers for slash-command dispatch, label dispatch, no-dispatch on opened-only issues, and missing-server errors. Confirms the behaviour test harness works without running full Godog suites.

pkg/behaviourtest/steps/jirapoll_test.go

registry.goRegister Jira poll steps in behaviour step registry +1/-0

Register Jira poll steps in behaviour step registry

• Hooks Jira poll step registration into the global Godog step registry so the new feature scenarios can run.

pkg/behaviourtest/steps/registry.go

init.goReset Jira mock fields between behaviour scenarios +3/-0

Reset Jira mock fields between behaviour scenarios

• Clears Jira mock server/state/config dir fields during scenario world reset to prevent cross-scenario contamination.

pkg/behaviourtest/suite/init.go

world.goExtend behaviour-test World with Jira mock state fields +7/-0

Extend behaviour-test World with Jira mock state fields

• Adds World fields for the Jira mock server, its backing state, and the temporary config directory used by Jira poll scenarios. Imports the mock driver types required by the new steps.

pkg/behaviourtest/world/world.go

Documentation (3) +767 / -1
jira-integration.mdAdd pre-alpha Jira integration setup guide +215/-0

Add pre-alpha Jira integration setup guide

• Documents how scheduled GitHub Actions runs 'fullsend poll --input-driver jira-poll', required secrets, and a sample workflow. Includes a shell dispatch step that reads 'dispatches.json' and triggers agent workflows via 'gh workflow run', plus notes on OAuth2 as an alternative.

docs/guides/user/jira-integration.md

jira-poll-adapter.mdClarify Jira-derived 'actor.role' semantics +1/-1

Clarify Jira-derived 'actor.role' semantics

• Updates the adapter spec to derive 'actor.role' strictly from Jira project roles (no cross-system identity/permission mapping). Establishes the Jira project as the authorization boundary for Jira-sourced events.

docs/normative/normalized-event/v1/jira-poll-adapter.md

jira-poll-input-driver.mdAdd implementation plan for Jira poll input driver +551/-0

Add implementation plan for Jira poll input driver

• Adds a detailed phased plan (client → poller → CLI), including dependency graph, coordination protocol, pagination notes, and troubleshooting guidance. Captures known issues and decisions for future follow-ups.

docs/plans/jira-poll-input-driver.md

@qodo-code-review

qodo-code-review Bot commented Jul 30, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 54 rules

Grey Divider


Action required

1. jira-integration.md missing guides index ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
A new guide was added under docs/guides/, but docs/guides/README.md was not updated to include
it. This breaks the required guides index and makes the new guide harder to discover.
Code

docs/guides/user/jira-integration.md[1]

+# Jira Integration
Relevance

●●● Strong

Guide index updates are expected; many doc PRs add guide then update docs/guides/README.md (e.g.,
#665, #1179).

PR-#665
PR-#1179
PR-#1190

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR adds a new guide at docs/guides/user/jira-integration.md, but the existing user guides
index in docs/guides/README.md does not include an entry for it, violating the requirement to
update the index when adding a new guide.

docs/guides/user/jira-integration.md[1-5]
docs/guides/README.md[31-44]
Skill: writing-user-docs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A new guide file was added under `docs/guides/`, but the guides index file `docs/guides/README.md` was not updated to list it.

## Issue Context
Compliance requires that `docs/guides/README.md` be updated whenever a new guide is added under `docs/guides/`.

## Fix Focus Areas
- docs/guides/README.md[31-44]
- docs/guides/user/jira-integration.md[1-5]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Checkpoint can stall ✓ Resolved 🐞 Bug ≡ Correctness
Description
jirapoll.Poller.processIssue returns early when detectChanges returns zero events, so it never
advances lastCheck. If Jira changed since lastCheck but those changes are ignored (e.g., unsupported
changelog fields) or skipped (e.g., unparseable timestamps), the poller will repeatedly lock and
rescan the same updates every cycle without making progress.
Code

internal/jirapoll/poller.go[R182-184]

+	if len(events) == 0 {
+		return nil
+	}
Relevance

●● Moderate

No historical evidence found: referenced internal/jirapoll paths don’t exist on default branch, so
no prior review pattern.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The poller currently exits before checkpoint advancement when no events are produced, and the change
discovery code can observe Jira updates without emitting any events for them (unsupported changelog
fields are ignored), creating a repeatable no-progress loop.

internal/jirapoll/poller.go[171-185]
internal/jirapoll/poller.go[227-235]
internal/jirapoll/discover.go[80-99]
internal/jirapoll/discover.go[102-135]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`processIssue` returns when `len(events)==0`, skipping `advanceLastCheck`. When Jira has changes since the previous checkpoint that do **not** produce routable `JiraEvent`s (unsupported changelog fields, entries skipped due to timestamp parse errors), the poller keeps reacquiring the lock and re-fetching the same changelog/comments every poll cycle.

## Issue Context
`detectChanges` emits events only for comments and a subset of changelog fields (labels/status/summary/description). Any other field change after `lastCheck` yields no events, and currently there is no "max seen timestamp" to advance the checkpoint past such changes.

## Fix
Track the latest observed change timestamp during discovery and advance `lastCheck` to that value even when no routable events are generated.

Suggested implementation approach:
- In `detectChanges`, keep `maxSeen time.Time` updated for every comment/changelog entry that passes the `createdAt.After(lastCheck)` filter, regardless of whether it maps to a `JiraEvent`.
- Return `(events, maxSeen, error)` (or store `maxSeen` on the poller) and update `processIssue` to:
 - If `len(events)==0` and `maxSeen` is non-zero, call `advanceLastCheck(..., maxSeen)` before returning.
- Add a regression test where the changelog contains an unsupported field (e.g. `assignee`) with a timestamp after `lastCheck`, and assert that the lastCheck entity property is advanced.

## Fix Focus Areas
- internal/jirapoll/poller.go[171-185]
- internal/jirapoll/discover.go[30-107]
- internal/jirapoll/discover.go[102-135]
- internal/jirapoll/poller_test.go[194-260]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. NormalizedEvent not defined ✓ Resolved 📜 Skill insight ✧ Quality
Description
The guide introduces jargon (e.g., NormalizedEvent) without an inline definition or a link to the
glossary on first use. This can confuse readers and violates the documentation jargon requirements.
Code

docs/guides/user/jira-integration.md[13]

+3. Converts each change to a NormalizedEvent (the same event shape GitHub and GitLab use).
Relevance

●●● Strong

Team frequently enforces defining/clarifying NormalizedEvent-related terms via links/tables in docs
(#5532).

PR-#5532
PR-#2650

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule requires jargon to be defined or linked on first use. The guide introduces
NormalizedEvent without any inline definition or glossary link at its first occurrence.

docs/guides/user/jira-integration.md[11-15]
Skill: writing-user-docs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The guide uses domain-specific jargon (e.g., `NormalizedEvent`) without defining it on first use or linking to a glossary entry.

## Issue Context
Documentation guides must define jargon on first use via a glossary link (e.g., `../../glossary.md#...`) or an inline parenthetical definition.

## Fix Focus Areas
- docs/guides/user/jira-integration.md[11-15]
- docs/guides/user/jira-integration.md[213-215]
- docs/glossary.md[1-200]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Jira guide uses prose steps ✓ Resolved 📜 Skill insight ✧ Quality
Description
The guide includes procedural instructions (e.g., creating a workflow file) written as prose instead
of numbered steps. This violates the requirement that procedures use numbered steps for clarity and
consistency.
Code

docs/guides/user/jira-integration.md[R68-71]

+## Scheduled workflow
+
+Create `.github/workflows/fullsend-poll-jira.yml`:
+
Relevance

●●● Strong

Procedures-as-numbered-steps has been accepted previously (rewrite prose imperatives into ordered
steps in #2663).

PR-#2663
PR-#2277

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule requires procedures to be written as ordered lists. The guide contains
imperative procedural instructions such as Create .github/workflows/fullsend-poll-jira.yml: as
prose rather than a numbered step list.

docs/guides/user/jira-integration.md[68-71]
Skill: writing-user-docs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Procedural instructions in the Jira integration guide are written as prose (e.g., "Create ...") instead of numbered (ordered) steps.

## Issue Context
Documentation guide procedures must be expressed as numbered steps, not narrative paragraphs.

## Fix Focus Areas
- docs/guides/user/jira-integration.md[37-71]
- docs/guides/user/jira-integration.md[68-90]
- docs/guides/user/jira-integration.md[171-189]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Unit test hits network ✓ Resolved 🐞 Bug ☼ Reliability
Description
TestRunJiraPoll_Validation calls runJiraPoll with a public Jira hostname; when validation passes it
runs the real poller, which performs HTTP requests. This makes unit tests non-deterministic and
potentially slow/flaky in CI environments without reliable outbound network access.
Code

internal/cli/poll_test.go[R153-156]

+			cmd := &cobra.Command{}
+			cmd.SetContext(context.Background())
+
+			err := runJiraPoll(cmd, tc.jiraURL, tc.jiraProject, tc.jqlOverride, tc.targetRepo, "", fullsendDir)
Relevance

●●● Strong

Repo strongly prefers deterministic tests; they added stubs to prevent “real” executions/hangs in
tests (#2986).

PR-#2986
PR-#2391

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test sets a real Jira Cloud URL and then calls runJiraPoll; runJiraPoll unconditionally creates
the Jira client and runs the poller, which calls into JiraClient.SearchIssues (HTTP via
jira.Client).

internal/cli/poll_test.go[83-157]
internal/cli/poll.go[114-153]
internal/jirapoll/poller.go[65-78]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`TestRunJiraPoll_Validation` is intended to verify validation behavior, but the "valid minimal config" case proceeds to build a real Jira client and executes `poller.Run`, causing outbound HTTP calls.

## Issue Context
`runJiraPoll` does not have a pure validation phase; it always constructs the Jira client/router and runs the poller after basic argument checks.

## Fix
Make the test validate inputs without hitting the network.

Options (pick one):
1. Extract a pure `validateJiraPollArgs(...) error` helper and unit test that directly; keep `runJiraPoll` calling it before doing any work.
2. Dependency-inject a Jira client/poller factory into `runJiraPoll` so tests can pass a stub that does not perform HTTP.
3. Point the test to an `httptest.Server` (local loopback) with minimal handlers and ensure it returns quickly and deterministically.

## Fix Focus Areas
- internal/cli/poll_test.go[83-179]
- internal/cli/poll.go[114-153]
- internal/jirapoll/poller.go[65-78]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
6. Guide restates architecture inline ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The guide explains system architecture/flow in-line without linking to docs/architecture.md, ADRs,
or other architectural references. This increases the risk of the guide diverging from the
authoritative architecture docs.
Code

docs/guides/user/jira-integration.md[R7-16]

+## How it works
+
+A scheduled GitHub Actions workflow runs `fullsend poll --input-driver jira-poll` on a cron. Each cycle:
+
+1. Queries Jira for recently updated issues in your project.
+2. Detects new comments and label changes since the last poll.
+3. Converts each change to a NormalizedEvent (the same event shape GitHub and GitLab use).
+4. Routes through the standard agent routing rules.
+5. Writes dispatch records that trigger agent workflows.
+
Relevance

●● Moderate

Some precedent for linking to architecture docs (#770), but no clear rule enforcement for guide “How
it works” sections.

PR-#770

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule requires guides to link to architectural references rather than restating architecture
inline. The guide’s ## How it works section describes the system flow (polling, normalization,
routing, dispatch) without any link to architecture references.

docs/guides/user/jira-integration.md[7-16]
Skill: writing-user-docs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Jira integration guide contains an inline "How it works" architectural explanation instead of linking to authoritative architecture references.

## Issue Context
Guides should avoid restating architecture and should link to ADRs/specs/`docs/architecture.md` for architectural context.

## Fix Focus Areas
- docs/guides/user/jira-integration.md[7-16]
- docs/guides/user/jira-integration.md[191-199]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread docs/guides/user/jira-integration.md
Comment thread docs/guides/user/jira-integration.md
Comment thread docs/guides/user/jira-integration.md
Comment thread docs/guides/user/jira-integration.md Outdated
Comment thread internal/jirapoll/poller.go Outdated
Comment thread internal/cli/poll_test.go Outdated
@ralphbean

Copy link
Copy Markdown
Member Author

@ralphbean ralphbean changed the title feat: add Jira poll input driver feat: add experimental Jira poll input driver Jul 30, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [logic-error] internal/jirapoll/poller.go:205searchCandidates uses fmt.Sprintf with %q to quote the project key in JQL, producing project = "PROJ". In Jira JQL, a double-quoted string is resolved as a project name rather than a project key. If the user's Jira project has a name that differs from its key (e.g., key PROJ but name "My Project"), the generated JQL may not match the intended project. Since --jira-project is documented as accepting a project key and the validProjectKey regex (^[A-Z][A-Z0-9]{1,9}$) already prevents JQL injection, unquoted interpolation with %s is safe and semantically correct. The test TestSearchCandidatesQuotesProjectKey explicitly asserts the double-quoted form, so both the code and the test need updating together.
    Remediation: Change fmt.Sprintf("project = %q AND ...") to fmt.Sprintf("project = %s AND ...") and update the test to expect project = PROJ AND statusCategory != Done ORDER BY updated DESC.

Low

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:149 — The example GHA workflow interpolates ${STAGE} and ${RESOURCE_KEY} into a ::warning:: workflow command without sanitization. Both values are constrained (STAGE from the router, RESOURCE_KEY from Jira issue key format issue-{JIRA_KEY} with uppercase-alphanumeric keys), so practical injection risk is negligible. The pattern is a minor template-hygiene concern for users who may extend the workflow with less-constrained data.

  • [authorization-boundary-gap] internal/jirapoll/convert.go:209resolveRole maps Jira project roles to ADR 0054 authorization roles using Jira project membership as the sole authorization boundary. No cross-system identity resolution (Jira user → GitHub user → repo permission) is performed. Documented as a known limitation in the user guide with the "Jira membership is not GitHub membership" warning section. Fail-closed for actors not in any project role (returns external). Cross-project guard correctly fails closed to external for issues outside the configured project.

  • [authorization-role-mapping] internal/jirapoll/convert.go:241mapJiraRole maps Jira project role names to ADR 0054 roles using case-insensitive string matching. Jira roles are admin-customizable. Fail-closed default for unknown role names (returns read) is correct. Documented as intentional for the MVP.

  • [race-condition] internal/jirapoll/lock.go:88 — TOCTOU race in releaseLock: reads lock property, checks ID match, then deletes. A concurrent poller could acquire the lock between the check and the delete. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [error-handling] internal/jirapoll/poller.go:346 — In processIssue, when routing fails for an event, the event is skipped via continue, but the checkpoint advances past all inspected entries regardless. A transiently failing routing error causes the event to be permanently skipped once lastCheck advances. The code acknowledges this trade-off with explicit comments.

  • [test-adequacy] internal/jirapoll/convert_test.go — No test for the case where JiraProject is empty (--jql-only mode) and the roleMembership map is non-empty. In this configuration, Run() sets roleMembership to an empty map when JiraProject is empty, so actors resolve to external by construction. The invariant is validated only by code inspection.
    Remediation: Add a test case where JiraProject is empty but roleMembership is non-empty.

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The Jira poll extractCommand correctly implements first-line-only instruction extraction, matching the spec text. However, the GitLab poll adapter (internal/poll/convert.go) uses full-body instruction extraction. The two poll adapters diverge on NormalizedEvent.Transition.Comment.Instruction.
    Remediation: Align the GitLab poll adapter to first-line-only extraction, or update the spec to clarify that instruction semantics intentionally differ across adapters.

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses status != Done while the implementation and user guide use statusCategory != Done. The ADR example is misleading because status != Done only matches a literal status named "Done", while statusCategory != Done matches all statuses in the Done category regardless of naming.
    Remediation: Update to statusCategory != Done.

  • [error-message-style] internal/cli/poll.go:140 — Error messages in validateJiraPollArgs use a slightly different formatting convention from the existing GitLab path. The --output error includes a verbose explanatory clause about the footgun (checkpoints advancing without dispatches). Minor cosmetic inconsistency.

  • [logging-idiom] internal/forge/jira/client.go, internal/jirapoll/convert.go — Both packages use stdlib log.Printf for warnings. If the rest of the codebase uses structured logging (slog), consider migrating these for consistency.

  • [package-placement] internal/forge/jira/client.go — The Jira REST client is placed under internal/forge/jira/ but does not implement the forge.Client interface (unlike the GitHub and GitLab clients in sibling packages). Architecturally justified since Jira is not a git forge, but a doc comment clarifying this would prevent confusion.

Previous run

Review

Findings

Medium

  • [logic-error] internal/jirapoll/poller.go:225searchCandidates uses fmt.Sprintf with %q to quote the project key in JQL, producing project = "PROJ". In Jira JQL, a quoted string is resolved as a project name rather than a project key. If the user's Jira project has a name that differs from its key (e.g., key PROJ but name "My Project"), the generated JQL may not match the intended project. Since --jira-project is documented as accepting a project key and the validProjectKey regex (^[A-Z][A-Z0-9]{1,9}$) already prevents JQL injection, unquoted interpolation with %s is safe and semantically correct.
    Remediation: Change fmt.Sprintf("project = %q AND ...") to fmt.Sprintf("project = %s AND ..."). The input validation makes %s safe here.

Low

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:167 — The example GHA workflow interpolates ${STAGE} and ${RESOURCE_KEY} into a ::warning:: workflow command without sanitization. Both values are constrained (STAGE from the router, RESOURCE_KEY from Jira issue key format issue-{JIRA_KEY} with uppercase-alphanumeric keys), so practical injection risk is negligible. The pattern is a minor template-hygiene concern for users who may extend the workflow with less-constrained data.

  • [authorization-boundary-gap] internal/jirapoll/convert.go:209resolveRole maps Jira project roles to ADR 0054 authorization roles using Jira project membership as the sole authorization boundary. No cross-system identity resolution (Jira user → GitHub user → repo permission) is performed. Documented as a known limitation in the user guide with the "Jira membership is not GitHub membership" warning section. Fail-closed for actors not in any project role (returns external). Cross-project guard correctly fails closed to external for issues outside the configured project.

  • [authorization-role-mapping] internal/jirapoll/convert.go:241mapJiraRole maps Jira project role names to ADR 0054 roles using case-insensitive string matching. Jira roles are admin-customizable. Fail-closed default for unknown role names (returns read) is correct. Documented as intentional for the MVP.

  • [race-condition] internal/jirapoll/lock.go:88 — TOCTOU race in releaseLock: reads lock property, checks ID match, then deletes. A concurrent poller could acquire the lock between the check and the delete. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [error-handling] internal/jirapoll/poller.go:248 — In processIssue, when routing fails for an event, the event is skipped via continue, but the checkpoint advances past all inspected entries regardless. A transiently failing routing error causes the event to be permanently skipped once lastCheck advances. The code acknowledges this trade-off with explicit comments.

  • [test-adequacy] internal/jirapoll/convert_test.go — No test for the case where JiraProject is empty (--jql-only mode) and the roleMembership map is non-empty. In this configuration, resolveRole skips the project-key guard and falls through to the membership lookup, meaning any actor with a mapped role would resolve using that role regardless of which project the issue belongs to. The code path is safe by construction (the poller only populates roleMembership when JiraProject is set), but the invariant is validated only by code inspection.
    Remediation: Add a test case where JiraProject is empty but roleMembership is non-empty.

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The Jira poll extractCommand correctly implements first-line-only instruction extraction, matching the spec text. However, the GitLab poll adapter (internal/poll/convert.go) uses full-body instruction extraction. The two poll adapters diverge on NormalizedEvent.Transition.Comment.Instruction.
    Remediation: Align the GitLab poll adapter to first-line-only extraction, or update the spec to clarify that instruction semantics intentionally differ across adapters.

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses status != Done while the implementation and user guide use statusCategory != Done. The ADR example is misleading because status != Done only matches a literal status named "Done", while statusCategory != Done matches all statuses in the Done category regardless of naming.
    Remediation: Update to statusCategory != Done.

  • [error-message-style] internal/cli/poll.go:140 — Error messages in validateJiraPollArgs use a slightly different formatting convention from the existing GitLab path. The --output error includes a verbose explanatory clause about the footgun (checkpoints advancing without dispatches). Minor cosmetic inconsistency.

  • [logging-idiom] internal/forge/jira/client.go, internal/jirapoll/convert.go — Both packages use stdlib log.Printf for warnings. If the rest of the codebase uses structured logging (slog), consider migrating these for consistency.

  • [package-placement] internal/forge/jira/client.go — The Jira REST client is placed under internal/forge/jira/ but does not implement the forge.Client interface (unlike the GitHub and GitLab clients in sibling packages). Architecturally justified since Jira is not a git forge, but a doc comment clarifying this would prevent confusion.


Labels: PR adds substantial Go code (Jira REST client, poll driver, CLI wiring, tests) and the go label is consistently applied to Go PRs per repo conventions.

Previous run (2)

Review

Findings

Medium

  • [authorization-boundary-gap] internal/jirapoll/convert.go:209resolveRole maps Jira project roles to ADR 0054 authorization roles using Jira project membership as the sole authorization boundary. No cross-system identity resolution (Jira user → GitHub user → repo permission) is performed. A Jira user holding the "Developers" role can trigger write-gated slash commands against the GitHub repository even without GitHub repo access. Cross-project guard at line 214 correctly fails closed to external when an issue's project key doesn't match --jira-project. Documented as a known limitation in the PR body and user guide with appropriate warnings. Fail-closed for actors not in any project role (returns external).

Low

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The Jira poll extractCommand correctly implements first-line-only instruction extraction, matching the spec text. However, the GitLab poll adapter (internal/poll/convert.go) uses full-body instruction extraction. The two poll adapters diverge on NormalizedEvent.Transition.Comment.Instruction: Jira produces first-line-only, GitLab produces multi-line.
    Remediation: Align the GitLab poll adapter to first-line-only extraction, or update the spec to clarify that instruction semantics intentionally differ across adapters.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:167 — The example GHA workflow interpolates ${STAGE} and ${RESOURCE_KEY} into a ::warning:: workflow command without sanitization. The current data flow is safe (STAGE from router output, RESOURCE_KEY constrained to issue-{PROJECT_KEY}-{ID}), but the pattern is an imperfect template for users who may extend it with richer Jira-derived data.

  • [authorization-role-mapping] internal/jirapoll/convert.go:241mapJiraRole maps Jira project role names to ADR 0054 roles using case-insensitive string matching. Jira roles are admin-customizable. Fail-closed default for unknown role names (returns read) is correct. Documented as intentional for the MVP.

  • [race-condition] internal/jirapoll/lock.go:88 — TOCTOU race in releaseLock: reads lock property, checks ID match, then deletes. A concurrent poller could acquire the lock between the check and the delete. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [error-handling] internal/jirapoll/poller.go:248 — In processIssue, when routing fails for an event, the event is skipped via continue, but maxTime is initialized to result.maxSeen. A transiently failing routing error causes the event to be permanently skipped once lastCheck advances. The code acknowledges this trade-off with explicit comments.

  • [test-adequacy] internal/jirapoll/convert_test.go — No test for the case where JiraProject is empty (--jql-only mode) and the roleMembership map is non-empty. The code path is safe by construction (poller only populates roleMembership when JiraProject is set), but the invariant is validated only by code inspection.
    Remediation: Add a test case where JiraProject is empty but roleMembership is non-empty.

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses status != Done while the implementation and user guide use statusCategory != Done. The ADR example is misleading because status != Done only matches a literal status named "Done".
    Remediation: Update to statusCategory != Done.

Previous run (3)

Review

Findings

Medium

  • [authorization-boundary-gap] internal/jirapoll/convert.go:197resolveRole maps Jira project roles to ADR 0054 authorization roles without cross-referencing GitHub repo permissions. A Jira user holding the "Developers" role can trigger write-gated slash commands against the GitHub repository even without GitHub repo access. Documented as a known limitation in the PR body and user guide with appropriate warnings. Fail-closed for actors not in any project role (returns "external").

Low

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The Jira poll extractCommand correctly implements first-line-only instruction extraction, matching the spec text. However, the GitLab poll adapter (internal/poll/convert.go) uses full-body instruction extraction. The two poll adapters diverge on NormalizedEvent.Transition.Comment.Instruction: Jira produces first-line-only, GitLab produces multi-line.
    Remediation: Align the GitLab poll adapter to first-line-only extraction, or update the spec to clarify that instruction semantics intentionally differ across adapters.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:145 — The example GHA workflow interpolates ${STAGE} and ${RESOURCE_KEY} into a ::warning:: workflow command without sanitization. The current data flow is safe (STAGE from router output, RESOURCE_KEY constrained to issue-{PROJECT_KEY}-{ID}), but the pattern is an imperfect template for users who may extend it with richer Jira-derived data.

  • [authorization-role-mapping] internal/jirapoll/convert.go:229mapJiraRole maps Jira project role names to ADR 0054 roles using string matching. Jira roles are admin-customizable. Fail-closed default for unknown role names (returns "read") is correct. Documented as intentional for the MVP.

  • [race-condition] internal/jirapoll/lock.go:88 — TOCTOU race in releaseLock: reads lock property, checks ID match, then deletes. A concurrent poller could acquire the lock between the check and the delete. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [error-handling] internal/jirapoll/poller.go:248 — In processIssue, when routing fails for an event, the event is skipped via continue, but maxTime is initialized to result.maxSeen. A transiently failing routing error causes the event to be permanently skipped once lastCheck advances. The code acknowledges this trade-off with explicit comments.

  • [test-adequacy] internal/jirapoll/convert_test.go — No test for the case where JiraProject is empty (--jql-only mode) and the roleMembership map is non-empty. The code path is safe by construction (poller only populates roleMembership when JiraProject is set), but the invariant is validated only by code inspection.

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses status != Done while the implementation and user guide use statusCategory != Done. The ADR example is misleading because status != Done only matches a literal status named "Done".
    Remediation: Update to statusCategory != Done.

  • [error-message-consistency] internal/forge/jira/client.go:94 — Constructor errors use jira: prefix while APIError uses jira api:. Minor inconsistency in new code.

Previous run (4)

Review

Findings

Medium

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The normative spec at lines 58–61 states: "Comment parsing matches the gha-event adapter: command is the first whitespace-delimited token of the first line; instruction is the remainder of the first line after the command." However, the Jira poll extractCommand in convert.go computes instruction as trimmed[len(command):] — the full body after the command token, preserving multi-line content. This matches the GitLab poll adapter (internal/poll/convert.go) but diverges from the gha-event adapter (ghaevent.go:425–439), which restricts instruction to first-line-only. The spec claims gha-event matching but the code matches GitLab poll behavior.
    Remediation: Update jira-poll-adapter.md lines 58–61 to accurately describe that instruction is the text after the command token in the full comment body, matching the GitLab poll adapter.

Low

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example at line 225 uses the outdated JQL pattern status != Done while the implementation (searchCandidates in poller.go) and user guide (jira-integration.md) both use statusCategory != Done. The ADR example is misleading because status != Done only matches a literal status named "Done", while statusCategory != Done matches all statuses in the Done category regardless of naming.
    Remediation: Update line 225 of ADR 0063 to use statusCategory != Done.

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [error-handling] internal/jirapoll/poller.go:165 — In processIssue, when routing fails for an event, the event is skipped via continue, but maxTime has already been advanced past it (initialized to result.maxSeen). A transiently failing routing error causes the event to be permanently skipped. The code acknowledges this trade-off with an explicit comment. No metric or counter is emitted for skipped events.

  • [test-adequacy] internal/jirapoll/convert_test.go:167 — No test for the case where JiraProject is empty (--jql-only mode) and the roleMembership map is non-empty. The current behavior is correct (empty membership map means all lookups return external), but the test gap means it is validated only by code inspection.

Previous run (5)

Review

Findings

Medium

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The normative spec states "instruction is the remainder of the first line after the command (same rules as README)." The Jira poll extractCommand in convert.go computes instruction as the full body after the command token (trimmed[len(command):]), not just the first line. The gha-event adapter (ghaevent.go:425) uses first-line-only extraction. Both poll adapters (Jira and GitLab) now use full-body behavior, but the gha-event adapter does not, and the spec claims they match.
    Remediation: Update jira-poll-adapter.md lines 58–61 to state that instruction is the text after the command token in the full comment body, preserving multi-line content. If the gha-event adapter should also use full-body extraction, update it to match; if first-line-only is intentional for gha-event, document the divergence.

Low

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses the outdated JQL pattern status != Done while the implementation, user guide, and plan doc were all updated to use statusCategory != Done in this push.
    Remediation: Update line 225 of ADR 0063 to use statusCategory != Done.

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [edge-case] internal/jirapoll/discover.go:62 — On first poll, the synthetic opened event is emitted unconditionally regardless of issue age, while comments/changelog entries outside the FirstPollBackfillWindow are suppressed.

Previous run (6)

Review

Findings

Medium

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The jira-poll-adapter spec states that instruction is "the remainder of the first line after the command (same rules as README)." The extractCommand function in convert.go computes instruction as the full body after the command token (trimmed[len(command):]), not just the first line. Both adapters (Jira and GitLab) now use full-body behavior, but the normative spec text was not updated.
    Remediation: Update jira-poll-adapter.md lines 58–61 to state that instruction is the text after the command token in the full comment body, preserving multi-line content.

Low

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses the outdated JQL pattern status != Done while the implementation, user guide, and plan doc were all updated to use statusCategory != Done in this push.
    Remediation: Update line 225 of ADR 0063 to use statusCategory != Done.

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [edge-case] internal/jirapoll/discover.go:62 — On first poll, the synthetic opened event is emitted unconditionally regardless of issue age, while comments/changelog entries outside the FirstPollBackfillWindow are suppressed.

  • [edge-case] internal/jirapoll/poller.go:49FirstPollBackfillWindow cannot be set to zero (disabled) because New() overwrites the zero value with the 24h default.

  • [scope-creep] internal/jirapoll/poller.go — The shell-based dispatch step advances lastCheck before confirming successful downstream scheduling, creating a silent failure mode. Known MVP simplification documented in the KNOWN LIMITATION comment, with follow-up tracked.
    Remediation: Add a troubleshooting entry warning that failed downstream dispatch steps result in silently dropped events.

  • [logic-error] internal/jirapoll/poller.go:129 — In searchCandidates, the default JQL uses Go's %q verb to quote the project key. Safe in practice because validProjectKey restricts input to [A-Z][A-Z0-9_]{1,9}, but %q uses Go-specific escape sequences not valid in JQL — fragile if the regex is ever relaxed.

  • [edge-case] internal/jirapoll/poller.go:86 — The roleMembership map is loaded once per poll cycle for p.opts.JiraProject. When using --jql that spans projects, actors from other projects default to external (fail-closed, safe). The user guide's Custom JQL section already documents this limitation.

  • [api-contract] internal/forge/jira/client.go:224defer resp.Body.Close() is called inside the retry for loop in do(). Currently safe because both branches return immediately, but a maintenance hazard if future changes add a loop continuation after these lines.
    Remediation: Replace defer resp.Body.Close() with explicit resp.Body.Close() calls, or extract the HTTP-call-and-decode into a separate function.


Labels: PR adds behaviour test infrastructure (mock Jira server, step definitions, feature file)

Previous run (7)

Review

Findings

Medium

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The jira-poll-adapter spec states that instruction is "the remainder of the first line after the command (same rules as README)." The extractCommand function in convert.go computes instruction as the full body after the command token (trimmed[len(command):]), not just the first line. Both adapters (Jira and GitLab) now use full-body behavior, but the normative spec text was not updated.
    Remediation: Update jira-poll-adapter.md lines 58–61 to state that instruction is the text after the command token in the full comment body, preserving multi-line content.

Low

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses the outdated JQL pattern status != Done while the implementation, user guide, and plan doc were all updated to use statusCategory != Done in this push.
    Remediation: Update line 225 of ADR 0063 to use statusCategory != Done.

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [race-condition] internal/forge/jira/client.go:88 — The oauth2TokenSource's singleflight.Do passes the first caller's context to the shared refreshToken call. If that caller's context is cancelled, all concurrent waiters receive the cancellation error. Self-healing via the retry loop in do().

  • [edge-case] internal/jirapoll/discover.go:62 — On first poll, the synthetic opened event is emitted unconditionally regardless of issue age, while comments/changelog entries outside the FirstPollBackfillWindow are suppressed.

  • [edge-case] internal/jirapoll/poller.go:49FirstPollBackfillWindow cannot be set to zero (disabled) because New() overwrites the zero value with the 24h default.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:176 — Example workflow emits ::warning::/::notice:: interpolating ${STAGE} and ${RESOURCE_KEY} from trusted fullsend output. Prior grep -qE concern resolved (now uses grep -qxF).

  • [ssrf] internal/forge/jira/client.go:238validateBaseURL allows non-HTTPS for loopback addresses to support httptest. Base URL is operator-controlled (CLI flag/env var).

  • [scope-creep] internal/cli/poll.go — Dispatch records written to JSON file with a shell-based downstream step, vs ADR 0063's specified gha-dispatch output driver. Acknowledged in PR body as a follow-up and documented in the KNOWN LIMITATION comment in processIssue.

Previous run (8)

Review

Findings

Medium

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The jira-poll-adapter spec states that instruction is "the remainder of the first line after the command (same rules as README)." The extractCommand function in convert.go computes instruction as the full body after the command token (trimmed[len(command):]), not just the first line. Both adapters (Jira and GitLab) now use full-body behavior, but the normative spec text was not updated.
    Remediation: Update jira-poll-adapter.md lines 58–61 to state that instruction is the text after the command token in the full comment body, preserving multi-line content.

  • [stale-example] docs/ADRs/0063-polling-based-work-discovery.md:225 — ADR 0063 example uses the outdated JQL pattern status != Done while the implementation, user guide, and plan doc were all updated to use statusCategory != Done in this push.
    Remediation: Update line 225 of ADR 0063 to use statusCategory != Done.

Low

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [race-condition] internal/forge/jira/client.go:88 — The oauth2TokenSource's singleflight.Do passes the first caller's context to the shared refreshToken call. If that caller's context is cancelled, all concurrent waiters receive the cancellation error. Self-healing via the retry loop in do().

  • [edge-case] internal/jirapoll/discover.go:62 — On first poll, the synthetic opened event is emitted unconditionally regardless of issue age, while comments/changelog entries outside the FirstPollBackfillWindow are suppressed.

  • [edge-case] internal/jirapoll/poller.go:49FirstPollBackfillWindow cannot be set to zero (disabled) because New() overwrites the zero value with the 24h default.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:176 — Example workflow emits ::warning::/::notice:: interpolating ${STAGE} and ${RESOURCE_KEY} from trusted fullsend output. Prior grep -qE concern resolved (now uses grep -qxF).

  • [ssrf] internal/forge/jira/client.go:238validateBaseURL allows non-HTTPS for loopback addresses to support httptest. Base URL is operator-controlled (CLI flag/env var).

  • [scope-creep] internal/cli/poll.go — Dispatch records written to JSON file with a shell-based downstream step, vs ADR 0063's specified gha-dispatch output driver. Acknowledged in PR body as a follow-up and documented in the KNOWN LIMITATION comment in processIssue.

Previous run (9)

Review

Findings

Medium

  • [spec-implementation-mismatch] docs/normative/normalized-event/v1/jira-poll-adapter.md:59 — The jira-poll-adapter spec states that instruction is "the remainder of the first line after the command (same rules as README)." The incremental change to extractCommand in convert.go deliberately changed behavior from first-line-only to full-body: instruction now preserves all lines after the command token. Both adapters (Jira and GitLab) now use full-body behavior, but the spec text was not updated. Since the adapter spec is normative, the spec should be corrected.
    Remediation: Update jira-poll-adapter.md lines 58–61 to state that instruction is the text after the command token in the full comment body, preserving multi-line content.

Low

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. Inherent to Jira's non-atomic entity property API; mitigated by downstream GHA workflow concurrency groups.

  • [race-condition] internal/forge/jira/client.go:88 — The oauth2TokenSource's singleflight.Do passes the first caller's context to the shared refreshToken call. If that caller's context is cancelled, all concurrent waiters receive the cancellation error. Self-healing via the retry loop in do().

  • [edge-case] internal/jirapoll/discover.go:62 — On first poll, the synthetic opened event is emitted unconditionally regardless of issue age, while comments/changelog entries outside the FirstPollBackfillWindow are suppressed.

  • [edge-case] internal/jirapoll/poller.go:49FirstPollBackfillWindow cannot be set to zero (disabled) because New() overwrites the zero value with the 24h default.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:176 — Example workflow emits ::warning::/::notice:: interpolating ${STAGE} and ${RESOURCE_KEY} from trusted fullsend output. Prior grep -qE concern resolved (now uses grep -qxF).

  • [fail-open] internal/jirapoll/poller.go:86 — When GetProjectRoleMembership fails, continues with empty membership map (all actors resolve to external). Fail-closed for most dispatch paths; residual risk limited to IsEntityAuthor triage bypass.

  • [ssrf] internal/forge/jira/client.go:238validateBaseURL allows non-HTTPS for loopback addresses to support httptest. Base URL is operator-controlled (CLI flag/env var).

  • [scope-creep] internal/cli/poll.go — Dispatch records written to JSON file with a shell-based downstream step, vs ADR 0063's specified gha-dispatch output driver. Acknowledged in PR body as a follow-up and documented in the KNOWN LIMITATION comment in processIssue.

Previous run (10)

Review

Findings

Medium

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. This is a known limitation of the write-then-verify protocol operating on Jira entity properties, which lack compare-and-swap semantics. Mitigated by downstream GHA workflow concurrency groups that prevent duplicate agent runs.

Low

  • [race-condition] internal/forge/jira/client.go:66 — The oauth2TokenSource's singleflight.Do passes the first caller's context to the shared refreshToken call. If that caller's context is cancelled, all concurrent waiters receive the cancellation error. Self-healing via the retry loop in do(), but may cause transient authentication failures under concurrent cancellation.
    Remediation: Consider a context-aware singleflight wrapper, or accept as a known limitation given the retry loop.

  • [architectural-coherence] internal/forge/jira/client.go — The Jira client lives under internal/forge/jira/ but does not implement the forge.Client interface. Architecturally justified since Jira is not a git forge, but creates a second client pattern under internal/forge/ that could confuse future contributors.
    Remediation: Add a brief doc comment on the package or LiveClient explaining that Jira is not a git forge and intentionally does not implement forge.Client.

  • [edge-case] internal/jirapoll/discover.go:49 — On first poll, the synthetic opened event is emitted unconditionally regardless of issue age, while comments/changelog entries outside the FirstPollBackfillWindow are suppressed. An issue created months ago will still emit an opened event with no accompanying recent activity events.

  • [edge-case] internal/jirapoll/poller.go:47FirstPollBackfillWindow cannot be set to zero (disabled) because New() overwrites the zero value with the 24h default. Follows the same pattern as M, N, and StaleThreshold, but means there is no way to opt out of backfill filtering on first poll.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:158 — Example workflow emits ::warning::/::notice:: interpolating ${STAGE} and ${RESOURCE_KEY} from trusted fullsend output. Prior grep -qE concern resolved (now uses grep -qxF).

  • [fail-open] internal/jirapoll/poller.go:86 — When GetProjectRoleMembership fails, continues with empty membership map (all actors resolve to external). Fail-closed for most dispatch paths; residual risk limited to IsEntityAuthor triage bypass.

  • [ssrf] internal/forge/jira/client.go:200validateBaseURL allows non-HTTPS for loopback addresses to support httptest. Base URL is operator-controlled (CLI flag/env var).

  • [naming-consistency] internal/jirapoll/types.go:55JiraEvent diverges from GitLab's poll.RoutableEvent naming/location. Reasonable given separate package; minor inconsistency expected before patterns stabilize.

  • [scope-creep] internal/cli/poll.go — Dispatch records written to JSON file with a shell-based downstream step, vs ADR 0063's specified gha-dispatch output driver. Acknowledged in PR body as a follow-up and documented in the KNOWN LIMITATION comment in processIssue.

Previous run (11)

Review

Findings

Medium

  • [race-condition] internal/jirapoll/lock.go:86 — TOCTOU race in releaseLock: the method reads the lock property, checks whether its ID matches expectedID, then deletes. Between the read and delete, a concurrent poller could write a new lock, and the delete would remove the concurrent poller's lock instead. This is a known limitation of the write-then-verify protocol operating on Jira entity properties, which lack compare-and-swap semantics. Mitigated by downstream GHA workflow concurrency groups that prevent duplicate agent runs.

Low

  • [logic-error] internal/jirapoll/poller.go:126 — The default JQL uses status != Done with a literal status name, but mapStatusTransition (discover.go) avoids matching status names because they are fully customizable per project/locale. Projects with non-standard done statuses can override with --jql. Practical impact is limited since most Jira projects use the standard "Done" name.
    Remediation: Use statusCategory != Done instead of status != Done.

  • [edge-case] internal/jirapoll/poller.go:62 — When --jql is provided without --jira-project, GetProjectRoleMembership is skipped and all actors default to external. The external role blocks most dispatch paths. Documented in the user guide.

  • [error-handling-gap] internal/jirapoll/poller.go:103 — When processIssue fails, the cycle returns error only when ALL issues fail. Partial failure returns nil. Deliberate polling resilience.

  • [edge-case] internal/jirapoll/discover.go:49 — On first poll (lastCheck zero), all comments and changelog entries are treated as new events. Self-correcting on next cycle; documented in user guide troubleshooting.

  • [api-contract] internal/jirapoll/convert.go:157extractCommand accepts any / prefix (vs GitLab's /fs- prefix). Non-fullsend slash commands are silently dropped by the router.

  • [api-contract] internal/jirapoll/convert.go:163extractCommand takes instruction from only the first line (vs GitLab taking the full body). Multi-line instructions are truncated.

  • [edge-case] internal/forge/jira/client.go:453SearchIssues uses POST but isIdempotent(POST) returns false, so 5xx errors are not retried. 429 rate-limit retry works for all methods.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:158 — Example workflow emits ::warning::/::notice:: interpolating ${STAGE} and ${RESOURCE_KEY} from trusted fullsend output. Prior grep -qE concern resolved (now uses grep -qxF).

  • [fail-open] internal/jirapoll/poller.go:86 — When GetProjectRoleMembership fails, continues with empty membership map (all actors resolve to external). Fail-closed for most dispatch paths; residual risk limited to IsEntityAuthor triage bypass.

  • [ssrf] internal/forge/jira/client.go:247validateBaseURL allows non-HTTPS for loopback addresses to support httptest. Base URL is operator-controlled (CLI flag/env var).

  • [TOCTOU] internal/jirapoll/lock.go:37 — Write-then-verify lock acquisition uses non-atomic Jira property writes. Mitigated by jitter window (500–1500ms) and downstream concurrency groups.

  • [naming-consistency] internal/jirapoll/types.go:49JiraEvent diverges from GitLab's poll.RoutableEvent naming/location. Minor abstraction inconsistency expected before patterns stabilize.

  • [edge-case] internal/cli/poll.go:37 — Unknown --input-driver value falls through to forge validation. Error message is adequate but could be clearer.

  • [error-message-consistency] internal/cli/poll.go:41 — Error message format differs from existing GitLab error pattern.

  • [code-organization] internal/cli/poll.go:114 — Helper type jiraPollArgs defined between command constructor and execution function; existing pattern places helpers after.


Labels: PR adds new user guide (docs/guides/user/jira-integration.md) and behaviour test feature file

Previous run (12)

Review

Findings

Low

  • [race-condition] internal/forge/jira/client.go:53 — The oauth2TokenSource.token() method has a thundering-herd race on token refresh. When the cached token expires, multiple concurrent goroutines all pass the fast-path check (lines 53–58), release the mutex, then all independently POST to the token endpoint. While functionally correct (the last writer wins), this wastes quota and round-trips under concurrency.
    Remediation: Use singleflight.Group to collapse concurrent refresh attempts into a single HTTP call.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:148 — The example GHA workflow constructs shell variables from Jira-sourced dispatch JSON fields via jq -r and interpolates them into grep and gh workflow run commands. The values originate from the trusted fullsend binary's dispatch output (not raw Jira input), limiting the risk. The grep -qE with ${STAGE} is technically a regex injection vector, though STAGE is constrained to harness-router-produced names.
    Remediation: Use grep -qF (fixed string) instead of grep -qE for the STAGE match.

  • [fail-open] internal/jirapoll/poller.go:62 — When GetProjectRoleMembership fails, the error is logged as WARNING and the poller continues with an empty membership map. All actors resolve to external role, which is the most restrictive non-error role and blocks most dispatch paths. The residual risk is narrow: the IsEntityAuthor bypass still allows needs-info triage dispatch for issue reporters.

  • [edge-case] internal/jirapoll/poller.go:62 — When --jql is provided without --jira-project, GetProjectRoleMembership is skipped and all actors default to external. The user guide now documents this limitation explicitly.

  • [error-handling-gap] internal/jirapoll/poller.go:91 — When processIssue fails, the error is logged and processErrors is incremented. The cycle now returns an error only when ALL selected issues fail. When some issues fail but others succeed, the cycle returns nil.

  • [edge-case] internal/jirapoll/discover.go:49 — On first poll (lastCheck is zero), all comments and changelog entries are treated as new events regardless of age, potentially producing many dispatches for issues with long histories.

  • [api-contract] internal/jirapoll/convert.go:132 — The Jira extractCommand function accepts any command starting with /, while the GitLab poll extractCommand requires the /fs- prefix. Non-fullsend slash commands in Jira would be parsed as commands but routing would fail to match, causing confusing warnings.

  • [api-contract] internal/jirapoll/convert.go:138 — The Jira extractCommand takes the instruction from only the first line, while the GitLab extractCommand takes instruction from the entire body after the command token. Multi-line instructions are truncated by the Jira implementation.

  • [edge-case] internal/forge/jira/client.go:403SearchIssues uses HTTP POST but isIdempotent returns false for POST, so search requests that receive 5xx errors are not retried (only 429 triggers retry for POST). JQL search is read-only but the retry logic treats POST as non-idempotent.

  • [ssrf] internal/forge/jira/client.go:198validateBaseURL allows non-HTTPS for loopback addresses (localhost, 127.0.0.1, ::1) to support httptest servers. The base URL is operator-controlled (CLI flag/env var), limiting this to a misconfiguration concern.

  • [TOCTOU] internal/jirapoll/lock.go:37 — The write-then-verify lock protocol uses non-atomic Jira entity property writes. Two pollers writing during the same jitter window could both believe they own the lock. Mitigated by downstream workflow concurrency groups.

Previous run (13)

Review

Findings

Low

  • [race-condition] internal/forge/jira/client.go:53 — The oauth2TokenSource.token() method has a thundering-herd race on token refresh. When the cached token expires, multiple concurrent goroutines all pass the fast-path check (lines 53–58), release the mutex, then all independently POST to the token endpoint. While functionally correct (the last writer wins), this wastes quota and round-trips under concurrency.
    Remediation: Use singleflight.Group to collapse concurrent refresh attempts into a single HTTP call.

  • [GHA-workflow-command-injection] docs/guides/user/jira-integration.md:148 — The example GHA workflow constructs shell variables from Jira-sourced dispatch JSON fields via jq -r and interpolates them into grep and gh workflow run commands. The values originate from the trusted fullsend binary's dispatch output (not raw Jira input), limiting the risk. The grep -qE with ${STAGE} is technically a regex injection vector, though STAGE is constrained to harness-router-produced names.
    Remediation: Use grep -qF (fixed string) instead of grep -qE for the STAGE match.

  • [fail-open] internal/jirapoll/poller.go:62 — When GetProjectRoleMembership fails, the error is logged as WARNING and the poller continues with an empty membership map. All actors resolve to external role, which is the most restrictive non-error role and blocks most dispatch paths. The residual risk is narrow: the IsEntityAuthor bypass still allows needs-info triage dispatch for issue reporters.

  • [edge-case] internal/jirapoll/poller.go:62 — When --jql is provided without --jira-project, GetProjectRoleMembership is skipped and all actors default to external. The user guide now documents this limitation explicitly.

  • [error-handling-gap] internal/jirapoll/poller.go:91 — When processIssue fails, the error is logged and processErrors is incremented. The cycle now returns an error only when ALL selected issues fail. When some issues fail but others succeed, the cycle returns nil.

  • [edge-case] internal/jirapoll/discover.go:49 — On first poll (lastCheck is zero), all comments and changelog entries are treated as new events regardless of age, potentially producing many dispatches for issues with long histories.

  • [api-contract] internal/jirapoll/convert.go:132 — The Jira extractCommand function accepts any command starting with /, while the GitLab poll extractCommand requires the /fs- prefix. Non-fullsend slash commands in Jira would be parsed as commands but routing would fail to match, causing confusing warnings.

  • [api-contract] internal/jirapoll/convert.go:149 — The Jira extractCommand takes the instruction from only the first line, while the GitLab extractCommand takes instruction from the entire body after the command token. Multi-line instructions are truncated by the Jira implementation.

  • [edge-case] internal/forge/jira/client.go:403SearchIssues uses HTTP POST but isIdempotent returns false for POST, so search requests that receive 5xx errors are not retried (only 429 triggers retry for POST). JQL search is read-only but the retry logic treats POST as non-idempotent.

  • [ssrf] internal/forge/jira/client.go:198validateBaseURL allows non-HTTPS for loopback addresses (localhost, 127.0.0.1, ::1) to support httptest servers. The base URL is operator-controlled (CLI flag/env var), limiting this to a misconfiguration concern.

  • [TOCTOU] internal/jirapoll/lock.go:37 — The write-then-verify lock protocol uses non-atomic Jira entity property writes. Two pollers writing during the same jitter window could both believe they own the lock. Mitigated by downstream workflow concurrency groups.

Previous run (14)

Review

Findings

Medium

  • [race-condition] internal/forge/jira/client.go:59 — The oauth2TokenSource.token() method has a thundering-herd race on token refresh. When the cached token expires, multiple concurrent goroutines all pass the fast-path check (lines 53–58), release the mutex, then all independently POST to the token endpoint. While functionally correct (the last writer wins), this wastes quota and round-trips under concurrency.
    Remediation: Use singleflight.Group to collapse concurrent refresh attempts into a single HTTP call.

Low

  • [fail-open] internal/jirapoll/poller.go:62 — When GetProjectRoleMembership fails, the error is logged as WARNING and the poller continues with an empty membership map. All actors resolve to external role, which is the most restrictive non-error role and blocks most dispatch paths. The residual risk is narrow: the IsEntityAuthor bypass still allows needs-info triage dispatch for issue reporters.

  • [edge-case] internal/jirapoll/poller.go:62 — When --jql is provided without --jira-project, GetProjectRoleMembership is skipped and all actors default to external. The user guide now documents this limitation explicitly.

  • [error-handling-gap] internal/jirapoll/poller.go:91 — When processIssue fails, the error is logged and processErrors is incremented. The cycle now returns an error only when ALL selected issues fail. When some issues fail but others succeed, the cycle returns nil — improved from the prior review which noted zero tracking.

  • [edge-case] internal/jirapoll/discover.go:49 — On first poll (lastCheck is zero), all comments and changelog entries are treated as new events regardless of age, potentially producing many dispatches for issues with long histories.

  • [api-contract] internal/jirapoll/convert.go:132 — The Jira extractCommand function accepts any command starting with /, while the GitLab poll extractCommand requires the /fs- prefix. Non-fullsend slash commands in Jira would be parsed as commands but routing would fail to match, causing confusing warnings.

  • [api-contract] internal/jirapoll/convert.go:149 — The Jira extractCommand takes the instruction from only the first line, while the GitLab extractCommand takes instruction from the entire body after the command token. Multi-line instructions are truncated by the Jira implementation.

  • [edge-case] internal/forge/jira/client.go:403SearchIssues uses HTTP POST but isIdempotent returns false for POST, so search requests that receive 5xx errors are not retried (only 429 triggers retry for POST). JQL search is read-only but the retry logic treats POST as non-idempotent.

  • [ssrf] internal/forge/jira/client.go:198validateBaseURL allows non-HTTPS for loopback addresses (localhost, 127.0.0.1, ::1) to support httptest servers. The base URL is operator-controlled (CLI flag/env var), limiting this to a misconfiguration concern.

  • [TOCTOU] internal/jirapoll/lock.go:37 — The write-then-verify lock protocol uses non-atomic Jira entity property writes. Two pollers writing during the same jitter window could both believe they own the lock. Mitigated by downstream workflow concurrency groups.

  • [api-shape] internal/cli/poll.go:30--forge is no longer required via MarkFlagRequired, but no early validation ensures at least one of --forge or --input-driver is provided. The runtime error message is functional but the UX could be improved with cmd.MarkFlagsOneRequired("forge", "input-driver").

  • [naming-convention] internal/forge/jira/client.go:25 — The Jira client struct is named Client while the established pattern in internal/forge/ uses LiveClient for concrete HTTP client types (see gitlab.go and github.go).

Previous run (15)

Review

Findings

High

  • [missing-doc] website/.vitepress/config.ts:236 — The new user guide docs/guides/user/jira-integration.md was added but the VitePress sidebar configuration uses a manually-maintained list for the "User Guides" section. Without a sidebar entry, the Jira Integration guide will not appear in website navigation. AGENTS.md instructs: "All other sections need a manual { text, link } entry."
    Remediation: Add { text: "Jira Integration", link: "/guides/user/jira-integration" } to the User Guides items in website/.vitepress/config.ts.

Medium

  • [logic-error] internal/jirapoll/poller.go:201 — The comment at line 199 states "Events that fail routing are not counted so they can be retried next cycle", but maxTime is initialized to result.maxSeen at line 201. Since result.maxSeen already encompasses the maximum timestamp across ALL detected events (including those that will fail routing), the per-event if event.UpdatedAt.After(maxTime) check can never advance maxTime beyond result.maxSeen. Consequently, lastCheck always advances past all events regardless of routing failures, making failed-routing events un-retryable on the next cycle.
    Remediation: If routing-failure retry is intended, initialize maxTime to time.Time{} (zero) instead of result.maxSeen, so only successfully-processed events advance the checkpoint. If the current advance-past-everything behavior is intentional (to avoid stalling on persistent routing errors), update the misleading comment.

Low

  • [fail-open] internal/jirapoll/poller.go:55 — When GetProjectRoleMembership fails, the error is logged as WARNING and the code continues with an empty membership map. All actors resolve to external role, which is the most restrictive non-error role and blocks most dispatch paths. The residual risk is narrow: the IsEntityAuthor bypass still allows needs-info triage dispatch for issue reporters.

  • [edge-case] internal/jirapoll/poller.go:82 — When --jql is provided without --jira-project, GetProjectRoleMembership is skipped and all actors default to external. The user guide documents --jql as an alternative but does not mention this role-resolution limitation.

  • [injection-vuln] internal/jirapoll/poller.go:101 — The default JQL uses fmt.Sprintf("project = %s ...") without quoting the project key. The input is admin-controlled (CLI flag), but validating against the expected Jira project key format (^[A-Z][A-Z0-9_]+$) would provide defense in depth.

  • [error-handling-gap] internal/jirapoll/poller.go:113 — When processIssue fails, the error is logged but no error count is tracked. The poll cycle exits with nil error even if all issues failed to process, so CI workflows see exit code 0 despite complete failure.

  • [race-condition] internal/forge/jira/client.go:50 — The oauth2TokenSource.token() method releases the mutex before the HTTP call, allowing concurrent goroutines to each issue independent token refresh requests. The code comment documents this as a deliberate latency optimization; correctness is preserved since all tokens are valid and the cache update is mutex-protected. Follow-up could use singleflight.Group to coalesce concurrent refreshes.

  • [ssrf] internal/forge/jira/client.go:196validateBaseURL only blocks non-HTTPS for non-loopback hosts. The loopback exception (http://localhost) is intended for httptest servers but remains available in production builds. The base URL is operator-controlled (CLI flag/env var), limiting this to a misconfiguration concern rather than an attacker-controlled SSRF vector.

  • [TOCTOU] internal/jirapoll/lock.go:35 — The write-then-verify lock protocol uses non-atomic Jira entity property writes. Two pollers writing during the same jitter window could both believe they own the lock. Similarly, releaseLock (line 72) reads the lock, checks the ID matches, then deletes — a concurrent poller could acquire the lock between read and delete. These are inherent limitations of Jira's non-atomic property API, documented as "safe but wasteful" and mitigated by downstream workflow concurrency groups.

  • [edge-case] internal/jirapoll/discover.go:36 — On first poll (lastCheck is zero), all comments and changelog entries are treated as new events regardless of age, potentially producing many dispatches for issues with long histories. The user guide documents this behavior in the Troubleshooting section ("self-correcting — the next cycle advances lastCheck").

  • [credential-leak-in-redirect] internal/forge/jira/client.go:160CheckRedirect compares the redirect target against via[0] (the original request) rather than via[len(via)-1] (the immediately preceding request). In a multi-hop chain A→B→C where B is cross-origin but C returns to A's host, the auth header would be sent to C despite the cross-origin hop through B. The scenario requires a specific redirect chain topology and the risk is low.

  • [api-shape] internal/cli/poll.go:30--forge is no longer required via MarkFlagRequired, but no early validation ensures at least one of --forge or --input-driver is provided. The runtime error message is functional but the UX could be improved with cmd.MarkFlagsOneRequired("forge", "input-driver").

  • [naming-convention] internal/forge/jira/client.go — The Jira client struct is named Client while the established pattern in internal/forge/ uses LiveClient for concrete HTTP client types (see gitlab.go and github.go).

  • [api-shape] internal/jirapoll/types.goJiraEvent and LockValue types are exported but only consumed within the jirapoll package. The existing GitLab poll package uses unexported types for equivalent intermediates.

  • [code-organization] internal/jirapoll/poller.go — The jirapoll package has no package-level doc comment, unlike internal/poll and internal/forge/jira.

  • [api-shape] internal/forge/jira/client.goSearchIssues in the JiraClient interface has no doc comment noting it exhausts pagination, unlike the GitLabClient interface which documents this contract explicitly.

  • [scope-creep] docs/normative/normalized-event/v1/jira-poll-adapter.md — Normative spec change to actor.role semantics (eliminating cross-system identity resolution in favor of Jira-project-role-only mapping) is not called out in the PR description. The change is architecturally sound but reviewers who skip the spec diff may miss this semantic shift.


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added feature Feature-category issue awaiting human prioritization component/dispatch Workflow dispatch and triggers labels Jul 30, 2026
@fullsend-ai-coder

fullsend-ai-coder Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🤖 Finished Fix · ❌ Failure · Started 9:12 PM UTC · Completed 9:26 PM UTC
Commit: 291780a · View workflow run →

- releaseLock: verify lock ID before deleting to prevent TOCTOU race
  where a slow poller could delete a faster poller's lock
- poller: reset dispatches slice at start of Run() to prevent
  accumulation across multiple calls
- discover: mapStatusTransition uses destination status name from
  changelog instead of current issue statusCategory, fixing incorrect
  transition mapping when multiple status changes occur between polls
- poller: don't advance maxTime past events that fail routing, so
  they can be retried next cycle
- client: WithHTTPClient propagates to oauth2 token source
- client: release mutex before HTTP call in oauth2 token refresh to
  avoid serializing all API calls under slow token endpoints
- client: cap SearchIssues pagination at 200 pages (10k issues)
- feature: remove @requires:jira-mock tag since mock is implemented

Signed-off-by: Ralph Bean <rbean@redhat.com>
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Ralph Bean <rbean@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Jul 30, 2026

Copy link
Copy Markdown

🤖 Review · ❌ Terminated · Started 9:19 PM UTC · Ended 9:37 PM UTC
Commit: ff197bd · View workflow run →

@fullsend-ai-coder

Copy link
Copy Markdown
Contributor

⚠️ Post-fix script failed — Push rejected (exit code 1)

The fix agent completed, but the post-fix script failed before finishing.

Workflow run: https://github.com/fullsend-ai/.fullsend/actions/runs/30582388769

Details:
To https://github.com/fullsend-ai/fullsend.git
! [rejected] jira-poll-input-driver -> jira-poll-input-driver (fetch first)
error: failed to push some refs to 'https://github.com/fullsend-ai/fullsend.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
To https://github.com/fullsend-ai/fullsend.git
! [rejected] jira-poll-input-driver -> jira-poll-input-driver (stale info)
error: failed to push some refs to 'https://github.com/fullsend-ai/fullsend.git'
Please check the workflow logs for full details and retry with /fs-fix if appropriate.

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added the go Pull requests that update go code label Aug 3, 2026
…base URL

Security review findings on the Jira client:

- CheckRedirect stripped Authorization relative to the previous hop, but
  Go's client re-copies the initial request's headers onto every redirect
  hop and only strips them when leaving the initial domain-or-subdomain
  (host only, ignoring scheme). A same-host https->http downgrade chain or
  a subdomain chain therefore re-attached the Basic email:token credential
  on a later hop. This client only talks to one Jira Cloud origin, which
  never legitimately redirects its REST API off-origin, so it now refuses
  any redirect leaving the original scheme+host outright — strictly
  stronger than stripping, and it also removes the SSRF surface of
  following redirects to arbitrary internal hosts. Added same-origin,
  refused-off-origin, and 3-hop re-attach regression tests.

- validateBaseURL now rejects a base URL with embedded credentials
  (https://user:token@host), which would otherwise be stored verbatim,
  propagated into every dispatched event's browse URL, and echoed into
  error text; error messages use url.Redacted() so a password can't leak
  to CI logs.

- Added updateAuthor to the Comment type (consumed by the poller fix).

Assisted-by: Claude (fix), Claude (review), Grok (review)
Signed-off-by: Wayne Sun <gsun@redhat.com>
…its to the editor

Jira entity properties (lock, lastCheck) are writable by anyone with Jira's
Edit-Issues permission — broader than the role this driver maps to write —
so they must be treated as untrusted input:

- readLastCheck clamps the stored checkpoint: a future value is treated as
  unset (it would otherwise silently suppress all detection on the issue),
  and a value rewound before the backfill window is floored at the
  window's start. Without this, a rewind made firstPoll false, bypassing
  the backfill gate and replaying the issue's entire comment history —
  re-dispatching old privileged slash commands under their authors' roles,
  an ADR 0054 gate bypass.

- isLockStale treats a future-dated lock as stale/reclaimable; previously
  time.Since on a future timestamp was negative and never exceeded the
  threshold, so one forged lock property wedged an issue permanently.

- Edit-detected comments are attributed to updateAuthor (the editor), not
  the original author: a user with Edit-All-Comments could otherwise
  rewrite a privileged user's comment to inject a slash command that ran
  under the author's role.

- Per-issue dispatch is capped (maxEventsPerIssue) so a flood — from a
  rewind or a bulk change — can't fan out unbounded agent workflow runs.

- Entity-property lock IDs are logged quoted (log-injection into the CI
  audit trail); the property-key builder escapes dots so two dotted target
  repos (a.b/c vs a/b.c) can't collapse to the same lock/checkpoint
  namespace; a null/unexpected comment body extracts to "" not "<nil>".

Assisted-by: Claude (fix), Claude (review), Grok (review)
Signed-off-by: Wayne Sun <gsun@redhat.com>
Add a security note that Jira Edit-Issues permission confers write access
to the poller's lock/lastCheck coordination properties, explain the clamp
bounds, note that edited comments are attributed to the editor, and record
the per-issue comment byte-budget as a tracked follow-up.

Assisted-by: Claude (fix), Claude (review), Grok (review)
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 11:15 PM UTC · Completed 11:35 PM UTC
Commit: 1223573 · View workflow run →

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All prior review findings (122 threads across multiple automated sweeps and manual review) are resolved as of the latest commits. CI is green across all checks. LGTM.

@waynesun09
waynesun09 added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit e6f01d7 Aug 3, 2026
16 checks passed
@waynesun09
waynesun09 deleted the jira-poll-input-driver branch August 3, 2026 23:52
@fullsend-ai-retro

fullsend-ai-retro Bot commented Aug 3, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 11:54 PM UTC · Completed 12:10 AM UTC
Commit: 1223573 · View workflow run →

@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #5778 — Jira poll input driver

This retro analyzed the full workflow for PR #5778, a large human-authored PR (+8,238 lines, 52 commits, 26 files) by ralphbean adding a Jira poll input driver. The PR took 4 days to merge (Jul 30–Aug 3) and was reviewed by the fullsend review agent (7 cycles), qodo-code-review[bot], and human reviewer waynesun09 (7 review sweeps). The agents repo is fullsend-ai/agents at commit 747566d.

Review quality: significant gap between agent and human

The review agent produced ~16 distinct findings across 7 review cycles (0 HIGH, 2 MEDIUM, 14 LOW). The human reviewer found ~43 findings (11 HIGH, 32 MEDIUM). The agent missed every HIGH-severity issue on this PR, including cross-project privilege escalation, TOCTOU lock bypass, non-functional OAuth2 auth (later removed entirely), unbounded pagination, and credential leak in HTTP redirect handling.

Three specific failure modes stood out:

  1. No plan-doc cross-referencing. The PR included a 490-line implementation plan (docs/plans/jira-poll-input-driver.md) and referenced ADR 0063's coordination protocol. The human reviewer systematically validated the implementation against these documents, finding ~15 spec-compliance gaps. The review agent never referenced them. Evidence for agents#269.

  2. Extreme finding repetition. The same ~12 LOW-severity findings were posted in nearly every review cycle (6–7 times each) without tracking whether they'd been resolved. This generated ~80+ inline comments, mostly duplicates, creating significant noise. Evidence for fullsend#2959, fullsend#5760, and fullsend#2816.

  3. Premature approval. The agent issued APPROVED twice (Jul 31 15:00 and 17:53) while the human reviewer was actively posting HIGH-severity findings (sweeps with 5 HIGH, then 1 HIGH issue). The agent's approval signal was actively misleading — dozens of unresolved issues remained. Evidence for fullsend#5648.

The agent's severity calibration was systematically low: issues the human demonstrated were HIGH (security vulnerabilities, non-functional features, spec violations) were either missed entirely or rated LOW by the agent. Evidence for fullsend#5250 and agents#412.

The agent did contribute some valid LOW-severity findings that led to code changes: naming convention (ClientLiveClient), cobra flag group for --forge/--input-driver mutual exclusivity, extractCommand prefix alignment, SearchIssues 5xx retry, and singleflight token refresh. These were legitimate cleanup items.

Fix workflow: high failure rate is expected but noisy

22 of 30 recent fix.yml runs failed. 19 of those 22 failures (86%) were the policy guard rejecting bot-triggered fixes on human-authored PRs without the fullsend-fix label — working as designed but reported as exit 1 (failure), inflating failure metrics. Already covered by fullsend#5811 and the meta-consolidation issue fullsend#5817.

No new proposals filed

All improvement opportunities identified in this retro are covered by existing open issues. Rather than filing duplicates, the evidence from this PR is documented above for reference. The most impactful existing issues, ordered by expected workflow improvement, are:

  • agents#269 — Review agent should read plan/spec documents and validate implementation completeness
  • fullsend#5648 — Block APPROVED verdict when review contains HIGH-severity findings
  • fullsend#2959 — Deduplicate findings across re-review iterations
  • agents#638 — Correctness subagent should follow ADR references discovered in code
  • fullsend#898 — Review agent misses security-critical findings on large architectural PRs
  • fullsend#5811 — Skip fix dispatch at routing level for human-authored PRs without label

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

Labels

component/dispatch Workflow dispatch and triggers component/docs User-facing documentation component/e2e End-to-end tests feature Feature-category issue awaiting human prioritization fullsend-fix Enables automatic bot-triggered fix runs on human-authored PRs go Pull requests that update go code requires-manual-review Review requires human judgment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants