diff --git a/docs/ADRs/0076-slash-command-entity-context-separation.md b/docs/ADRs/0076-slash-command-entity-context-separation.md index 2f142c20dc..b4cdcab800 100644 --- a/docs/ADRs/0076-slash-command-entity-context-separation.md +++ b/docs/ADRs/0076-slash-command-entity-context-separation.md @@ -17,6 +17,9 @@ Date: 2026-07-28 Accepted +Extends entity-context rules for conversation surfaces in +[ADR 0086](0086-conversation-surface-for-agent-participation.md). + Refines [ADR 0002](0002-initial-fullsend-design.md) (initial design) and reinforces [ADR 0020](0020-composable-single-responsibility-agents-with-individual-sandboxes.md) (single-responsibility agents). diff --git a/docs/ADRs/0086-conversation-surface-for-agent-participation.md b/docs/ADRs/0086-conversation-surface-for-agent-participation.md new file mode 100644 index 0000000000..8d223a0cc3 --- /dev/null +++ b/docs/ADRs/0086-conversation-surface-for-agent-participation.md @@ -0,0 +1,162 @@ +--- +title: "86. Conversation surface for agent participation" +status: Accepted +relates_to: + - agent-architecture + - agent-infrastructure + - security-threat-model +topics: + - discussions + - dispatch + - conversation + - thread + - tracker + - portability + - slash-commands +--- + +# 86. Conversation surface for agent participation + +Date: 2026-08-11 + +## Status + +Accepted + +Builds on: + +- Forge abstraction: [ADR 0005](0005-forge-abstraction-layer.md) +- Credential isolation / host API: [ADR 0017](0017-credential-isolation-for-sandboxed-agents.md), + [ADR 0046](0046-host-side-api-server-design.md) +- Authorization / registration / dispatch: [ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md), + [ADR 0058](0058-agent-registration.md), + [ADR 0061](0061-harness-cel-dispatch.md), + [ADR 0063](0063-polling-based-work-discovery.md) +- Entity-context separation: [ADR 0076](0076-slash-command-entity-context-separation.md) + +Aligns with the `tracker.Client` split +([#5988](https://github.com/fullsend-ai/fullsend/issues/5988)): domain +interfaces stay narrow; `forge.Client` remains git-hosting only. + +## Context + +Agents already act on forge **work items** and **change proposals** via slash +commands and host/post-script comments. GitHub Discussions (and later Slack, +Discord, Matrix, GitLab discussions) are a different surface: community chat +that must not be shoehorned into `work_item` without breaking entity-context +rules ([ADR 0076](0076-slash-command-entity-context-separation.md)). + +`forge.Client` is scoped to git-hosting. Issue content for non-forge backends +(Jira) already moves to `tracker.Client`. Slack and peers are not forges — +chat read/write must not grow `forge.Client`. + +Slack's API already names the container a **conversation** (channel/DM) and +replies under a parent a **thread**. Fullsend should use those words so +adapters map by name, not by overloaded metaphors. GitHub Discussions fit the +same shape: a Discussion is a conversation; a top-level comment plus its flat +replies is a thread (GitHub allows only one reply nesting level). Native +webhook payloads associate replies via a parent pointer on the message +(`comment.parent_id` on `discussion_comment`), not a separate thread object. + +## Options + +### A. Special-case GitHub Discussions in workflows + +Fastest for GitHub; GitHub-centric and duplicates auth/writeback. + +### B. Always-on chat bot outside the execution stack + +Low latency; bypasses unidirectional control, mint/OIDC identity, and harness +registration ([ADR 0016](0016-unidirectional-control-flow.md)). + +### C. Extend `forge.Client` with Conversation/Message methods + +Pulls non-forge chat into the forge package and fights the `tracker.Client` +split. + +### D. Portable Conversation surface via `conversation.Client` (chosen) + +Parallel to `tracker.Client`: narrow domain interface + dispatch/event reuse, +with per-backend adapters. + +## Decision + +Adopt **Option D**. + +### Domain model + +| Relation | Cardinality | Notes | +|----------|-------------|--------| +| Category → Conversation | **1:M** | Exclusive partition (GitHub Discussion category; Slack channel **section** when mapped). | +| Conversation → Thread | **1:M** | A thread is the top-level message plus replies that name it as parent. | +| Thread → Message | **1:M** | Flat within the thread on GitHub (reply-to-reply nesting rejected). | +| Conversation ↔ Label | **M:M** | Triage tags on the conversation when the backend supports them. | +| Message / Thread → Category / Label | **none** | Inherit routing context from the parent conversation. | + +**Backend mapping** + +| Fullsend | Slack API / UI | GitHub Discussions | +|----------|----------------|--------------------| +| Category | Channel section (optional grouping) | Discussion category | +| Conversation | Conversation (channel / DM) | Discussion | +| Thread | Thread (`thread_ts`) | Top-level comment + flat replies | +| Message | Message | Comment or reply | + +**Message identity and threading in events.** Comment transitions carry the +message itself: `transition.comment.id` and `transition.comment.parent_id` +(both required for conversation comment events). `parent_id` is always the +thread-root message id — the safe target for a reply in that thread. For a +top-level / thread-root message, adapters set `parent_id` equal to `id`. For a +reply, `parent_id` is the root (GitHub Actions `discussion_comment` uses +`null` parent on the root and a numeric parent on replies — normalize root to +`parent_id == id`; Slack maps `thread_ts`, using the message `ts` when the +message starts the thread). Threads are not a separate `entity.kind`. + +**Category ≠ label.** Categories are a single exclusive partition with optional +*format* hints (open discussion, Q&A, announcement, poll). Labels are additive +tags on the conversation. Adapters must not encode the category name as a +synthetic label. + +**Permissions.** Platform authorization remains +[ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md). Category +and message/parent ids are **routing metadata** for harness CEL, not a second +auth system. + +GitHub's public GraphQL `DiscussionCategory` exposes `isAnswerable` but no +announcement/poll/discussion format field. Adapters MUST map +`isAnswerable == true` to `format: question_answer` and SHOULD omit `format` +otherwise unless they document an explicit heuristic. UI-only create +restrictions are not queryable via that API and MUST NOT be treated as +enforceable from `format` alone. + +### Architecture seams + +1. **`conversation.Client`:** `internal/conversation` with Conversation, + Thread, and Message APIs (category metadata on Conversation). GitHub + Discussions first; Slack/peers implement directly. **Not** on + `forge.Client` or `tracker.Client`. +2. **`NormalizedEvent`:** `entity.kind: conversation`. Require + `state.conversation.category` (`name` required; `id`/`slug`/`format` + optional). On comment transitions, require `transition.comment.id` and + `transition.comment.parent_id` (`parent_id == id` for thread-root messages). + Reuse `state.labels` for conversation labels only. Ingress covers + conversation lifecycle events and message events on conversations. See + [normative v1](../normative/normalized-event/v1/). +3. **Ingress / egress / identity / security:** shim + dispatch drivers; + host-mediated writeback via `conversation.Client`; least-privilege + Discussions/chat scopes; conversation and message bodies untrusted. + Entity-context separation keeps code-mutating slash commands off + conversations ([ADR 0076](0076-slash-command-entity-context-separation.md)). + +## Consequences + +- Harnesses can route with CEL on `state.conversation.category`, + `transition.comment.id` / `parent_id`, and `state.labels`. +- Slack maps channel→conversation, section→category; `parent_id` is always the + reply target (Slack `thread_ts` / message `ts`; GitHub normalized parent). +- `entity.id` remains numeric for forge-native conversations (e.g. Discussion + number); non-numeric backends (Slack/Discord/Matrix) need a follow-on carrier + (e.g. opaque `entity.key` or string id) before those adapters. +- Domain split: `forge.Client` / `tracker.Client` / `conversation.Client`. +- Linking a conversation or thread to a work item for `/fs-code` remains a + follow-on. diff --git a/docs/architecture.md b/docs/architecture.md index 3cc582c809..197d6ebce4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ the dedicated org-level `/.fullsend` config repo is deprecated **Decided:** - Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)). +- Conversation surface: agents participate in GitHub Discussions and later other chat systems through a narrow `conversation.Client` (parallel to `tracker.Client` for issue content), not by extending `forge.Client` ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). A **conversation** is the container (Discussion / Slack channel) with exactly one category and optional M:M labels; a **thread** is the top-level message plus replies that share its `parent_id` (`parent_id == id` on the root message). - Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → vendor-binary → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). - Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function or Cloudflare Worker) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager (GCF mint), Worker secrets (CF mint), or the local filesystem (standalone mint), not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). @@ -217,6 +218,7 @@ One concrete implementation option is [`oidcx`](https://github.com/oxidecomputer - How are credentials rotated and revoked, and who has authority to do that? - Does the identity provider integrate with existing secrets management, or is it a new system? - How will per-role identity work on GitLab and Forgejo, which lack GitHub's app manifest flow? GitLab uses a bot PAT stored as a protected CI/CD variable — see [ADR 0067](ADRs/0067-gitlab-cron-polling-event-dispatch.md). +- Which agent roles need Discussions (or other chat) write scopes, and how do those scopes map onto named mint privilege levels? Conversation participation requires least-privilege identity deltas per [ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md). ## Agent Dispatch and Coordination Layer @@ -237,6 +239,14 @@ The existing design principle is that [the repo is the coordinator](problems/age as webhooks ([ADR 0063](ADRs/0063-polling-based-work-discovery.md)). Initial scope is per-repo mode only. - GitLab dispatch uses cron-polled scheduled pipelines for issue/comment/label events and native `merge_request_event` for MR events. No webhook bridge required (see [ADR 0067](ADRs/0067-gitlab-cron-polling-event-dispatch.md)). +- Conversation participation: GitHub Discussions (and future chat systems) enter + dispatch as `NormalizedEvent` entities with `entity.kind: conversation`, + express threading on `transition.comment.id` / `parent_id` (`parent_id` always + names the thread root), reuse CEL harness triggers and ADR 0054 authorization, + and write back through host/post-script or host-side API servers via + `conversation.Client` — not a separate always-on chat bot and not an + extension of `forge.Client` + ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). - Dispatch authorization gate: all agent dispatch paths — slash commands and automatic event triggers — require authorization before dispatching. GitHub paths check the acting user's collaborator permission via the @@ -256,6 +266,12 @@ The existing design principle is that [the repo is the coordinator](problems/age - How does work assignment interact with the backlog/priority agent described in [agent-architecture.md](problems/agent-architecture.md)? - What happens when work needs to be cancelled, retried, or reassigned? - Does the coordinator need state (a queue, a lock, a claim system), or can it be stateless and event-driven? +- When should a conversation or thread be linked to a work item (e.g. Discussion + → issue) so a conversation-native agent can hand off to `/fs-code` without + violating entity-context separation ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md), + [ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md))? +- How should concurrent agent runs that touch the same conversation thread be + coordinated ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md))? ## Policy Store @@ -349,7 +365,7 @@ ADR 0002: [Building block 1](ADRs/0002-initial-fullsend-design.md#1-webhook--dis ### 2. Slash-command parser + ACL -Parses `/fs-triage`, `/fs-code`, `/fs-review`, and related commands and enforces who is allowed to invoke each. Commands are restricted to the entity context where their agent's inputs exist — `/fs-code` dispatches only from issues (no associated PR), `/fs-fix` and `/fs-review` only from PRs ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md)). +Parses `/fs-triage`, `/fs-code`, `/fs-review`, and related commands and enforces who is allowed to invoke each. Commands are restricted to the entity context where their agent's inputs exist — `/fs-code` dispatches only from issues (no associated PR), `/fs-fix` and `/fs-review` only from PRs ([ADR 0076](ADRs/0076-slash-command-entity-context-separation.md)). Conversation surfaces (GitHub Discussions and future chat systems) are a separate entity context: conversation-native agents may listen on conversations/threads there, but code-mutating slash commands do not ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). ADR 0002: [Building block 2](ADRs/0002-initial-fullsend-design.md#2-slash-command-parser--acl). ### 3. Label state machine guard diff --git a/docs/guides/user/cel-triggers-reference.md b/docs/guides/user/cel-triggers-reference.md index d7f643291b..92163e32cc 100644 --- a/docs/guides/user/cel-triggers-reference.md +++ b/docs/guides/user/cel-triggers-reference.md @@ -48,18 +48,19 @@ The `event` variable has the following top-level fields: | Field | Type | Description | |---|---|---| | `event.repo` | string | Repository path (`owner/repo`) | -| `event.entity.kind` | string | `"work_item"` (issue) or `"change_proposal"` (PR) | -| `event.entity.id` | int | Issue or PR number | +| `event.entity.kind` | string | `"work_item"` (issue), `"change_proposal"` (PR), or `"conversation"` (Discussion / Slack channel; [ADR 0086](../../ADRs/0086-conversation-surface-for-agent-participation.md)) | +| `event.entity.id` | int | Issue, PR, or conversation number | | `event.transition.kind` | string | What happened — see [transition kinds](#transition-kinds) | | `event.transition.label` | object | Present only when `kind == "label_changed"` | -| `event.transition.comment` | object | Present only when `kind == "comment_added"` | +| `event.transition.comment` | object | Present only when `kind == "comment_added"` (conversation comments carry `id` and `parent_id`, with `parent_id == id` for thread roots; [ADR 0086](../../ADRs/0086-conversation-surface-for-agent-participation.md)) | | `event.transition.review` | object | Present only when `kind == "review_submitted"` | | `event.actor.id` | string | Forge login of the user or bot that triggered the event | | `event.actor.kind` | string | `"human"` or `"bot"` | | `event.actor.role` | string | Repository permission: `admin`, `maintain`, `write`, `triage`, `read`, `none`, `external` | -| `event.actor.is_entity_author` | boolean | True when the actor is the author of the work item or change proposal | +| `event.actor.is_entity_author` | boolean | True when the actor is the author of the work item, change proposal, or conversation | | `event.state.labels` | list | Label names on the entity at event time | | `event.state.change_proposal` | object | Present when a change proposal is involved (includes `is_fork`, `head_ref`, `base_ref`) | +| `event.state.conversation` | object | Required when `entity.kind == "conversation"` (includes `category.name`; optional `category.id` / `slug` / `format`) | | `event.source.system` | string | `"github"`, `"gitlab"`, `"jira"`, `"manual"`, or `"schedule"` | This table covers the most common trigger fields. For the complete field list — including `event.entity.url`, `event.entity.key`, `event.source.raw_type`, and all `event.state.change_proposal` sub-fields — see the [NormalizedEvent v1 schema](../../normative/normalized-event/v1/normalized-event.schema.json). diff --git a/docs/normative/normalized-event/v1/README.md b/docs/normative/normalized-event/v1/README.md index 7bc478f056..4e4f70d85f 100644 --- a/docs/normative/normalized-event/v1/README.md +++ b/docs/normative/normalized-event/v1/README.md @@ -29,6 +29,19 @@ input, and **Jira poll** input: is the production GitLab adapter ([ADR 0067](../../../ADRs/0067-gitlab-cron-polling-event-dispatch.md)); `jira-poll` is the production Jira poll adapter; `json` supports tests and replay. +- `entity.kind: conversation` covers GitHub Discussions and future chat + systems ([ADR 0086](../../../ADRs/0086-conversation-surface-for-agent-participation.md)). + A conversation is the container (Discussion / Slack channel). Threading is + expressed on the message via `transition.comment.id` and + `transition.comment.parent_id` (always the thread-root id; equal to `id` for + top-level messages). GitHub Discussion adapters are planned under + `gha-event`; additional `source.system` values (e.g. Slack) are non-breaking + enum additions when those drivers land. +- Conversations carry **`state.conversation.category`** (exactly one; 1:M + category→conversation) separately from **`state.labels`** (M:M tags on the + conversation). Threads and messages carry neither category nor labels; + `comment_added` events still snapshot the parent conversation's category and + labels and MUST include `transition.comment.id` and `parent_id`. ## Versioning @@ -48,7 +61,7 @@ Input drivers map native forge events into this struct: | Driver | Source | v1 status | |--------|--------|-----------| -| `gha-event` | `GITHUB_EVENT_PATH` + `gh` snapshot for labels and change-proposal metadata | Production | +| `gha-event` | `GITHUB_EVENT_PATH` + `gh` snapshot for labels and change-proposal metadata | Production; Discussions → `entity.kind: conversation` planned ([ADR 0086](../../../ADRs/0086-conversation-surface-for-agent-participation.md)) | | `gitlab-poll` | GitLab CI event payload (cron-polled; [ADR 0067](../../../ADRs/0067-gitlab-cron-polling-event-dispatch.md)) | Production (poll) | | `jira-poll` | Jira issue search + changelog/comments since `lastCheck` ([jira-poll-adapter.md](jira-poll-adapter.md), [ADR 0063](../../../ADRs/0063-polling-based-work-discovery.md)) | Production (poll) | | `json` | stdin or `--input-file` | Tests, replay | @@ -56,6 +69,11 @@ Input drivers map native forge events into this struct: Adapters must populate: - `state.labels` when routing guards or label-based triggers apply. +- `state.conversation` (with `category.name` at minimum) whenever + `entity.kind` is `conversation` — including message/`comment_added` events. + Do not encode the category as a synthetic label. On comment transitions, + also set `transition.comment.id` and `parent_id` (`parent_id == id` for + thread-root messages; otherwise the root id). - `state.change_proposal` (including `head_ref`, `base_ref`, and `head_sha` when known) whenever a matched harness needs change-proposal execution context. Webhook payloads are often incomplete — adapters should fill gaps via GitHub @@ -168,6 +186,14 @@ event.transition.kind == "review_submitted" event.transition.kind == "comment_added" && event.transition.comment.command == "/fs-fix" && !event.state.change_proposal.is_fork + +// Conversation-native agent on Discussion slash command (ADR 0086) +event.entity.kind == "conversation" + && event.transition.kind == "comment_added" + && has(event.transition.comment.command) + && event.transition.comment.command == "/fs-vouch" + && has(event.state.conversation.category.slug) + && event.state.conversation.category.slug == "vouch-request" ``` See [`examples/`](examples/) for matching `NormalizedEvent` fixtures. diff --git a/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json new file mode 100644 index 0000000000..fb315b8bea --- /dev/null +++ b/docs/normative/normalized-event/v1/examples/discussion-fs-vouch-comment.json @@ -0,0 +1,39 @@ +{ + "repo": "fullsend-ai/fullsend", + "entity": { + "kind": "conversation", + "id": 5182, + "url": "https://github.com/fullsend-ai/fullsend/discussions/5182" + }, + "actor": { + "id": "contributor", + "kind": "human", + "role": "write", + "is_entity_author": true + }, + "transition": { + "kind": "comment_added", + "comment": { + "id": "DC_kwDOExampleComment", + "parent_id": "DC_kwDOExampleComment", + "command": "/fs-vouch", + "body": "/fs-vouch please review my request", + "instruction": "please review my request" + } + }, + "state": { + "labels": ["needs-triage"], + "conversation": { + "category": { + "id": "DIC_kwDOExample", + "name": "Vouch Request", + "slug": "vouch-request" + } + } + }, + "source": { + "system": "github", + "raw_type": "discussion_comment", + "raw_action": "created" + } +} diff --git a/docs/normative/normalized-event/v1/normalized-event.schema.json b/docs/normative/normalized-event/v1/normalized-event.schema.json index ef12468bb3..b4b569b1a0 100644 --- a/docs/normative/normalized-event/v1/normalized-event.schema.json +++ b/docs/normative/normalized-event/v1/normalized-event.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://fullsend.ai/normative/normalized-event/v1/normalized-event.schema.json", "title": "NormalizedEvent", - "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks, GitLab cron-poll input (ADR 0067), and Jira poll input (ADR 0063).", + "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks (including Discussions via entity.kind conversation and transition.comment id/parent_id per ADR 0086), GitLab cron-poll input (ADR 0067), and Jira poll input (ADR 0063).", "type": "object", "additionalProperties": false, "required": ["repo", "entity", "transition", "actor", "state", "source"], @@ -83,6 +83,32 @@ } } } + }, + { + "if": { + "properties": { + "entity": { + "properties": { "kind": { "const": "conversation" } }, + "required": ["kind"] + } + }, + "required": ["entity"] + }, + "then": { + "properties": { + "state": { + "required": ["conversation"] + } + }, + "required": ["state"] + }, + "else": { + "properties": { + "state": { + "properties": { "conversation": false } + } + } + } } ], "$defs": { @@ -100,8 +126,8 @@ "properties": { "kind": { "type": "string", - "enum": ["work_item", "change_proposal"], - "description": "Forge-neutral entity kind (issue vs PR/MR)." + "enum": ["work_item", "change_proposal", "conversation"], + "description": "Forge-neutral entity kind (issue vs PR/MR vs conversation/Discussion/channel). Threads are not a separate entity.kind; reply association uses transition.comment.parent_id. See ADR 0086." }, "id": { "type": "integer", @@ -185,6 +211,18 @@ "additionalProperties": false, "required": ["body"], "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Backend message id for this comment. Required when entity.kind is conversation on comment_* transitions (ADR 0086). GitHub: comment id or node_id; Slack: message ts." + }, + "parent_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Thread-root message id — always the safe reply target for this comment's thread (ADR 0086). Required when entity.kind is conversation on comment_* transitions. Equal to comment.id for top-level / thread-root messages; for replies, the root id (normalize GitHub discussion_comment null parent to id; Slack thread_ts, or ts when the message starts the thread)." + }, "command": { "type": "string", "maxLength": 64, @@ -282,7 +320,7 @@ }, "is_entity_author": { "type": "boolean", - "description": "True when actor.id is the author of the work item or change proposal." + "description": "True when actor.id is the author of the work item, change proposal, or conversation." } } }, @@ -298,7 +336,52 @@ "minLength": 1, "maxLength": 255 }, - "description": "Snapshot of label names on the entity at event time." + "description": "Snapshot of label names on the entity at event time. See state.conversation for conversation-specific label semantics." + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "required": ["category"], + "properties": { + "category": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Backend-stable category id when available (e.g. GitHub GraphQL node id)." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable category name (GitHub Discussion category; Slack channel section when mapped)." + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Backend slug when available. On GitHub, present in webhook REST payloads (discussion.category.slug) but not on GraphQL DiscussionCategory; GITHUB_EVENT_PATH adapters can populate it, GraphQL-only enrichment cannot. Treat as untrusted input even after schema validation — no structural pattern is enforced so Slack/Discord/Matrix adapters can pass through backend identifiers." + }, + "format": { + "type": "string", + "enum": [ + "discussion", + "question_answer", + "announcement", + "poll", + "other" + ], + "description": "Optional portable category format for routing. Not a permission authority (ADR 0054). GitHub GraphQL DiscussionCategory exposes isAnswerable but no announcement/poll/discussion format field: adapters MUST set question_answer when isAnswerable is true, and SHOULD omit format otherwise unless they document an explicit heuristic (e.g. name/slug matching). UI-only GitHub create restrictions (e.g. announcement maintain/admin) are not queryable via the public API and MUST NOT be inferred solely from this field." + } + }, + "description": "Exclusive category of the conversation (1:M category→conversation). Distinct from state.labels (M:M tags on the conversation, not on threads or messages)." + } + }, + "description": "Required when entity.kind is conversation and forbidden otherwise (ADR 0086). Snapshot of conversation metadata at event time. Threading is on transition.comment (id / parent_id), not here. state.labels are labels on the conversation; category is not a label; threads and messages are not labeled independently." }, "change_proposal": { "type": "object", diff --git a/internal/normevent/event.go b/internal/normevent/event.go index e11e8fc204..eb65a095fc 100644 --- a/internal/normevent/event.go +++ b/internal/normevent/event.go @@ -18,15 +18,18 @@ type Event struct { Source Source `json:"source"` } -// EntityKind identifies work items vs change proposals. +// EntityKind identifies work items, change proposals, or conversations. +// Threads are not a separate entity kind; reply association uses +// transition.comment.parent_id (ADR 0086). type EntityKind string const ( EntityWorkItem EntityKind = "work_item" EntityChangeProposal EntityKind = "change_proposal" + EntityConversation EntityKind = "conversation" ) -// Entity is the target work item or change proposal. +// Entity is the target work item, change proposal, or conversation. type Entity struct { Kind EntityKind `json:"kind"` ID int `json:"id"` @@ -74,8 +77,14 @@ type LabelChange struct { Action string `json:"action"` // added | removed } -// Comment describes a comment_added transition. +// Comment describes a comment_* transition. +// ID and ParentID are backend-native message ids (ADR 0086): GitHub comment +// id/node_id and normalized parent_id; Slack ts and thread_ts. +// For conversation comment events both are required; ParentID is always the +// thread-root id (equal to ID when this message is the root). type Comment struct { + ID string `json:"id,omitempty"` + ParentID string `json:"parent_id,omitempty"` Command string `json:"command,omitempty"` Body string `json:"body"` Instruction string `json:"instruction,omitempty"` @@ -118,8 +127,22 @@ type Actor struct { // State is a snapshot of entity metadata at event time. type State struct { - Labels []string `json:"labels"` - ChangeProposal *ChangeProposal `json:"change_proposal,omitempty"` + Labels []string `json:"labels"` + ChangeProposal *ChangeProposal `json:"change_proposal,omitempty"` + Conversation *ConversationState `json:"conversation,omitempty"` +} + +// ConversationState is conversation-level metadata (ADR 0086). +type ConversationState struct { + Category ConversationCategory `json:"category"` +} + +// ConversationCategory is the exclusive category of a conversation. +type ConversationCategory struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + Format string `json:"format,omitempty"` } // ChangeProposal carries branch/repo context for PR/MR workloads. @@ -224,6 +247,26 @@ func (e *Event) Validate() error { return fmt.Errorf("normalized event: linked_change_proposal forbidden when entity.kind is change_proposal") } + if e.Entity.Kind == EntityConversation { + if e.State.Conversation == nil { + return fmt.Errorf("normalized event: state.conversation required when entity.kind is conversation") + } + if strings.TrimSpace(e.State.Conversation.Category.Name) == "" { + return fmt.Errorf("normalized event: state.conversation.category.name is required") + } + switch e.Transition.Kind { + case TransitionCommentAdded, TransitionCommentEdited, TransitionCommentDeleted: + if e.Transition.Comment == nil || strings.TrimSpace(e.Transition.Comment.ID) == "" { + return fmt.Errorf("normalized event: transition.comment.id required for comment transitions on conversations") + } + if strings.TrimSpace(e.Transition.Comment.ParentID) == "" { + return fmt.Errorf("normalized event: transition.comment.parent_id required for comment transitions on conversations") + } + } + } else if e.State.Conversation != nil { + return fmt.Errorf("normalized event: state.conversation forbidden when entity.kind is %s", e.Entity.Kind) + } + if e.State.ChangeProposal != nil && e.Entity.Kind == EntityWorkItem && e.Entity.LinkedChangeProposal == nil { return fmt.Errorf("normalized event: entity.linked_change_proposal required when state.change_proposal is set on work_item") } diff --git a/internal/normevent/event_test.go b/internal/normevent/event_test.go index 142c8cd732..8f4b4e9e4d 100644 --- a/internal/normevent/event_test.go +++ b/internal/normevent/event_test.go @@ -74,3 +74,127 @@ func TestToMap_RoundTrip(t *testing.T) { require.NoError(t, err) assert.Equal(t, "work_item", m["entity"].(map[string]any)["kind"]) } + +func TestParseJSON_ConversationExample(t *testing.T) { + dir := examplesDir(t) + data, err := os.ReadFile(filepath.Join(dir, "discussion-fs-vouch-comment.json")) + require.NoError(t, err) + ev, err := ParseJSON(data) + require.NoError(t, err) + require.Equal(t, EntityConversation, ev.Entity.Kind) + require.NotNil(t, ev.State.Conversation) + assert.Equal(t, "Vouch Request", ev.State.Conversation.Category.Name) + assert.Equal(t, "vouch-request", ev.State.Conversation.Category.Slug) + require.NotNil(t, ev.Transition.Comment) + assert.Equal(t, "DC_kwDOExampleComment", ev.Transition.Comment.ID) + assert.Equal(t, "DC_kwDOExampleComment", ev.Transition.Comment.ParentID) + + m, err := ev.ToMap() + require.NoError(t, err) + conv := m["state"].(map[string]any)["conversation"].(map[string]any) + cat := conv["category"].(map[string]any) + assert.Equal(t, "vouch-request", cat["slug"]) + comment := m["transition"].(map[string]any)["comment"].(map[string]any) + assert.Equal(t, "DC_kwDOExampleComment", comment["id"]) + assert.Equal(t, "DC_kwDOExampleComment", comment["parent_id"]) +} + +func TestValidate_ConversationRules(t *testing.T) { + base := Event{ + Repo: "fullsend-ai/fullsend", + Entity: Entity{ + Kind: EntityConversation, + ID: 1, + URL: "https://github.com/fullsend-ai/fullsend/discussions/1", + }, + Transition: Transition{Kind: TransitionOpened}, + Actor: Actor{ + ID: "user", + Kind: ActorHuman, + Role: RoleWrite, + }, + State: State{ + Labels: []string{}, + Conversation: &ConversationState{ + Category: ConversationCategory{Name: "General"}, + }, + }, + Source: Source{System: SystemGitHub, RawType: "discussion"}, + } + + t.Run("missing_conversation", func(t *testing.T) { + ev := base + ev.State.Conversation = nil + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "state.conversation required") + }) + + t.Run("empty_category_name", func(t *testing.T) { + ev := base + ev.State.Conversation = &ConversationState{ + Category: ConversationCategory{Name: " "}, + } + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "category.name") + }) + + t.Run("conversation_forbidden_on_work_item", func(t *testing.T) { + ev := base + ev.Entity.Kind = EntityWorkItem + ev.Entity.URL = "https://github.com/fullsend-ai/fullsend/issues/1" + ev.Source.RawType = "issues" + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "state.conversation forbidden") + }) + + t.Run("comment_requires_id", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{Body: "/fs-vouch", ParentID: "DC_kwDO"}, + } + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "transition.comment.id required") + }) + + t.Run("comment_requires_parent_id", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{ID: "DC_kwDOComment", Body: "/fs-vouch"}, + } + err := ev.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "transition.comment.parent_id required") + }) + + t.Run("comment_with_id_and_parent_ok", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{ + ID: "DC_kwDOComment", + ParentID: "DC_kwDOComment", + Body: "/fs-vouch", + }, + } + require.NoError(t, ev.Validate()) + }) + + t.Run("comment_reply_parent_ok", func(t *testing.T) { + ev := base + ev.Transition = Transition{ + Kind: TransitionCommentAdded, + Comment: &Comment{ + ID: "DC_kwDOReply", + ParentID: "DC_kwDOParent", + Body: "following up", + }, + } + require.NoError(t, ev.Validate()) + }) +}