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:
- The ticket's
tagsincludebug,feature_request, orauto:status(the tag applied by #1147's Investigate flow). - The ticket is in the
ops@raxx.apporsupport@raxx.appmailbox (not an internal-only mailbox). - The ticket has not already produced a GitHub issue (checked via the
github_issue_urlcustom 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
- FreeScout webhook:
X-FreeScout-SignatureHMAC-SHA256 (existing pattern fromstatus-raxx-app.md §9). - GitHub API: a fine-grained Personal Access Token (PAT) scoped to the
raxx-app/TradeMasterAPIrepo withissues: write. Alternatively, a GitHub App installation token forraxx-dev-bot— preferred because it rotates automatically. Token stored in Infisical at/raptor/prod/github-issue-bot-token(or/raptor/prod/github-app-private-key+ installation ID if using GH App). - FreeScout write-back: existing
FREESCOUT_API_KEYalready in Raptor env.
Loop prevention (Direction A)
- Before creating a GitHub issue, Raptor fetches
GET /api/conversations/{id}and checkscustom_fields.github_issue_url. If non-empty, skip creation and returnHTTP 200(idempotent). - After creating the GitHub issue, Raptor writes the issue URL to
custom_fields.github_issue_urlon the FreeScout conversation. Subsequent webhook deliveries (FreeScout retries) hit the idempotency check and no-op. - 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)
- 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 viagh issue view --json commentsand skips creation if found. - 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-linkedtag that triggers further processing in Direction A). Alternatively, a_from_github_actiontag 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:
github_issue_urlcustom 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).- 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. - FreeScout
auto:statustag 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
- Live two-way status sync (GitHub issue closed → FreeScout ticket auto-resolved): deferred per #357's own v2 deferral note. File a separate card when prioritized.
- Customer-visible GitHub issue links in
support.raxx.app: internal notes andgithub_issue_urlare operator-only fields; persupport-raxx-app.md §4, internal fields are stripped before any customer-facing response. - Auto-assignment of GitHub issues to engineers based on FreeScout tags: out of scope; that is triage logic, not linking logic.
- Lambda / external bridge: Raptor is the correct host for Direction A bridge logic given it already holds the FreeScout API key and has the webhook receiver pattern established. No new infrastructure needed.
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.