Raxx · internal docs

internal · gated ↑ index

FreeScout → GitHub Issue Auto-Linking

Status: Research / Design Date: 2026-05-17 UTC Author: feature-developer agent (dispatched from #723) Parent epic: #707 (FreeScout productionization) Related: #651, #357 (superseded by #1147), #631, #1147, #1148 Category: not-blocking-launch — workflow enhancement


1. Goal

When a FreeScout ticket meets certain criteria (bug report, feature request, or ops incident), automatically create a corresponding GitHub issue so engineering work is tracked in the right system. Optionally, when a GitHub issue is labeled with a support-relevant tag, open or update a linked FreeScout ticket.

Mental model: FreeScout owns SLA + customer interaction; GitHub owns engineering work. The link between them is a stable reference in both objects — not live sync state.


2. What Is Already Scoped (Existing Cards)

Covered by #1147 (shipped, CLOSED)

feat(console/status): "Investigate" button on degraded tiles — auto-file FreeScout ticket

1147 shipped the console → FreeScout direction: an operator click in the console

creates a FreeScout ticket with a workflow UUID and a back-link to the console tile. It does NOT create a GitHub issue, and it does NOT link the FreeScout ticket to a GitHub issue number.

Covered by #357 → superseded by #1147

357 defined the v1 scope for the Investigate flow. Closed as superseded. Its v2

scope (bidirectional FreeScout ↔ console sync) is explicitly deferred; no card exists for that yet.

Covered by #631 (CLOSED)

incident_severity custom field on FreeScout tickets. This field drives which non-OPERATIONAL state the status-state machine enters. It has no GitHub-issue linking logic.

Covered by #651 sub-cards (#1004–#1012)

The support.raxx.app portal sub-cards cover the customer-facing ticket experience. None of the nine sub-cards (#1004–#1012) include FreeScout → GitHub issue creation. The support.raxx.app design doc (docs/architecture/support-raxx-app.md) confirms no GitHub issue linking is in scope for the portal.

Gap confirmed

No existing card covers FreeScout-ticket-triggered GitHub issue creation or GitHub- issue-triggered FreeScout-ticket creation. The work described below is net-new scope.


3. FreeScout API Constraints

Per project_freescout_api_limits.md, the scriptable FreeScout surfaces are:

Operation Mechanism
Read conversations, threads, customers GET /api/conversations, GET /api/conversations/{id}/threads
Create conversation (ticket) POST /api/conversations
Update conversation status/tags PUT /api/conversations/{id}
Add internal note to conversation POST /api/conversations/{id}/threads with type: note
Receive event notifications Outbound webhook (HMAC-SHA256 signed) on conversation events
Mailbox management UI-only — not scriptable
Auto-reply rules UI-only — not scriptable

Key constraint: FreeScout custom field values are readable/writable via the conversations API. Once the component_tag and incident_severity fields are provisioned (#605, #631), a new github_issue_url custom field can be added to hold the back-reference — no architectural change needed, just a new field provisioned in the FreeScout admin UI and documented in the runbook.


4. Direction A — FreeScout Ticket → GitHub Issue

Trigger

FreeScout fires a webhook on conversation.created. Raptor (or a lightweight bridge) receives the webhook, evaluates criteria, and calls the GitHub API to open an issue.

Criteria for issue creation

A ticket triggers GitHub issue creation when all of the following are true:

  1. The ticket's tags include bug, feature_request, or auto:status (the tag applied by #1147's Investigate flow).
  2. The ticket is in the ops@raxx.app or support@raxx.app mailbox (not an internal-only mailbox).
  3. The ticket has not already produced a GitHub issue (checked via the github_issue_url custom field on the conversation — empty means not yet linked).

Keyword scanning of the ticket body is an optional secondary filter but should not be the primary trigger — tags are operator-controlled and more reliable.

Bridge implementation (Raptor-resident)

The lightest viable path: a new Raptor route that is already a known-good FreeScout webhook receiver (the freescout_client.py pattern from #1147 is reusable).

FreeScout (tickets.raxx.app)
  → POST /api/webhooks/freescout/support  [existing, from #1007]
  or
  → POST /api/webhooks/freescout/ops      [new route, for ops mailbox events]
       ↓
  Raptor: evaluate criteria
       ↓  (if criteria met)
  GitHub REST API: POST /repos/raxx-app/TradeMasterAPI/issues
       ↓
  FreeScout API: PUT /api/conversations/{id}
                 { custom_fields: { github_issue_url: "https://github.com/..." } }
                 + POST internal note thread: "GitHub issue #N filed: <url>"

Authentication

Loop prevention (Direction A)

  1. Before creating a GitHub issue, Raptor fetches GET /api/conversations/{id} and checks custom_fields.github_issue_url. If non-empty, skip creation and return HTTP 200 (idempotent).
  2. After creating the GitHub issue, Raptor writes the issue URL to custom_fields.github_issue_url on the FreeScout conversation. Subsequent webhook deliveries (FreeScout retries) hit the idempotency check and no-op.
  3. The internal FreeScout note ("GitHub issue #N filed") gives the operator a visible audit trail without triggering another webhook event.

5. Direction B — GitHub Issue → FreeScout Ticket

Trigger

A GitHub Actions workflow fires when an issue is labeled. Relevant labels:

GitHub label FreeScout action
customer-reported Create or update a FreeScout ticket in the ops@ mailbox
regression Create FreeScout ticket tagged bug + regression
needs-customer-confirm Add an internal note to the linked FreeScout ticket asking for customer confirmation

Implementation

A GitHub Actions workflow in .github/workflows/freescout-sync.yml:

on:
  issues:
    types: [labeled]

The workflow step calls POST /api/conversations via curl or a lightweight Python script, using FREESCOUT_API_URL + FREESCOUT_API_KEY from GitHub Actions secrets (sourced from Infisical at workflow dispatch time via the existing vault-read pattern).

Loop prevention (Direction B)

  1. The GH Actions step writes the FreeScout conversation URL as a GitHub issue comment (gh issue comment --body "FreeScout ticket: <url>") with a sentinel prefix [freescout-autolink]. On any re-trigger, the workflow checks for an existing comment with that prefix via gh issue view --json comments and skips creation if found.
  2. FreeScout does NOT fire a webhook back to Raptor for tickets created by the GH Actions flow (because the ticket will not have the auto-linked tag that triggers further processing in Direction A). Alternatively, a _from_github_action tag can be applied to FreeScout tickets created by Direction B — Direction A's webhook handler can then explicitly skip those.

6. Trigger Criteria Summary

Source Trigger Criteria Action
FreeScout conversation.created tag in {bug, feature_request, auto:status} AND github_issue_url empty Create GitHub issue; write back URL
FreeScout conversation.status_changed to closed github_issue_url non-empty Add GH issue comment "FreeScout ticket resolved"; optionally close GH issue
GitHub issues.labeled with customer-reported No [freescout-autolink] comment exists Create FreeScout ticket; write back GH comment
GitHub issues.labeled with needs-customer-confirm [freescout-autolink] comment exists (ticket already linked) POST internal note on existing FreeScout ticket

7. Loop Prevention Model

All round-trips are guarded by a stable reference written to both objects before any further event can fire:

FreeScout conversation → custom field: github_issue_url = "https://github.com/..."
GitHub issue          → comment body starts with "[freescout-autolink] ..."

Rules: 1. Direction A handler checks github_issue_url before any GitHub API call. 2. Direction B workflow checks for [freescout-autolink] comment before any FreeScout API call. 3. FreeScout tickets created by Direction B carry tag _from_github — Direction A webhook handler skips any ticket bearing that tag. 4. GitHub issues created by Direction A are labeled freescout-linked by the GH API call — Direction B workflow skips issues already bearing that label.

This gives a two-lock system. Either lock independently prevents a runaway loop.


8. Auth Summary

Secret Where stored Consumer
FREESCOUT_API_KEY Infisical /raptor/prod/freescout-api-key; Heroku config var Raptor (existing)
FREESCOUT_WEBHOOK_SECRET Infisical /raptor/prod/freescout-ops-webhook-secret Raptor webhook receiver
GITHUB_ISSUE_BOT_TOKEN Infisical /raptor/prod/github-issue-bot-token Raptor (Direction A outbound)
FREESCOUT_API_KEY (GH Actions) GitHub Actions secret FREESCOUT_API_KEY (sourced from Infisical at dispatch) GH Actions workflow (Direction B)

The GitHub token for Raptor should be a fine-grained PAT for raxx-app/TradeMasterAPI with scope issues:write only. If raxx-dev-bot GitHub App gains issues:write permission on the repo, use an installation token instead — avoids manual rotation.


9. Dependency Order

Before any sub-card can ship:

  1. github_issue_url custom field provisioned in FreeScout admin UI (small ops task; add to the #605/#631 runbook pattern — no new card needed if the runbook is updated in the same PR as the first Direction A card).
  2. Raptor ops-mailbox webhook endpoint exists. The support-mailbox webhook (POST /api/webhooks/freescout/support) is planned under #1007. The ops-mailbox variant is a new route; it can share the same handler logic.
  3. FreeScout auto:status tag applied by #1147's Investigate flow is already in production before Direction A is enabled, so the first real trigger is an ops ticket from the Investigate button.

10. Implementation Breakdown — Proposed Sub-Cards

The following five atomic cards cover the full scope. They are sequenced; each is a valid stopping point.

Card 1 — ops(support): add github_issue_url custom field to FreeScout + runbook (size:xs)

Provision a fourth FreeScout custom field github_issue_url (type: text / URL). Update docs/runbooks/freescout-status-fields.md (the runbook from #605/#631) to document the field slug, type, and purpose. Confirm field slug via FreeScout API. No code changes. Depends on: #605 (FreeScout custom fields runbook) being merged.

Card 2 — feat(raptor): FreeScout ops-mailbox webhook receiver + GitHub issue creation (Direction A) (size:s)

New Raptor route: POST /api/webhooks/freescout/ops. On conversation.created: evaluate criteria, call GitHub REST API to create an issue, write github_issue_url back to FreeScout, post internal note. Idempotency via github_issue_url check. HMAC signature verification. FLAG_FREESCOUT_GITHUB_AUTOLINK gates the route (default off). pytest coverage: happy path, idempotency re-trigger, criteria-not-met no-op, invalid signature 401.

Card 3 — feat(raptor): FreeScout ticket-close → GitHub issue comment (Direction A, close event) (size:xs)

Extend the ops-mailbox webhook handler to process conversation.status_changed to closed. If github_issue_url is set on the ticket, post a GitHub issue comment: "FreeScout ticket resolved (UTC timestamp)." Does not close the GitHub issue — that is an engineer decision. Same flag as Card 2. pytest: close event with linked issue, close event without linked issue (no-op).

Card 4 — feat(ci): GitHub Actions workflow — issue-labeled → FreeScout ticket (Direction B) (size:s)

.github/workflows/freescout-sync.yml triggered on issues.labeled. On customer-reported or regression labels: check for existing [freescout-autolink] comment, create FreeScout ticket in ops mailbox if not found, post sentinel comment. On needs-customer-confirm + existing link: POST internal note to the linked ticket. Auth: FREESCOUT_API_KEY GitHub Actions secret. Loop prevention: _from_github tag on created tickets. Test: workflow unit test via act or equivalent; AC checklist in the card.

Card 5 — feat(console): FreeScout ticket detail — show linked GitHub issue badge (size:xs)

In the console ticket thread view, if custom_fields.github_issue_url is present, render a small badge linking to the GitHub issue. Read-only display; no console-side write logic. Depends on Card 2 having shipped (so real data exists). Feature flag: same FLAG_FREESCOUT_GITHUB_AUTOLINK. Jest test: badge renders when URL present, badge absent when field empty.


11. What Is NOT in Scope for These Cards


12. Open Questions (Operator Decisions Required Before Cards Are Claimed)

OQ-1 — GH App vs. fine-grained PAT for Direction A: raxx-dev-bot GitHub App installation token is preferred (auto-rotating). Confirm whether raxx-dev-bot has issues:write on raxx-app/TradeMasterAPI, or whether a separate fine-grained PAT should be provisioned. Blocks Card 2.

OQ-2 — Criteria for auto-create: tag-based only, or also keyword scan? Tag-based is more reliable (operator-controlled). Keyword scanning (bug, crash, broken, feature request in subject/body) is noisier but catches untagged tickets. Recommend tag-only for v1, with keyword scan as an opt-in config option in Card 2. Does not block writing Card 2 — it is an implementation choice, not an architecture question. Needs confirmation before flag flip.

OQ-3 — Scope of Direction B labels: The proposed labels (customer-reported, regression, needs-customer-confirm) are new labels that do not yet exist in the repo. Confirm the label set before Card 4 is filed. Does not block writing the workflow — labels can be created as part of Card 4.