Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions Deployment/Networking/PrompterIngress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ namespace Cratis.Prompter.Deployment.Networking;
/// </summary>
/// <remarks>
/// The bot dials out to Discord, so nothing about answering needs to be reachable from the internet. The
/// one inbound caller is the Documentation build's re-index webhook, so the ingress exposes exactly that
/// path and nothing else — <c>/healthz</c> stays cluster-internal for the probes to use. The NGINX
/// inbound callers are the Documentation build's re-index trigger and GitHub's webhook deliveries, so the
/// ingress exposes exactly those two paths and nothing else — <c>/healthz</c> stays cluster-internal for the
/// probes to use. Both are authenticated by the application itself (a shared secret and an HMAC signature
/// respectively), so the ingress is routing, not a security boundary. The NGINX
/// controller and the <c>letsencrypt-prod</c> ClusterIssuer are cluster-scoped resources owned by Studio's
/// stack; this only references them by name.
/// </remarks>
public sealed class PrompterIngress
{
/// <summary>
/// The paths published to the internet. Adding the GitHub webhook (BACKLOG P-44) means adding its path
/// here — everything else on the host stays unroutable.
/// The paths published to the internet: the Documentation build's re-index trigger and GitHub's webhook
/// deliveries. Everything else on the host — <c>/healthz</c> included — stays unroutable from outside.
/// </summary>
static readonly string[] _publicPaths = ["/reindex"];
static readonly string[] _publicPaths = ["/reindex", "/github/webhook"];

/// <summary>
/// Initializes a new instance of the <see cref="PrompterIngress"/> class.
Expand Down
13 changes: 13 additions & 0 deletions Deployment/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
var voyageApiKey = config.RequireSecret("voyageApiKey");
var reindexSecret = config.RequireSecret("reindexSecret");

// The GitHub credentials are optional: an empty token leaves issue filing off and an empty webhook
// secret makes the webhook endpoint refuse everything, so a deployment that sets neither behaves
// exactly as it did before the tracker bridge existed (decision D-16).
var gitHubToken = config.GetSecret("gitHubToken") ?? Output.CreateSecret(string.Empty);
var gitHubWebhookSecret = config.GetSecret("gitHubWebhookSecret") ?? Output.CreateSecret(string.Empty);
var answeringRepositories = config.Get("answeringRepositories");
var issueNotifyChannelId = config.Get("issueNotifyChannelId");

var askChannelId = config.Get("askChannelId");
var helpForumChannelId = config.Get("helpForumChannelId");

Expand Down Expand Up @@ -75,6 +83,10 @@
ReindexSecret = reindexSecret,
AskChannelId = askChannelId,
HelpForumChannelId = helpForumChannelId,
GitHubToken = gitHubToken,
GitHubWebhookSecret = gitHubWebhookSecret,
AnsweringRepositories = answeringRepositories,
IssueNotifyChannelId = issueNotifyChannelId,
DependsOn = { postgres.Resource },
});

Expand All @@ -94,5 +106,6 @@
["namespace"] = namespaceName,
["image"] = image,
["reindexUrl"] = $"https://{host}/reindex",
["gitHubWebhookUrl"] = $"https://{host}/github/webhook",
};
});
9 changes: 8 additions & 1 deletion Deployment/Pulumi.production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ config:
# --- Discord channels (non-secret ids; omit to disable that surface) -------
# prompter-deployment:askChannelId: "000000000000000000"
# prompter-deployment:helpForumChannelId: "000000000000000000"
# --- Tracker bridge (D-16; optional, off until the secrets are set) --------
# Repositories whose newly-opened issues Prompter may answer, comma-separated owner/name. Empty answers
# nowhere: commenting on a tracker is opt-in per repository.
# prompter-deployment:answeringRepositories: "Cratis/Chronicle,Cratis/Arc"
# Channel that gets the enriched "new issue, and whether the docs already answer it" announcement.
# prompter-deployment:issueNotifyChannelId: "000000000000000000"
#
# --- Secrets --------------------------------------------------------------
# postgresPassword, discordToken, anthropicApiKey, voyageApiKey and reindexSecret are set with
# postgresPassword, discordToken, anthropicApiKey, voyageApiKey and reindexSecret (plus the optional
# gitHubToken and gitHubWebhookSecret) are set with
# `./scripts/set-secrets.sh` and land here as passphrase-encrypted `secure:` values, which is what
# makes them safe to commit. They are absent until that script runs — `pulumi up` fails fast naming
# the missing key, which is the intended behavior.
21 changes: 21 additions & 0 deletions Deployment/Services/PrompterDeployment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public PrompterDeployment(PrompterDeploymentArgs args)
["Cratis__Prompter__Anthropic__ApiKey"] = args.AnthropicApiKey,
["Cratis__Prompter__Voyage__ApiKey"] = args.VoyageApiKey,
["Cratis__Prompter__ReindexSecret"] = args.ReindexSecret,
["Cratis__Prompter__GitHub__Token"] = args.GitHubToken,
["Cratis__Prompter__GitHub__WebhookSecret"] = args.GitHubWebhookSecret,
},
},
new CustomResourceOptions { Provider = args.Provider, DependsOn = [args.NamespaceResource] });
Expand All @@ -82,6 +84,25 @@ public PrompterDeployment(PrompterDeploymentArgs args)
env.Add(new EnvVarArgs { Name = "Cratis__Prompter__Discord__HelpForumChannelId", Value = args.HelpForumChannelId });
}

if (!string.IsNullOrWhiteSpace(args.IssueNotifyChannelId))
{
env.Add(new EnvVarArgs { Name = "Cratis__Prompter__GitHub__NotifyChannelId", Value = args.IssueNotifyChannelId });
}

// The allowlist binds as an indexed list, so each entry gets its own variable. An empty setting
// leaves the list empty, which means Prompter answers on no tracker at all.
var answering = (args.AnsweringRepositories ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

for (var index = 0; index < answering.Length; index++)
{
env.Add(new EnvVarArgs
{
Name = $"Cratis__Prompter__GitHub__AnsweringRepositories__{index}",
Value = answering[index]
});
}

var container = new ContainerArgs
{
Name = Name,
Expand Down
23 changes: 23 additions & 0 deletions Deployment/Services/PrompterDeploymentArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ public sealed class PrompterDeploymentArgs
/// </summary>
public required Output<string> ReindexSecret { get; init; }

/// <summary>
/// Gets the token used to file issues and comment on them. Empty leaves issue filing switched off, and
/// the <c>/issue</c> command says so rather than failing opaquely.
/// </summary>
public required Output<string> GitHubToken { get; init; }

/// <summary>
/// Gets the secret GitHub signs webhook deliveries with. Empty makes the webhook endpoint refuse
/// everything, which is the safe posture for an unconfigured deployment.
/// </summary>
public required Output<string> GitHubWebhookSecret { get; init; }

/// <summary>
/// Gets the repositories whose newly-opened issues may be answered, as a comma-separated
/// <c>owner/name</c> list. Empty answers nowhere — answering someone's tracker is opt-in.
/// </summary>
public string? AnsweringRepositories { get; init; }

/// <summary>
/// Gets the channel new issues are announced in, if configured.
/// </summary>
public string? IssueNotifyChannelId { get; init; }

/// <summary>
/// Gets the id of the channel where plain messages are treated as questions, if configured.
/// </summary>
Expand Down
7 changes: 7 additions & 0 deletions Deployment/scripts/set-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
# export ANTHROPIC_API_KEY=...
# export VOYAGE_API_KEY=...
# export REINDEX_SECRET=...
# export GITHUB_TOKEN=... # optional: lets Prompter file issues
# export GITHUB_WEBHOOK_SECRET=... # optional: lets Prompter receive issue events
# ./scripts/set-secrets.sh
#
# Generating the two secrets that are ours to invent:
# openssl rand -base64 32 # postgresPassword
# openssl rand -hex 32 # reindexSecret (also goes into the Documentation repo's webhook call)
# openssl rand -hex 32 # gitHubWebhookSecret (also goes into each repository's webhook settings)
#
set -euo pipefail

Expand All @@ -39,4 +42,8 @@ set_secret anthropicApiKey "${ANTHROPIC_API_KEY:-}"
set_secret voyageApiKey "${VOYAGE_API_KEY:-}"
set_secret reindexSecret "${REINDEX_SECRET:-}"

# Optional - the tracker bridge (D-16). Leaving both unset keeps issue filing and the webhook off.
set_secret gitHubToken "${GITHUB_TOKEN:-}"
set_secret gitHubWebhookSecret "${GITHUB_WEBHOOK_SECRET:-}"

echo "Done. Review Deployment/Pulumi.${STACK}.yaml — secrets are stored as 'secure: v1:...'."
61 changes: 61 additions & 0 deletions Documentation/guides/reporting-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Report something with Prompter
description: Turn a Discord conversation into a GitHub issue on the right Cratis repository, without leaving the chat.
---

Good bug reports die in chat all the time. Someone hits a real problem at midnight, describes it perfectly in
a thread, gets a workaround, and the tracker never hears about it. The description was never the hard part -
the transcription was.

Prompter closes that gap. Describe the problem where you already are, and it writes the issue for you.

## Filing something

Use the `/issue` command with a description of what happened:

```text
/issue The projection stops updating after I rename a property. It worked in 16.0.4.
```

Prompter drafts the issue - a title, a body, what kind of work it is, and which repository it belongs in -
and shows it back to you privately. Nobody else sees the draft. You get three choices:

- **Create issue** - it is filed, and you get the link.
- **Cancel** - nothing happens, and the draft is discarded.
- Neither - the draft expires by itself after fifteen minutes.

Nothing reaches GitHub until you press the button. That is deliberate: filing is always a person's decision,
never an inference from something you typed.

## What Prompter fills in

The drafted issue carries what you said, a link back to the Discord conversation, and a note that Prompter
filed it on your behalf. The link is how a maintainer asks you a follow-up question - your Discord name is
never written into the issue, because the thread already knows who was there and a public tracker does not
need to.

It also picks the repository from what you described. When it cannot tell which product you mean, it says so
in the preview rather than guessing quietly, so you can correct it before anything is public.

## Anything worth tracking

`/issue` is not only for bugs. Use it for a missing API, a feature you want, a rough idea worth discussing, or
documentation that does not exist or cannot be found. Prompter works out which it is and labels it
accordingly.

If something similar is already open, Prompter shows it in the preview - commenting on the existing issue is
usually more useful than opening a second one.

## When Prompter answers your issue

On repositories that opt in, Prompter also reads newly-opened issues and comments with a grounded answer when
the documentation covers the question. If it cannot answer from the docs, it stays quiet - silence on a
tracker costs nothing, and a hedged guess costs a maintainer's attention.

To stop it commenting on a particular issue, label the issue `no-prompter`.

## What is not stored

Nothing you write here is kept by Prompter. The draft lives in memory until you confirm or it expires, and the
[privacy](../concepts/privacy.md) posture is unchanged: no message content, nothing that identifies you. What
becomes public is exactly what you approved in the preview.
2 changes: 2 additions & 0 deletions Documentation/guides/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
href: using-prompter.md
- name: Run Prompter locally
href: running-locally.md
- name: Report something with Prompter
href: reporting-issues.md
- name: Set up the Discord app
href: discord-setup.md
- name: Deploy Prompter
Expand Down
30 changes: 21 additions & 9 deletions Planning/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ are the two directions plus the notification; P-47 is what happens to an issue o

## Post-v1 surfaces (2026-08-06)

- **P-44** **GitHub issues surface** — Prompter answers newly-opened issues on the Cratis product repos with
- **P-44** ~~**GitHub issues surface**~~ **Done 2026-08-06** (code) — Prompter answers newly-opened issues on the Cratis product repos with
the same grounded retrieval it uses on Discord. `IAnswers.For` is surface-agnostic, so this is a new entry
point plus a webhook, not new answering logic: add `POST /github/webhook` to the Kestrel host that already
serves `/healthz` + `/reindex`, verify the `X-Hub-Signature-256` HMAC the same constant-time way
Expand All @@ -258,8 +258,13 @@ are the two directions plus the notification; P-47 is what happens to an issue o
endpoint is the cheaper spike if App registration is slow. Composes with **P-33**: a refusal on an issue
*is* a docs gap already sitting in a tracker — label it `docs-gap` and the filing problem disappears.
Distinct from **P-32**, which ingests *answered* issues as a retrieval source; the two compose.
Needs the deployed bot to be publicly reachable (M5.3 ingress), so it lands after P-21.
- **P-45** **File a GitHub issue from Discord** — turn a conversation into tracked work: a bug someone hit, an
**Shipped:** `POST /github/webhook` verifies the `X-Hub-Signature-256` HMAC against the raw body, ignores
everything that is not `issues.opened` (so a repository can point its whole webhook at it), skips bots and
pull requests, honors the `no-prompter` label, and answers only for repositories on the opt-in allowlist —
**staying silent on a refusal**, which is reported to the maintainer channel instead. The ingress publishes
the path; `WebhookAuth`/`IssueEvents`/`IssueAnswerComment` are spec-covered. Live verification needs the
deployed bot and a repository webhook (playbook Stage C3).
- **P-45** ~~**File a GitHub issue from Discord**~~ **Done 2026-08-06** (code) — turn a conversation into tracked work: a bug someone hit, an
API that is missing, a feature request, a half-formed idea, or a documentation gap. Two entry points: a
`/issue` slash command, and a **message context-menu action** ("File as issue") so an existing message or
thread can be captured without retyping it. Prompter drafts the issue from the conversation — title, body,
Expand All @@ -273,15 +278,22 @@ are the two directions plus the notification; P-47 is what happens to an issue o
recent open issues offers "this looks like #123 — comment there instead?" before opening a duplicate.
Routing is **Q-7**: the owning product repo, which needs the product classifier P-30 wants anyway; when the
classifier is unsure, ask in the preview rather than guessing.
A refusal or a 👎 additionally offers the same action pre-filled — that is the P-33 flywheel, now one case
of the general mechanism rather than its own feature.
- **P-46** **Tell Discord when a GitHub issue is opened** — maintainers should see tracker activity where they
**Shipped as the `/issue` command:** Prompter drafts title/body/kind/product with the model, routes to the
owning repository, offers likely duplicates, and shows an ephemeral preview with Create/Cancel; the draft
lives in memory for 15 minutes and is taken on click, so a double-click cannot file twice and an abandoned
draft leaves no trace. `IssueRouting`/`IssueComposition`/`IssueDraftParsing`/`IssueButton`/`IssuePreview`/
`PendingIssues` are spec-covered. **Residue:** the message context-menu entry point ("File as issue" on an
existing message) needs NetCord's message-command context registered, which was not compile-verifiable
against beta.12 in the same pass — the slash command covers the same ground meanwhile. A refusal or a 👎
offering the same action pre-filled (the P-33 flywheel) is likewise still to come.
- **P-46** ~~**Tell Discord when a GitHub issue is opened**~~ **Done 2026-08-06** (code) — maintainers should see tracker activity where they
already are. **Do the zero-code version first:** a Discord channel webhook URL with `/github` appended,
registered as a repo (or org) webhook for `issues` events — no Prompter involvement, working in minutes,
and it stays useful even if Prompter is down. Build it *into* Prompter (on top of P-44's webhook receiver)
only for what the native version cannot do: enriching the notification with Prompter's own read of the
issue ("already answered from the docs" / "no docs cover this — likely a real gap") and routing to
different channels by product.
only for what the native version cannot do — which is what shipped: `IssueNotification` posts to
`GitHub:NotifyChannelId` saying whether Prompter answered the issue from the docs or could not, which is the
line that turns a notification into triage. Spec-covered. Per-product channel routing is not built; one
channel today.

- **P-47** **Auto-implement the easy ones** — an issue that is genuinely mechanical (a typo, a missing null
guard, a doc page that should exist, a small API addition with an obvious shape) should not wait for a
Expand Down
4 changes: 3 additions & 1 deletion Planning/DISCORD_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ decisions are D-7 in [`DECISIONS.md`](DECISIONS.md).
| **Rate limit** | >5 questions / 10 min per user (`Discord:RateLimit`, per user id held in memory only) | Friendly "give me a breather" refusal, no answer — **ephemeral** on `/ask` so it does not clutter the channel, in-channel/in-thread on the other surfaces |
| **Long answers** | Answer > 2000 chars | On the mention/ask-channel path, split on paragraph/code-fence boundaries across up to 3 messages, sources on the last; `/ask` and the forum reply as a single message truncated with an ellipsis |
| **Failure** | Model/API error or timeout (`Discord:AnswerTimeoutSeconds`, default 60 s) | Short apology message — never silence, handler never throws |
| **/issue** | Slash command anywhere (added 2026-08-06, D-16) | Drafts a GitHub issue from the description — title, body, kind, product — and shows it as an **ephemeral** preview with Create/Cancel buttons (`issue:<action>:<token>`). Nothing reaches GitHub until the button is pressed; the draft is held in memory for 15 minutes, taken on click so a double-click cannot file twice, and never persisted. Routes to the owning product repository, offers likely duplicates, and says so in the preview when it could not tell which product. Shares the question rate limit; replies that filing is unconfigured when no token is set |

**Never in v1:** unprompted interjections in channels not listed above (D-7 — every surviving vendor converged
on mention/dedicated-channel; chime-in is a post-v1 experiment), DMs (not needed, keeps GDPR surface small),
and answering other bots.
and answering other bots. **Never at all:** filing an issue without someone pressing the button — D-16 makes
filing a deliberate act, never an inference from what a message said.

### Answer format

Expand Down
Loading
Loading