Skip to content

feat: Add Flue v2 issue triage shadow mode - #19079

Open
sfanahata wants to merge 21 commits into
masterfrom
feat/flue-v2-triage-shadow
Open

feat: Add Flue v2 issue triage shadow mode#19079
sfanahata wants to merge 21 commits into
masterfrom
feat/flue-v2-triage-shadow

Conversation

@sfanahata

@sfanahata sfanahata commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What this does

Builds the GitHub issue triage bot proposed in #17811 on Flue v2. The complete bot is included, but it merges in read-only shadow mode. We review backlog results before enabling writes.

Behavior

Issue Bot action
Employee, actionable, auto-fix eligible Open a validated broken-link or redirect PR; retain High-priority fallback.
Employee, actionable, no auto-fix Set minimum High priority and require an individual owner.
Employee, missing information Ask on GitHub, set minimum High, never auto-close.
External, actionable, auto-fix eligible Open a validated broken-link or redirect PR.
External, actionable, prioritized Route with Urgent, High, Medium, or Low priority.
External, actionable, no priority Add Parking Lot, leave open, and request Docs review in Linear.
External, missing information Ask on GitHub with no priority; close after 14 days without a reply.
  • Specific technical SDK issues move to the owning Linear SDK team and receive matching GitHub labels. Editorial, cross-SDK, and ambiguous work stays with DOCS.
  • High/Urgent issues without an owner get a Linear reminder after seven days.
  • High/Urgent unresolved issues get a Linear reminder after four weeks.
  • External Medium/Low issues inactive in both GitHub and Linear for three months are labeled Parking Lot, moved to Linear Canceled, commented, and closed.
  • Human priority and team overrides are preserved.

Safety gates

Repository variable Effect
FLUE_TRIAGE_MODE=shadow Analyze and upload artifacts only. No writes.
FLUE_TRIAGE_MODE=apply Apply routing, priority, comments, labels, and due lifecycle actions.
FLUE_TRIAGE_AUTO_FIX_ENABLED=true Allow validated broken-link and exact-redirect PRs.

Apply and auto-fix remain disabled unless explicitly enabled. Analysis, issue mutations, and repository-write PR creation run in separately permissioned jobs.

Validation plan

After merge:

  1. Keep FLUE_TRIAGE_MODE=shadow.
  2. Dispatch Triage Backtest for a calibration sample, then the open backlog.
  3. Review the HTML/CSV table for routing, priority, missing information, auto-fix, and Parking Lot candidates.
  4. Record corrections as eval feedback.
  5. Enable apply mode only after the reviewed results meet the agreed thresholds.

The backtest never writes. Immediate “not worth prioritizing” decisions remain open for human Parking Lot review even in apply mode.

Before apply mode, create the GitHub Parking Lot label outside the currently incomplete declarative label catalog.

Verification

  • 272 repository tests pass; 8 live-model evals remain opt-in.
  • 40 focused policy, API, lifecycle, retry, report, and fixer tests pass.
  • TypeScript, ESLint, Prettier, frozen-lockfile install, workflow YAML parsing, Warden, CodeQL, and security checks pass on the current branch.

sergical and others added 17 commits August 19, 2026 15:09
Adds a Flue agent that classifies incoming GitHub issues using existing
label taxonomy (Platform, Product Area, Team, Impact, Effort) and
generates structured triage reports. Runs on issue open events and
via manual workflow_dispatch trigger.

- .flue/agents/triage-issue.ts: Agent entry point using Sonnet
- .agents/skills/classify-docs-issue.md: Classification skill with
  full label mapping from issue templates
- .flue/AGENTS.md: Project context for the agent
- .github/workflows/flue-triage-issue.yml: GitHub Actions workflow
- DRY_RUN=true by default, set DRY_RUN=false for live runs

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove issues.opened trigger — manual dispatch only until
  prompt injection detection is added
- Remove LINEAR_API_KEY from agent sandbox — Linear ticket
  creation will be a separate post-agent step
- Downgrade permissions to issues: read (no write needed yet)
- Fix concurrency group to match dispatch-only trigger

Co-Authored-By: Claude <noreply@anthropic.com>
GitHub API calls are now wrapped as custom ToolDefs (fetch_issue,
search_issues) that run in the Node process, not the sandbox. The
agent never sees GH_TOKEN — it calls the tools by name and gets
structured results back.

Also fixes import path to @flue/runtime/client (what the CLI
bundles internally, vs @flue/sdk/client which requires separate
install).

Co-Authored-By: Claude <noreply@anthropic.com>
Issue content is now fetched and validated in the TypeScript handler
before the LLM ever sees it. If injection patterns are detected,
the agent returns a flagged report and skips AI triage entirely.

The skill now receives pre-parsed fields (title, body, labels,
author) as arguments instead of fetching the issue itself, so
the LLM never processes raw untrusted API responses.

Co-Authored-By: Claude <noreply@anthropic.com>
- Import from @flue/runtime (client entrypoint was folded in)
- Use local() factory instead of 'local' string
- Move skill to .agents/skills/<name>/SKILL.md convention

Verified: dry run against issue #17799 produces correct triage.

Co-Authored-By: Claude <noreply@anthropic.com>
- Use Linear priority scale (urgent/high/medium/low) instead of
  impact (small/medium/large) to match the Docs team's workflow
- Update existing DOCS-XXXX Linear ticket instead of creating
  duplicates — finds the auto-synced ticket and sets priority +
  labels
- Fix get_linked_prs tool to return JSON strings (Flue tools
  must return strings)
- Add PR-first check: skip deep RCA when a linked PR already
  exists
- Handle unstructured issues (no template labels) by classifying
  from content alone

Co-Authored-By: Claude <noreply@anthropic.com>
When DRY_RUN=false, the agent now posts the full triage report
as a comment on the existing DOCS-XXXX ticket in addition to
setting priority and labels. This gives Shannon/Alex the
classification, related docs, suggested labels, and recommended
action directly in Linear.

Co-Authored-By: Claude <noreply@anthropic.com>
- Sort imports per simple-import-sort
- Name the default export function (import/no-anonymous-default-export)
- Apply Prettier formatting

Co-Authored-By: Claude <noreply@anthropic.com>
- Use Docs team label IDs instead of DevEx team IDs
- Separate priority, label, and comment into independent API
  calls so one failure doesn't block the others
- Add error logging for failed Linear mutations

Tested live: priority updates correctly, triage comments post
to existing DOCS tickets.

Co-Authored-By: Claude <noreply@anthropic.com>
Fetch the issue's current labels in the search query and skip
issueAddLabel when the target label is already present. This
avoids Linear's label-group exclusivity errors (e.g., trying
to add 'Docs' when it's already on the issue from GitHub sync).

Co-Authored-By: Claude <noreply@anthropic.com>
Major architecture change for security, idempotency, and debuggability:

- Agent is now purely read-only — returns JSON, no secrets, no writes
- New apply-triage.sh handles all writes (GitHub labels, Linear
  update, GitHub comment fallback) with idempotency checks
- Uses <!-- flue-triage --> marker to prevent duplicate comments
- Checks Linear for existing triage before posting
- Falls back to GitHub comment if Linear ticket not found yet
- Re-enables issues.opened trigger (agent has no write access)
- Simplified triage report format — no more redundant fields
- Removed suggestedLabels and dryRun (agent is always read-only)

Co-Authored-By: Claude <noreply@anthropic.com>
Consolidate all orchestration (triage + writes) in the TypeScript
handler instead of a separate bash script. This follows Flue's
recommended pattern where the handler is the orchestration layer.

Keeps all improvements from the bash approach:
- Idempotency via triage marker on GitHub comments
- Linear comment dedup check
- Label conflict handling (skip if already present)
- GitHub fallback when Linear ticket not found
- Error logging without crashing

Removes:
- .flue/scripts/apply-triage.sh
- JSON extraction logic in workflow
- Multi-step workflow (now single step)

Co-Authored-By: Claude <noreply@anthropic.com>
Add author_association check so the agent only runs for issues
opened by MEMBER, COLLABORATOR, or OWNER. External users' issues
are still triaged via manual workflow_dispatch. This prevents
unbounded Anthropic API costs from spam issue creation.

Addresses Warden finding D7Y-HH3.

Co-Authored-By: Claude <noreply@anthropic.com>
Match sentry-javascript's pattern: only run triage when the issue
has template-applied labels (Docs, Docs Platform, or Bug). Issues
without templates (spam, bots) skip auto-triage but can still be
triaged via workflow_dispatch.

Co-Authored-By: Claude <noreply@anthropic.com>
Fixes:
- Team field now uses v.picklist with allowlist matching labels.yml
  instead of v.optional(v.string()) — prevents injection of
  arbitrary GitHub labels (Warden VK2-ZRW)
- Linear fallback checks commentCreate success before setting
  linearOk — failed mutations now correctly trigger GitHub
  fallback (Cursor)
- get_linked_prs fetches full PR details via /pulls/:number API
  to get accurate merged status (Cursor)
- Idempotency fix: GitHub fallback comment uses TRIAGE_MARKER
  but Linear success does not, so re-runs can retry Linear when
  ticket appears later (Sentry bot)
- Removed overly broad injection patterns (act as, curl|sh,
  echo, eval, base64) that would false-positive on legitimate
  issue content (Sentry bot)
- Fixed SKILL.md step numbering (duplicate Step 2, missing Step 8)
  and added explicit summary instruction (Sentry bot)
- linearQuery now catches fetch errors instead of crashing

Not a bug: "missing filesystem tools" — sandbox: local() provides
bash/grep/find via Flue's built-in tools, custom tools are
additional.

Co-Authored-By: Claude <noreply@anthropic.com>
- Global concurrency group (flue-triage) so only one triage
  runs at a time — queues instead of parallelizing
- AbortSignal.timeout(120s) on the skill call — hard cap on
  LLM processing time per issue

Combined with label gate and 10-min workflow timeout, worst
case for 100 spam issues is now ~$5-10 processed sequentially
(~2 min each) instead of in parallel.

The ultimate backstop is setting a spend limit on the Anthropic
API key itself.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
develop-docs Ready Ready Preview Aug 20, 2026 10:12pm
sentry-docs Ready Ready Preview Aug 20, 2026 10:12pm

Request Review

@codeowner-assignment
codeowner-assignment Bot requested a review from a team August 19, 2026 22:48
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🏷 The following changes will be made to the repository labels

Progress: resolved 1, reused 0, downloaded 0, added 0
 WARN  3 deprecated subdependencies found: har-validator@5.1.5, request@2.88.2, uuid@3.4.0
Packages: +104
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 104, reused 0, downloaded 104, added 104, done
Syncing labels for "getsentry/sentry-docs"
Validating provided labels
Fetching labels from GitHub
 > Missing: the "Parking Lot" label is missing from the repo. It will be created.
 > Changed: the "Mobile" label in the repo is out of date. It will be updated to "Mobile" with color "#584774".
 > Added: the "arcade" label in the repo is not expected. It will be deleted.
 > Added: the "auth-complex" label in the repo is not expected. It will be deleted.
 > Added: the "Docs" label in the repo is not expected. It will be deleted.
 > Added: the "Docs Content" label in the repo is not expected. It will be deleted.
 > Added: the "Docs Platform" label in the repo is not expected. It will be deleted.
 > Added: the "docs-screenshots" label in the repo is not expected. It will be deleted.
 > Added: the "documentation" label in the repo is not expected. It will be deleted.
 > Added: the "jared" label in the repo is not expected. It will be deleted.
 > Added: the "Playwright" label in the repo is not expected. It will be deleted.
 > Added: the "Security" label in the repo is not expected. It will be deleted.
 > Added: the "stale-image" label in the repo is not expected. It will be deleted.
 > Added: the "State: Unreproducible" label in the repo is not expected. It will be deleted.
 > Added: the "State: Won't Do" label in the repo is not expected. It will be deleted.
 > Added: the "Supply-Chain-Vuln" label in the repo is not expected. It will be deleted.
 > Added: the "Team: SDKs" label in the repo is not expected. It will be deleted.
This is a dry run. No changes have been made on GitHub

Comment thread .github/workflows/flue-triage-issue.yml
Comment thread .flue/github.ts
Comment thread .flue/github.ts Outdated
@sfanahata

Copy link
Copy Markdown
Contributor Author

Addressed the label-sync dry-run warning in 4e50a7a by removing the .github/labels.yml change. This PR will no longer trigger the incomplete declarative catalog to delete unmanaged labels such as Docs and Docs Platform. The Parking Lot GitHub label should be created outside this shadow-mode PR before write mode is enabled.

Comment thread .flue/github.ts
Comment thread .flue/github.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f4dec0c. Configure here.

Comment thread .flue/apply-triage.ts
'<!-- sentry-docs-parking-review:v1 -->',
`${docsMention} review requested: this issue is proposed for Parking Lot (${result.decision.parkingLotReason}). GitHub remains open and Linear remains active until a person approves or reprioritizes it.`
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Parking Lot label never cleared

Medium Severity

applyTriageResult only adds the Parking Lot label for immediate no-priority review and never removes it. After a human reprioritizes or a later triage assigns a real priority, GitHub can keep showing Parking Lot while Linear is active again, which contradicts the review comment that reprioritization leaves the issue active.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f4dec0c. Configure here.

Comment thread .flue/fix-broken-link.ts
if (response.status < 300) {
throw new Error(`The reported broken URL currently resolves: ${response.url}`);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redirects treated as broken URLs

Medium Severity

verifyBroken uses redirect: 'manual' and only rejects responses with status below 300. A 301/302 therefore counts as broken, so the autofixer can open content-edit or redirect PRs for URLs that already resolve through an existing redirect.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f4dec0c. Configure here.

Comment thread .flue/apply-triage.ts
Comment on lines +52 to +70
export function parseTriageState(body: string): PersistedTriageState | undefined {
const match = body.match(/<!-- sentry-docs-triage-state:v2:([A-Za-z0-9_-]+) -->/);
if (!match) return undefined;
const parsed = JSON.parse(Buffer.from(match[1], 'base64url').toString('utf8')) as {
policyVersion?: number;
triagedAt?: string;
decision?: unknown;
applied?: PersistedTriageState['applied'];
overrides?: PersistedTriageState['overrides'];
};
if (parsed.policyVersion !== 2 || !parsed.triagedAt) return undefined;
return {
policyVersion: 2,
triagedAt: parsed.triagedAt,
decision: v.parse(TriageDecisionSchema, parsed.decision),
...(parsed.applied ? {applied: parsed.applied} : {}),
...(parsed.overrides ? {overrides: parsed.overrides} : {}),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Untrusted Linear comments control triage state and lifecycle closures

Anyone who can comment on a linked Linear issue can plant a forged sentry-docs-triage-state:v2 marker that apply/lifecycle treat as authoritative, including forcing issue closures and Parking Lot moves after crafted due dates.

Evidence
  • parseTriageState() accepts any Linear comment body matching <!-- sentry-docs-triage-state:v2:... --> and only validates policyVersion, triagedAt, and TriageDecisionSchema; it does not check comment author, bot identity, or signature.
  • applyTriageResult() selects existingState with linear.comments.toReversed().map(parseTriageState).find(Boolean), so the newest matching comment wins regardless of who wrote it.
  • run-lifecycle.ts uses the same unauthenticated lookup and, when state.decision.actionability === 'needs-information' and needsInformationCloseDueAt (derived from attacker-controlled triagedAt) is due, cancels Linear and closes the GitHub issue as not_planned.
  • The same path can also force Parking Lot labeling plus GitHub/Linear closure for inactive medium/low issues once the forged triagedAt/decision make parkingLotEligibleAt due.
Also found at 1 additional location
  • .flue/run-lifecycle.ts:40-44

Identified by Warden · security-review · YGA-AN7

Comment thread .flue/fix-broken-link.ts
'--title',
`fix(docs): Resolve broken link from #${issue.number}`,
'--body',
`Automated, validated broken-link fix.\n\n${decision.quickFix!.description}\n\nFixes #${issue.number}${linearReference}\n\nValidation: redirect rules, redirect-chain lint, focused tests, formatting, and git diff checks passed.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Model-controlled quickFix description can inject GitHub closing keywords into bot PRs

Untrusted model quickFix.description is interpolated into the automated PR body without neutralizing GitHub closing keywords, so a crafted broken-link triage result can make the bot PR auto-close unrelated issues on merge. Sanitize or omit model text from the body (e.g. strip Fixes/Closes/Resolves forms) and keep only server-generated references.

Evidence
  • createPullRequest() builds gh pr create --body with ${decision.quickFix!.description} inserted verbatim before the intentional Fixes #${issue.number} line.
  • decision is parsed only with TriageDecisionSchema; quickFix.description is a free-form string up to 500 chars and may contain newlines and Fixes/Closes/Resolves #N keywords.
  • main() feeds that model decision into createPullRequest() after eligibility checks, and the workflow runs this with a GitHub App token that has pull-requests: write.
  • No sanitization or allowlist removes closing-keyword patterns from the model-controlled description before the privileged PR is opened.

Identified by Warden · security-review · HRU-Q2H

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants