From fefaf14692e4f8362fdcc23b2f6413394fdc32b8 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 6 Aug 2026 13:21:27 +0200 Subject: [PATCH] Move work between Discord and the trackers (P-44, P-45, P-46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering was only half the job. The expensive part of a community question is rarely the answer — it is that the signal dies in chat. A bug reported at midnight, an API someone expected to exist, an idea that gets three "yes please" reactions: none of it reaches a tracker unless a maintainer happens to be reading and happens to transcribe it. Prompter is already in those conversations. /issue drafts the issue from what someone described — title, body, kind, product — routes it to the owning repository, offers likely duplicates, and shows an ephemeral preview. Nothing reaches GitHub until the button is pressed, which is what keeps filing a person's decision rather than an inference from a message. Drafts live in memory for fifteen minutes and are taken on click, so a double-click cannot file twice and an abandoned draft leaves no trace: D-13 is untouched, because nothing is persisted. POST /github/webhook verifies GitHub's 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 a no-prompter label, and answers only where a repository opted in. It stays silent when it cannot ground an answer — a tracker comment is the most visible thing this bot does, and a hedged guess costs a maintainer more attention than it saves. The maintainer channel is told either way, and "nothing in the docs covers this" is the more useful of the two. Everything is off until its credentials exist, so deployments that set nothing behave exactly as before. One spec caught a real bug: eviction ordered by timestamp ties when several drafts are held in the same clock tick, so "drop the oldest" dropped an arbitrary one. Ordered by insertion sequence now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015LV2femm3ZR47Lk2SscwJb --- Deployment/Networking/PrompterIngress.cs | 12 +- Deployment/Program.cs | 13 ++ Deployment/Pulumi.production.yaml | 9 +- Deployment/Services/PrompterDeployment.cs | 21 +++ Deployment/Services/PrompterDeploymentArgs.cs | 23 +++ Deployment/scripts/set-secrets.sh | 7 + Documentation/guides/reporting-issues.md | 61 ++++++++ Documentation/guides/toc.yml | 2 + Planning/BACKLOG.md | 30 ++-- Planning/DISCORD_INTEGRATION.md | 4 +- Planning/GO_LIVE_PLAYBOOK.md | 66 +++++++- Planning/SESSION_HANDOVER.md | 38 +++++ README.md | 9 ++ Source/Discord/FileIssue.cs | 141 ++++++++++++++++++ Source/Discord/FileIssueLogging.cs | 21 +++ Source/Discord/IssueButton.cs | 63 ++++++++ Source/Discord/IssueButtonClick.cs | 17 +++ Source/Discord/IssueConfirmation.cs | 112 ++++++++++++++ Source/Discord/IssueConfirmationLogging.cs | 18 +++ Source/Discord/IssuePreview.cs | 92 ++++++++++++ Source/GitHub/ExistingIssue.cs | 12 ++ Source/GitHub/FiledIssue.cs | 12 ++ Source/GitHub/IIssueDrafting.cs | 19 +++ Source/GitHub/IIssues.cs | 52 +++++++ Source/GitHub/IssueAnswerComment.cs | 37 +++++ Source/GitHub/IssueComposition.cs | 68 +++++++++ Source/GitHub/IssueDraft.cs | 15 ++ Source/GitHub/IssueDraftParsing.cs | 96 ++++++++++++ Source/GitHub/IssueDraftPrompt.cs | 63 ++++++++ Source/GitHub/IssueDrafting.cs | 29 ++++ Source/GitHub/IssueEvents.cs | 100 +++++++++++++ Source/GitHub/IssueKind.cs | 31 ++++ Source/GitHub/IssueNotification.cs | 44 ++++++ Source/GitHub/IssueRouting.cs | 60 ++++++++ Source/GitHub/Issues.cs | 97 ++++++++++++ Source/GitHub/IssuesLogging.cs | 18 +++ Source/GitHub/OpenedIssue.cs | 36 +++++ Source/GitHub/PendingIssue.cs | 12 ++ Source/GitHub/PendingIssues.cs | 102 +++++++++++++ Source/GitHub/WebhookAuth.cs | 65 ++++++++ Source/GitHubOptions.cs | 96 ++++++++++++ Source/Hosting/HostBuilderExtensions.cs | 18 +++ Source/Operations/GitHubWebhook.cs | 104 +++++++++++++ Source/Operations/GitHubWebhookLogging.cs | 18 +++ Source/Operations/OperationsLogging.cs | 12 ++ Source/Operations/PrompterEndpoints.cs | 63 ++++++++ Source/Program.cs | 1 + Source/PrompterOptions.cs | 6 + Specs/Fakes/ControlledTime.cs | 16 ++ .../and_the_answer_has_no_sources.cs | 17 +++ .../and_the_answer_has_sources.cs | 21 +++ .../and_the_action_files_the_issue.cs | 16 ++ .../when_parsing/and_the_custom_id_cancels.cs | 16 ++ .../and_the_custom_id_files_the_issue.cs | 17 +++ .../and_the_custom_id_is_not_ours.cs | 14 ++ .../and_the_conversation_is_linkable.cs | 19 +++ .../and_there_is_no_conversation_link.cs | 16 ++ .../and_the_issue_is_a_bug.cs | 17 +++ .../and_the_provenance_label_is_empty.cs | 18 +++ .../when_parsing/and_the_body_is_blank.cs | 15 ++ .../when_parsing/and_the_json_is_malformed.cs | 15 ++ ...and_the_json_is_wrapped_in_a_code_fence.cs | 24 +++ .../when_parsing/and_the_reply_has_no_json.cs | 15 ++ .../and_the_reply_is_bare_json.cs | 19 +++ .../when_parsing/and_the_title_is_missing.cs | 15 ++ .../and_it_is_one_of_the_known_kinds.cs | 14 ++ .../and_it_is_unrecognized.cs | 12 ++ .../and_the_reporter_added_a_hint.cs | 16 ++ .../and_there_is_no_hint.cs | 17 +++ .../and_a_bot_opened_it.cs | 17 +++ .../and_no_repository_opted_in.cs | 17 +++ .../and_the_issue_opted_out.cs | 18 +++ .../and_the_repository_did_not_opt_in.cs | 17 +++ .../and_the_repository_opted_in.cs | 18 +++ .../and_a_bot_opened_the_issue.cs | 22 +++ .../and_an_issue_was_opened.cs | 34 +++++ .../and_it_is_a_pull_request.cs | 22 +++ .../and_the_action_is_not_opened.cs | 16 ++ .../and_the_payload_is_malformed.cs | 15 ++ .../and_answering_was_not_attempted.cs | 18 +++ .../and_prompter_answered_it.cs | 19 +++ .../and_prompter_could_not_answer_it.cs | 17 +++ .../and_similar_issues_are_already_open.cs | 21 +++ .../and_the_body_is_enormous.cs | 21 +++ .../and_the_product_was_not_recognized.cs | 20 +++ .../and_the_product_was_recognized.cs | 25 ++++ .../and_the_defaults_are_configured.cs | 18 +++ .../and_no_product_was_named.cs | 18 +++ .../and_the_product_is_cased_differently.cs | 15 ++ .../and_the_product_is_known.cs | 17 +++ ...the_product_is_surrounded_by_whitespace.cs | 15 ++ .../and_the_product_is_unknown.cs | 17 +++ .../and_another_is_already_held.cs | 26 ++++ .../and_the_capacity_is_exceeded.cs | 30 ++++ .../and_it_has_expired.cs | 25 ++++ .../and_it_is_still_fresh.cs | 27 ++++ .../and_it_was_already_taken.cs | 25 ++++ .../and_the_token_is_unknown.cs | 16 ++ .../and_no_secret_is_configured.cs | 16 ++ .../and_the_payload_was_altered.cs | 26 ++++ .../and_the_signature_header_is_absent.cs | 16 ++ .../and_the_signature_is_not_prefixed.cs | 16 ++ .../and_the_signature_is_uppercase_hex.cs | 24 +++ .../and_the_signature_matches.cs | 26 ++++ 104 files changed, 3081 insertions(+), 23 deletions(-) create mode 100644 Documentation/guides/reporting-issues.md create mode 100644 Source/Discord/FileIssue.cs create mode 100644 Source/Discord/FileIssueLogging.cs create mode 100644 Source/Discord/IssueButton.cs create mode 100644 Source/Discord/IssueButtonClick.cs create mode 100644 Source/Discord/IssueConfirmation.cs create mode 100644 Source/Discord/IssueConfirmationLogging.cs create mode 100644 Source/Discord/IssuePreview.cs create mode 100644 Source/GitHub/ExistingIssue.cs create mode 100644 Source/GitHub/FiledIssue.cs create mode 100644 Source/GitHub/IIssueDrafting.cs create mode 100644 Source/GitHub/IIssues.cs create mode 100644 Source/GitHub/IssueAnswerComment.cs create mode 100644 Source/GitHub/IssueComposition.cs create mode 100644 Source/GitHub/IssueDraft.cs create mode 100644 Source/GitHub/IssueDraftParsing.cs create mode 100644 Source/GitHub/IssueDraftPrompt.cs create mode 100644 Source/GitHub/IssueDrafting.cs create mode 100644 Source/GitHub/IssueEvents.cs create mode 100644 Source/GitHub/IssueKind.cs create mode 100644 Source/GitHub/IssueNotification.cs create mode 100644 Source/GitHub/IssueRouting.cs create mode 100644 Source/GitHub/Issues.cs create mode 100644 Source/GitHub/IssuesLogging.cs create mode 100644 Source/GitHub/OpenedIssue.cs create mode 100644 Source/GitHub/PendingIssue.cs create mode 100644 Source/GitHub/PendingIssues.cs create mode 100644 Source/GitHub/WebhookAuth.cs create mode 100644 Source/GitHubOptions.cs create mode 100644 Source/Operations/GitHubWebhook.cs create mode 100644 Source/Operations/GitHubWebhookLogging.cs create mode 100644 Specs/Fakes/ControlledTime.cs create mode 100644 Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_no_sources.cs create mode 100644 Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_sources.cs create mode 100644 Specs/for_IssueButton/when_building_a_custom_id/and_the_action_files_the_issue.cs create mode 100644 Specs/for_IssueButton/when_parsing/and_the_custom_id_cancels.cs create mode 100644 Specs/for_IssueButton/when_parsing/and_the_custom_id_files_the_issue.cs create mode 100644 Specs/for_IssueButton/when_parsing/and_the_custom_id_is_not_ours.cs create mode 100644 Specs/for_IssueComposition/when_building_a_body/and_the_conversation_is_linkable.cs create mode 100644 Specs/for_IssueComposition/when_building_a_body/and_there_is_no_conversation_link.cs create mode 100644 Specs/for_IssueComposition/when_building_labels/and_the_issue_is_a_bug.cs create mode 100644 Specs/for_IssueComposition/when_building_labels/and_the_provenance_label_is_empty.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing/and_the_body_is_blank.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_malformed.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_wrapped_in_a_code_fence.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing/and_the_reply_has_no_json.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing/and_the_reply_is_bare_json.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing/and_the_title_is_missing.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_one_of_the_known_kinds.cs create mode 100644 Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_unrecognized.cs create mode 100644 Specs/for_IssueDraftPrompt/when_building_the_user_message/and_the_reporter_added_a_hint.cs create mode 100644 Specs/for_IssueDraftPrompt/when_building_the_user_message/and_there_is_no_hint.cs create mode 100644 Specs/for_IssueEvents/when_deciding_to_answer/and_a_bot_opened_it.cs create mode 100644 Specs/for_IssueEvents/when_deciding_to_answer/and_no_repository_opted_in.cs create mode 100644 Specs/for_IssueEvents/when_deciding_to_answer/and_the_issue_opted_out.cs create mode 100644 Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_did_not_opt_in.cs create mode 100644 Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_opted_in.cs create mode 100644 Specs/for_IssueEvents/when_parsing_a_delivery/and_a_bot_opened_the_issue.cs create mode 100644 Specs/for_IssueEvents/when_parsing_a_delivery/and_an_issue_was_opened.cs create mode 100644 Specs/for_IssueEvents/when_parsing_a_delivery/and_it_is_a_pull_request.cs create mode 100644 Specs/for_IssueEvents/when_parsing_a_delivery/and_the_action_is_not_opened.cs create mode 100644 Specs/for_IssueEvents/when_parsing_a_delivery/and_the_payload_is_malformed.cs create mode 100644 Specs/for_IssueNotification/when_announcing_an_issue/and_answering_was_not_attempted.cs create mode 100644 Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_answered_it.cs create mode 100644 Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_could_not_answer_it.cs create mode 100644 Specs/for_IssuePreview/when_rendering/and_similar_issues_are_already_open.cs create mode 100644 Specs/for_IssuePreview/when_rendering/and_the_body_is_enormous.cs create mode 100644 Specs/for_IssuePreview/when_rendering/and_the_product_was_not_recognized.cs create mode 100644 Specs/for_IssuePreview/when_rendering/and_the_product_was_recognized.cs create mode 100644 Specs/for_IssueRouting/when_listing_choices/and_the_defaults_are_configured.cs create mode 100644 Specs/for_IssueRouting/when_routing_a_product/and_no_product_was_named.cs create mode 100644 Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_cased_differently.cs create mode 100644 Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_known.cs create mode 100644 Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_surrounded_by_whitespace.cs create mode 100644 Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_unknown.cs create mode 100644 Specs/for_PendingIssues/when_holding_a_draft/and_another_is_already_held.cs create mode 100644 Specs/for_PendingIssues/when_holding_many_drafts/and_the_capacity_is_exceeded.cs create mode 100644 Specs/for_PendingIssues/when_taking_a_held_draft/and_it_has_expired.cs create mode 100644 Specs/for_PendingIssues/when_taking_a_held_draft/and_it_is_still_fresh.cs create mode 100644 Specs/for_PendingIssues/when_taking_a_held_draft/and_it_was_already_taken.cs create mode 100644 Specs/for_PendingIssues/when_taking_a_held_draft/and_the_token_is_unknown.cs create mode 100644 Specs/for_WebhookAuth/when_verifying_a_delivery/and_no_secret_is_configured.cs create mode 100644 Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_payload_was_altered.cs create mode 100644 Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_header_is_absent.cs create mode 100644 Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_not_prefixed.cs create mode 100644 Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_uppercase_hex.cs create mode 100644 Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_matches.cs diff --git a/Deployment/Networking/PrompterIngress.cs b/Deployment/Networking/PrompterIngress.cs index 2a272e4..666fd64 100644 --- a/Deployment/Networking/PrompterIngress.cs +++ b/Deployment/Networking/PrompterIngress.cs @@ -13,18 +13,20 @@ namespace Cratis.Prompter.Deployment.Networking; /// /// /// 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 — /healthz 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 — /healthz 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 letsencrypt-prod ClusterIssuer are cluster-scoped resources owned by Studio's /// stack; this only references them by name. /// public sealed class PrompterIngress { /// - /// 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 — /healthz included — stays unroutable from outside. /// - static readonly string[] _publicPaths = ["/reindex"]; + static readonly string[] _publicPaths = ["/reindex", "/github/webhook"]; /// /// Initializes a new instance of the class. diff --git a/Deployment/Program.cs b/Deployment/Program.cs index 8b38fc6..2524fdd 100644 --- a/Deployment/Program.cs +++ b/Deployment/Program.cs @@ -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"); @@ -75,6 +83,10 @@ ReindexSecret = reindexSecret, AskChannelId = askChannelId, HelpForumChannelId = helpForumChannelId, + GitHubToken = gitHubToken, + GitHubWebhookSecret = gitHubWebhookSecret, + AnsweringRepositories = answeringRepositories, + IssueNotifyChannelId = issueNotifyChannelId, DependsOn = { postgres.Resource }, }); @@ -94,5 +106,6 @@ ["namespace"] = namespaceName, ["image"] = image, ["reindexUrl"] = $"https://{host}/reindex", + ["gitHubWebhookUrl"] = $"https://{host}/github/webhook", }; }); diff --git a/Deployment/Pulumi.production.yaml b/Deployment/Pulumi.production.yaml index 2a443ae..6525cfb 100644 --- a/Deployment/Pulumi.production.yaml +++ b/Deployment/Pulumi.production.yaml @@ -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. diff --git a/Deployment/Services/PrompterDeployment.cs b/Deployment/Services/PrompterDeployment.cs index a01b535..5d79242 100644 --- a/Deployment/Services/PrompterDeployment.cs +++ b/Deployment/Services/PrompterDeployment.cs @@ -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] }); @@ -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, diff --git a/Deployment/Services/PrompterDeploymentArgs.cs b/Deployment/Services/PrompterDeploymentArgs.cs index 6a9b782..9af8582 100644 --- a/Deployment/Services/PrompterDeploymentArgs.cs +++ b/Deployment/Services/PrompterDeploymentArgs.cs @@ -61,6 +61,29 @@ public sealed class PrompterDeploymentArgs /// public required Output ReindexSecret { get; init; } + /// + /// Gets the token used to file issues and comment on them. Empty leaves issue filing switched off, and + /// the /issue command says so rather than failing opaquely. + /// + public required Output GitHubToken { get; init; } + + /// + /// Gets the secret GitHub signs webhook deliveries with. Empty makes the webhook endpoint refuse + /// everything, which is the safe posture for an unconfigured deployment. + /// + public required Output GitHubWebhookSecret { get; init; } + + /// + /// Gets the repositories whose newly-opened issues may be answered, as a comma-separated + /// owner/name list. Empty answers nowhere — answering someone's tracker is opt-in. + /// + public string? AnsweringRepositories { get; init; } + + /// + /// Gets the channel new issues are announced in, if configured. + /// + public string? IssueNotifyChannelId { get; init; } + /// /// Gets the id of the channel where plain messages are treated as questions, if configured. /// diff --git a/Deployment/scripts/set-secrets.sh b/Deployment/scripts/set-secrets.sh index 7a57ac5..7ff145f 100755 --- a/Deployment/scripts/set-secrets.sh +++ b/Deployment/scripts/set-secrets.sh @@ -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 @@ -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:...'." diff --git a/Documentation/guides/reporting-issues.md b/Documentation/guides/reporting-issues.md new file mode 100644 index 0000000..aeaf6bd --- /dev/null +++ b/Documentation/guides/reporting-issues.md @@ -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. diff --git a/Documentation/guides/toc.yml b/Documentation/guides/toc.yml index 6299d48..0726092 100644 --- a/Documentation/guides/toc.yml +++ b/Documentation/guides/toc.yml @@ -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 diff --git a/Planning/BACKLOG.md b/Planning/BACKLOG.md index bde0f95..b710d36 100644 --- a/Planning/BACKLOG.md +++ b/Planning/BACKLOG.md @@ -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 @@ -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, @@ -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 diff --git a/Planning/DISCORD_INTEGRATION.md b/Planning/DISCORD_INTEGRATION.md index 09efcad..f3011cb 100644 --- a/Planning/DISCORD_INTEGRATION.md +++ b/Planning/DISCORD_INTEGRATION.md @@ -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::`). 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 diff --git a/Planning/GO_LIVE_PLAYBOOK.md b/Planning/GO_LIVE_PLAYBOOK.md index 1c01f02..c973f9b 100644 --- a/Planning/GO_LIVE_PLAYBOOK.md +++ b/Planning/GO_LIVE_PLAYBOOK.md @@ -5,18 +5,20 @@ worked through in order by a human — Sindre or Einari — with no reading of t required. Each step says who can do it, exactly what to run, and how you know it worked. Nothing here is code work. The bot is code-complete and released; what remains is credentials, a test run, -one calibration, and the cluster. +one calibration, and the cluster. Stage C switches on the tracker bridge, which ships in the same image and +stays off until its credentials exist. > Where we are, verified 2026-08-06: **v0.1.1 released**, `cratis/prompter:0.1.1` and `:latest` on Docker Hub > (amd64 + arm64), image pulls and starts. The Pulumi stack exists but **has never been applied**. No API > keys, no Discord application, no cluster secrets. The corpus has never been indexed for real. -## The two stages, and why in this order +## The stages, and why in this order | Stage | What it proves | Time | Cost | |---|---|---|---| | **A · Laptop + test server** | The whole product actually works — retrieval, answers, citations, every Discord surface | An afternoon | Free tier + a few cents of API | | **B · Cluster** | It stays up without a laptop, and the docs stay fresh automatically | An hour, plus DNS propagation | Marginal — the cluster is already paid for | +| **C · Tracker bridge** | Filing issues from Discord, answering new ones, notifying maintainers | Under an hour | Nothing | Do not skip A. The bot dials *out* to Discord, so a laptop is a completely legitimate way to run the real thing against a test server — and every problem you find there is one you are not debugging through @@ -238,6 +240,56 @@ visibly. --- +--- + +## Stage C — the tracker bridge (optional, any time after A) + +The issue features ship in the same image and stay **off** until their credentials exist, so none of this +blocks going live. Turn them on when the bot is answering reliably. + +### C1 · Plain issue notifications in Discord — no code, five minutes · *Sindre or Einari* + +Do this one first regardless of the rest; it needs nothing from Prompter and keeps working when Prompter is +down. + +1. In the Discord channel you want them in: **Edit Channel → Integrations → Webhooks → New Webhook**, copy + the URL. +2. **Append `/github` to that URL.** +3. On each repository: **Settings → Webhooks → Add webhook**, paste the URL, content type + `application/json`, and select the **Issues** event. + +**Verify:** open a throwaway issue and watch it appear in the channel. Close it again. + +### C2 · Let Prompter file issues from Discord · *whoever administers the org* + +1. Create the token. A **fine-grained personal access token** is the simplest thing that works: *Settings → + Developer settings → Fine-grained tokens*, resource owner **Cratis**, select the repositories issues may + be filed in, permission **Issues: Read and write**, nothing else. A GitHub App is the better long-term + identity — see the note below — but the token gets you running today and swapping is a config change. +2. Set it: `export GITHUB_TOKEN=...` then `./scripts/set-secrets.sh` (Stage B4), and `pulumi up`. + +**Verify:** `/issue something small and harmless` in Discord → a preview appears → **Create issue** → the link +works, and the issue carries the `from-discord` label. + +> **Token or App?** The token is one secret and no setup, but it acts as *you*: issues will show your name as +> the author, it expires on the schedule you pick, and its rate limit is your personal one. A GitHub App gets +> Prompter its own identity ("Prompter filed this"), no expiry, and per-repository installation — worth doing +> once the volume justifies the hour it costs. The code sends either as a bearer token, so switching is +> changing one secret. + +### C3 · Let Prompter answer new issues · *whoever administers the org* + +1. `export GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32)`, run `./scripts/set-secrets.sh`. +2. Set `answeringRepositories` in the stack config to the repositories that opt in, and optionally + `issueNotifyChannelId` for the enriched announcement (the one that says whether the docs already answer + it). `pulumi up`. +3. On each opted-in repository: **Settings → Webhooks → Add webhook** → + `https:///github/webhook`, content type `application/json`, the same secret, **Issues** event. + +**Verify:** open a throwaway issue asking something the docs cover → Prompter comments with citations. Open +one asking about something they do not → **it stays silent**, and the maintainer channel says so. That +asymmetry is the design, not a bug. + ## Week one — what to watch - `kubectl -n prompter-production logs deploy/prompter -f` on the first real day. Answers should take 5–15 s. @@ -247,8 +299,8 @@ visibly. ## Then what -Once it is answering reliably, the next features are the tracker bridge decided in -[D-16](DECISIONS.md#d-16--prompter-bridges-discord-and-the-trackers--2026-08-06): filing issues from Discord -(**P-45**), answering newly-opened GitHub issues (**P-44**), and notifying Discord when an issue appears -(**P-46** — whose zero-code version, a Discord webhook URL with `/github` appended registered on the repos, -can be switched on any time and does not depend on any of this). +The tracker bridge ([D-16](DECISIONS.md#d-16--prompter-bridges-discord-and-the-trackers--2026-08-06)) is +built and shipped — Stage C above switches it on. What is left of that idea is **P-47**: handing genuinely +mechanical issues to a coding agent. That one needs its own decision record before any repository opts in, +because it puts machine-written pull requests in front of maintainers; the guardrails it must carry are in +the backlog entry. diff --git a/Planning/SESSION_HANDOVER.md b/Planning/SESSION_HANDOVER.md index 3fada71..20f818f 100644 --- a/Planning/SESSION_HANDOVER.md +++ b/Planning/SESSION_HANDOVER.md @@ -3,6 +3,44 @@ Resume state for anyone (human or agent) continuing work in a fresh session. Newest entry first — append, don't rewrite history. +## 2026-08-06 (evening) — The tracker bridge is built: P-44, P-45 and P-46 ship + +**State:** Release build **0 warnings**, **389 specs green** (up from 278 — 111 new). Everything D-16 +described now exists in code, off by default until its credentials are set, so an existing deployment is +unaffected. + +**What shipped.** + +- **P-45 · `/issue`** — describe something in Discord, Prompter drafts the issue (title, body, kind, product) + with the model, routes it to the owning product repository, checks for likely duplicates, and shows an + **ephemeral** preview with Create/Cancel. Nothing reaches GitHub until the button is pressed. Drafts live in + memory for 15 minutes and are *taken* on click, so a double-click cannot file twice and an abandoned draft + leaves no trace — filing stays consent-in-the-moment and D-13 is untouched. Issues carry a `from-discord` + label, a kind label, and a link back to the thread; no Discord username is written into a public tracker. +- **P-44 · `POST /github/webhook`** — verifies GitHub's `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 a `no-prompter` label, and answers only for allowlisted repositories. + **Silence on refusal** is the rule: an ungrounded answer is never posted. +- **P-46 · maintainer announcement** — the enriched half only: the message says whether the docs already + answer the issue, which is what turns a notification into triage. The plain notification is still better + served by GitHub's own Discord webhook, and the playbook says so. +- Stack, ingress and secrets updated; `Documentation/guides/reporting-issues.md` written for the community; + `/issue` added to the Discord behavior contract; playbook gained **Stage C** to switch it all on. + +**A spec caught a real bug:** draft eviction ordered by timestamp, which ties when several drafts are held in +the same clock tick, so "drop the oldest" dropped an arbitrary one. Now ordered by insertion sequence. + +**Not built, deliberately.** **P-47** (handing mechanical issues to a coding agent) — Prompter files issues; +GitHub's own agents act on them, behind a human-applied label, draft PRs only, opt-in repos. It needs its own +decision record before any repository opts in. Also outstanding: the message context-menu entry point ("File +as issue" on an existing message) needs NetCord's message-command context, which was not compile-verifiable +against beta.12 in this pass; the slash command covers the same ground meanwhile. + +**On PAT vs GitHub App:** the token is one secret and no setup, so the playbook starts there — but it acts as +the person who created it (issues show *their* name), expires, and shares that person's rate limit. An App +gives Prompter its own identity and no expiry for about an hour of setup. The code sends either as a bearer +token, so switching is changing one secret. + ## 2026-08-06 (later still) — Go-live playbook written; the tracker-bridge scope corrected (D-16) **A misread worth recording:** the "file issues from Discord" idea was scoped here as a *docs-gap* feature diff --git a/README.md b/README.md index 19e07b1..445af01 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ you at a human. That honesty is the feature. - **Help forum** — open a new thread in the help forum and Prompter takes the first swing automatically, so you're never waiting on the timezone gods for a first answer. - **👍 / 👎 buttons** — one click under any answer; the verdict is logged so the docs (and the bot) get better. +- **`/issue`** — turn what you are describing into a GitHub issue on the right Cratis repository: Prompter + drafts it, shows it to you privately, and files it only when you say so. Bugs, missing APIs, feature + requests, ideas, docs gaps — anything worth tracking. > Prompter never barges into normal conversation — it only speaks when spoken to, and it rate-limits each > person to a handful of questions per window so no one can spam it. See @@ -156,6 +159,12 @@ Configuration binds to the `Cratis:Prompter` section (environment variables use | Voyage API key | `Cratis__Prompter__Voyage__ApiKey` | — | | Interaction retention (days) | `Cratis__Prompter__RetentionDays` | `90` | | Re-index webhook secret | `Cratis__Prompter__ReindexSecret` | — | +| GitHub token (file issues) | `Cratis__Prompter__GitHub__Token` | — | +| GitHub webhook secret (answer issues) | `Cratis__Prompter__GitHub__WebhookSecret` | — | +| Repositories whose issues may be answered | `Cratis__Prompter__GitHub__AnsweringRepositories__0` | — (none) | +| Channel new issues are announced in | `Cratis__Prompter__GitHub__NotifyChannelId` | — | +| Label put on issues filed from Discord | `Cratis__Prompter__GitHub__IssueLabel` | `from-discord` | +| Label that opts an issue out of answers | `Cratis__Prompter__GitHub__OptOutLabel` | `no-prompter` | API keys are never committed — use environment variables or a git-ignored `Source/appsettings.Development.json`. diff --git a/Source/Discord/FileIssue.cs b/Source/Discord/FileIssue.cs new file mode 100644 index 0000000..a119c2f --- /dev/null +++ b/Source/Discord/FileIssue.cs @@ -0,0 +1,141 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using Cratis.Prompter.GitHub; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NetCord; +using NetCord.Rest; +using NetCord.Services.ApplicationCommands; + +namespace Cratis.Prompter.Discord; + +/// +/// The /issue slash command: turns what someone is describing in Discord into a GitHub issue. +/// +/// Drafts the issue from the description. +/// Looks for likely duplicates before the preview is shown. +/// Holds the draft between the preview and the confirmation. +/// The per-user throttle, shared with question answering. +/// The clock the throttle's refills are measured against. +/// The Prompter options carrying the GitHub configuration and reply text. +/// Logger for diagnostics. +/// +/// Filing is always a deliberate act (decision D-16): Prompter drafts, the reporter confirms in an ephemeral +/// preview, and only then does an issue exist. Anyone may file — at this community's size an approval step +/// would cost more in missed reports than it saves in noise — with the duplicate hint, the shared rate limit +/// and the provenance label carrying that load instead. +/// +public class FileIssue( + IIssueDrafting drafting, + IIssues issues, + PendingIssues pending, + RateLimiter rateLimiter, + TimeProvider timeProvider, + IOptions options, + ILogger logger) : ApplicationCommandModule +{ + /// + /// The reply when issue filing has no credential configured. + /// + public const string NotConfiguredReply = + "I can't file issues yet — no GitHub credential is configured for me. A maintainer can set one up."; + + /// + /// The reply when the model could not turn the description into an issue. + /// + public const string CouldNotDraftReply = + "I couldn't turn that into an issue. Try describing what you expected and what happened instead."; + + /// + /// Drafts a GitHub issue from a description and shows it for confirmation. + /// + /// What is wrong, missing, or being asked for. + /// A representing the asynchronous operation. + [SlashCommand("issue", "Turn this into a GitHub issue on the right Cratis repository")] + public async Task Handle(string description) + { + var github = options.Value.GitHub; + + if (!github.FilingEnabled) + { + await RespondEphemerally(NotConfiguredReply); + return; + } + + // The rate-limit key is the user id, used only in memory - never stored, never logged. + var userKey = Context.User.Id.ToString(CultureInfo.InvariantCulture); + if (!rateLimiter.TryConsume(userKey, timeProvider.GetUtcNow())) + { + logger.IssueRateLimited(); + await RespondEphemerally(options.Value.Discord.RateLimitedReply); + return; + } + + // Drafting calls the model, which takes seconds - well past the 3-second acknowledgement window. + // The deferral is ephemeral so the whole exchange stays private until an issue actually exists. + await Context.Interaction.SendResponseAsync( + InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + + try + { + var draft = await drafting.Draft(description, hint: null); + if (draft is null) + { + logger.CouldNotDraftIssue(); + await Context.Interaction.SendFollowupMessageAsync(Ephemeral(CouldNotDraftReply)); + return; + } + + var repository = IssueRouting.RepositoryFor(draft.Product, github); + var routed = IssueRouting.IsRouted(draft.Product, github); + var similar = await issues.FindSimilar(repository, draft.Title); + var token = pending.Hold(draft, repository, ConversationUrl()); + + await Context.Interaction.SendFollowupMessageAsync(new() + { + Content = IssuePreview.Text(draft, repository, routed, similar), + Flags = MessageFlags.Ephemeral, + Components = [IssuePreview.Buttons(token)] + }); + } + catch (Exception exception) + { + logger.IssueDraftingFailed(exception); + await TryApologize(); + } + } + + static InteractionMessageProperties Ephemeral(string content) => + new() { Content = content, Flags = MessageFlags.Ephemeral }; + + string? ConversationUrl() + { + if (Context.Interaction.Channel is null) + { + return null; + } + + var guild = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture) ?? "@me"; + var channel = Context.Interaction.Channel.Id.ToString(CultureInfo.InvariantCulture); + + return $"https://discord.com/channels/{guild}/{channel}"; + } + + async Task RespondEphemerally(string content) => + await Context.Interaction.SendResponseAsync(InteractionCallback.Message(Ephemeral(content))); + + async Task TryApologize() + { + try + { + await Context.Interaction.SendFollowupMessageAsync(Ephemeral(options.Value.Discord.ErrorReply)); + } + catch (Exception exception) + { + // Nothing more can be delivered to this interaction; record it and let the command return. + logger.IssueApologyFailed(exception); + } + } +} diff --git a/Source/Discord/FileIssueLogging.cs b/Source/Discord/FileIssueLogging.cs new file mode 100644 index 0000000..180f8fc --- /dev/null +++ b/Source/Discord/FileIssueLogging.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Logging; + +namespace Cratis.Prompter.Discord; + +internal static partial class FileIssueLogging +{ + [LoggerMessage(LogLevel.Information, "Issue filing refused: the user is over their rate limit")] + internal static partial void IssueRateLimited(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "The model's reply could not be read as an issue draft")] + internal static partial void CouldNotDraftIssue(this ILogger logger); + + [LoggerMessage(LogLevel.Error, "Drafting an issue failed")] + internal static partial void IssueDraftingFailed(this ILogger logger, Exception exception); + + [LoggerMessage(LogLevel.Warning, "Could not deliver the issue-drafting apology")] + internal static partial void IssueApologyFailed(this ILogger logger, Exception exception); +} diff --git a/Source/Discord/IssueButton.cs b/Source/Discord/IssueButton.cs new file mode 100644 index 0000000..dd6289e --- /dev/null +++ b/Source/Discord/IssueButton.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.Discord; + +/// +/// Encodes and decodes the custom id carried by the buttons under an issue preview. The custom id is +/// issue:<action>:<token> — the routes the click to the confirmation +/// handler, and the token resolves back to the held draft, which is what keeps the issue body out of a custom +/// id that Discord caps at 100 characters. +/// +public static class IssueButton +{ + /// + /// The custom-id prefix that routes a button click to the issue confirmation handler. + /// + public const string Prefix = "issue"; + + /// + /// The action that files the drafted issue. + /// + public const string FileAction = "file"; + + /// + /// The action that discards the drafted issue. + /// + public const string CancelAction = "cancel"; + + const char Separator = ':'; + + /// + /// Builds the custom id for a preview button. + /// + /// The action the button performs. + /// The token identifying the held draft. + /// The custom id. + public static string CustomId(string action, string token) => $"{Prefix}{Separator}{action}{Separator}{token}"; + + /// + /// Decodes a preview button's custom id. + /// + /// The custom id from the clicked button. + /// + /// The decoded click, or when the custom id is not a well-formed issue custom id + /// (wrong prefix, wrong shape, or an unknown action). + /// + public static IssueButtonClick? Parse(string customId) + { + var parts = customId.Split(Separator); + if (parts.Length != 3 || parts[0] != Prefix) + { + return null; + } + + var action = parts[1]; + if (action != FileAction && action != CancelAction) + { + return null; + } + + return parts[2].Length == 0 ? null : new IssueButtonClick(action, parts[2]); + } +} diff --git a/Source/Discord/IssueButtonClick.cs b/Source/Discord/IssueButtonClick.cs new file mode 100644 index 0000000..b5c6cac --- /dev/null +++ b/Source/Discord/IssueButtonClick.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.Discord; + +/// +/// A decoded click on one of the buttons under an issue preview. +/// +/// The action clicked, either or . +/// The token identifying the held draft. +public record IssueButtonClick(string Action, string Token) +{ + /// + /// Gets a value indicating whether the click files the issue. + /// + public bool Files => Action == IssueButton.FileAction; +} diff --git a/Source/Discord/IssueConfirmation.cs b/Source/Discord/IssueConfirmation.cs new file mode 100644 index 0000000..a040c51 --- /dev/null +++ b/Source/Discord/IssueConfirmation.cs @@ -0,0 +1,112 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NetCord; +using NetCord.Rest; +using NetCord.Services.ComponentInteractions; + +namespace Cratis.Prompter.Discord; + +/// +/// Handles the buttons under an issue preview: files the held draft, or discards it. +/// +/// Files the issue. +/// Holds the drafts awaiting confirmation. +/// The Prompter options carrying the GitHub configuration. +/// Logger for diagnostics. +public class IssueConfirmation( + IIssues issues, + PendingIssues pending, + IOptions options, + ILogger logger) : ComponentInteractionModule +{ + /// + /// The reply when the draft is no longer available to file. + /// + public const string ExpiredReply = + "That draft has expired — run `/issue` again and I'll draft a fresh one."; + + /// + /// The reply when the reporter discards the draft. + /// + public const string CancelledReply = "Nothing filed."; + + /// + /// The reply when filing failed at GitHub. + /// + public const string FailedReply = + "I couldn't file that on GitHub. The draft is gone, but nothing was created — try again in a moment."; + + /// + /// Files or discards a drafted issue. Any custom id whose first segment is + /// routes here. + /// + /// A representing the asynchronous operation. + [ComponentInteraction(IssueButton.Prefix)] + public async Task Confirm() + { + var customId = Context.Interaction.Data.CustomId; + var click = IssueButton.Parse(customId); + if (click is null) + { + logger.UnrecognizedIssueCustomId(customId); + await Acknowledge(ExpiredReply); + return; + } + + // Taking removes the draft, so a double-click cannot file the same issue twice. + var held = pending.Take(click.Token); + + if (!click.Files) + { + await Acknowledge(CancelledReply); + return; + } + + if (held is null) + { + await Acknowledge(ExpiredReply); + return; + } + + // Filing calls GitHub, which is well within the interaction window but not guaranteed to be, so the + // response is deferred first. The reply carries the issue URL, which is the only thing the reporter + // needs from this exchange. + await Context.Interaction.SendResponseAsync( + InteractionCallback.DeferredMessage(MessageFlags.Ephemeral)); + + try + { + var filed = await issues.File( + held.Repository, + held.Draft.Title, + IssueComposition.Body(held.Draft, held.ConversationUrl), + IssueComposition.Labels(held.Draft, options.Value.GitHub)); + + logger.FiledIssueFromDiscord(filed.Repository, filed.Number); + + await Context.Interaction.SendFollowupMessageAsync(new() + { + Content = $"Filed: {filed.Url}", + Flags = MessageFlags.Ephemeral + }); + } + catch (Exception exception) + { + logger.IssueFilingFailed(exception, held.Repository); + + await Context.Interaction.SendFollowupMessageAsync(new() + { + Content = FailedReply, + Flags = MessageFlags.Ephemeral + }); + } + } + + async Task Acknowledge(string content) => + await Context.Interaction.SendResponseAsync( + InteractionCallback.Message(new() { Content = content, Flags = MessageFlags.Ephemeral })); +} diff --git a/Source/Discord/IssueConfirmationLogging.cs b/Source/Discord/IssueConfirmationLogging.cs new file mode 100644 index 0000000..bec481c --- /dev/null +++ b/Source/Discord/IssueConfirmationLogging.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Logging; + +namespace Cratis.Prompter.Discord; + +internal static partial class IssueConfirmationLogging +{ + [LoggerMessage(LogLevel.Warning, "Unrecognized issue button custom id: {CustomId}")] + internal static partial void UnrecognizedIssueCustomId(this ILogger logger, string customId); + + [LoggerMessage(LogLevel.Information, "Filed issue #{Number} in {Repository} from Discord")] + internal static partial void FiledIssueFromDiscord(this ILogger logger, string repository, int number); + + [LoggerMessage(LogLevel.Error, "Filing an issue in {Repository} failed")] + internal static partial void IssueFilingFailed(this ILogger logger, Exception exception, string repository); +} diff --git a/Source/Discord/IssuePreview.cs b/Source/Discord/IssuePreview.cs new file mode 100644 index 0000000..39de289 --- /dev/null +++ b/Source/Discord/IssuePreview.cs @@ -0,0 +1,92 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Text; +using Cratis.Prompter.GitHub; +using NetCord; +using NetCord.Rest; + +namespace Cratis.Prompter.Discord; + +/// +/// Renders the preview a reporter confirms before anything is filed, and the buttons under it. +/// +/// +/// The preview is the consent step (decision D-16), so it has to show what will actually be public: the +/// title, the repository, and enough of the body to notice if the model got it wrong. It is ephemeral — +/// only the reporter sees it — so a draft they abandon leaves nothing behind in the channel either. +/// +public static class IssuePreview +{ + /// + /// The label on the button that files the issue. + /// + public const string FileLabel = "Create issue"; + + /// + /// The label on the button that discards the draft. + /// + public const string CancelLabel = "Cancel"; + + /// + /// The most body characters shown in the preview, keeping the whole message inside Discord's limit. + /// + public const int BodyPreviewLength = 1200; + + /// + /// Builds the preview text. + /// + /// The drafted issue. + /// The repository it would be filed in. + /// Whether the repository came from the draft's product or from the fallback. + /// Open issues that look like duplicates, best match first. + /// The message content to show ephemerally. + public static string Text(IssueDraft draft, string repository, bool routed, IReadOnlyList similar) + { + var preview = new StringBuilder() + .Append(CultureInfo.InvariantCulture, $"**{draft.Title}**\n") + .Append(CultureInfo.InvariantCulture, $"`{repository}` · {draft.Kind.ToString().ToLowerInvariant()}"); + + if (!routed) + { + // Say so rather than quietly filing in the fallback: the reporter is the only person who can + // correct a product the conversation never named. + preview.Append(" · *couldn't tell which product — check this*"); + } + + preview.Append("\n\n").Append(Truncate(draft.Body, BodyPreviewLength)); + + if (similar.Count > 0) + { + preview.Append("\n\n**Already open, possibly the same thing**\n"); + + foreach (var issue in similar) + { + preview.Append(CultureInfo.InvariantCulture, $"- [#{issue.Number} {issue.Title}]({issue.Url})\n"); + } + } + + preview.Append("\n*Only you can see this. Nothing is filed until you say so.*"); + + return preview.ToString(); + } + + /// + /// Builds the action row carrying the confirm and cancel buttons. + /// + /// The token identifying the held draft. + /// The action row to attach to the preview. + public static ActionRowProperties Buttons(string token) => new( + [ + new ButtonProperties(IssueButton.CustomId(IssueButton.FileAction, token), FileLabel, ButtonStyle.Success), + new ButtonProperties(IssueButton.CustomId(IssueButton.CancelAction, token), CancelLabel, ButtonStyle.Secondary) + ]); + + static string Truncate(string body, int limit) + { + var text = body.Trim(); + + return text.Length <= limit ? text : text[..limit].TrimEnd() + "…"; + } +} diff --git a/Source/GitHub/ExistingIssue.cs b/Source/GitHub/ExistingIssue.cs new file mode 100644 index 0000000..28e2d59 --- /dev/null +++ b/Source/GitHub/ExistingIssue.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// An open issue that already exists, used to offer "this looks like #123" before a duplicate is opened. +/// +/// The issue number. +/// The issue title. +/// The issue's web address. +public record ExistingIssue(int Number, string Title, string Url); diff --git a/Source/GitHub/FiledIssue.cs b/Source/GitHub/FiledIssue.cs new file mode 100644 index 0000000..496cfc6 --- /dev/null +++ b/Source/GitHub/FiledIssue.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// An issue that now exists on GitHub. +/// +/// The issue number within its repository. +/// The repository it was filed in, without the owner. +/// The issue's web address, which is what a reporter is shown. +public record FiledIssue(int Number, string Repository, string Url); diff --git a/Source/GitHub/IIssueDrafting.cs b/Source/GitHub/IIssueDrafting.cs new file mode 100644 index 0000000..5e71553 --- /dev/null +++ b/Source/GitHub/IIssueDrafting.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// Defines drafting an issue from a conversation. +/// +public interface IIssueDrafting +{ + /// + /// Drafts an issue from what someone wrote. + /// + /// The conversation text to draft from. + /// An optional steer from the reporter. + /// Token to cancel the operation. + /// The draft, or when the model's reply could not be read as one. + Task Draft(string conversation, string? hint, CancellationToken cancellationToken = default); +} diff --git a/Source/GitHub/IIssues.cs b/Source/GitHub/IIssues.cs new file mode 100644 index 0000000..02dd8ab --- /dev/null +++ b/Source/GitHub/IIssues.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// Defines the GitHub issue operations Prompter needs. Narrow on purpose — filing, commenting and looking +/// for likely duplicates is the entire surface, and the credential behind it is scoped to match. +/// +public interface IIssues +{ + /// + /// Files an issue. + /// + /// The repository name, without the owner. + /// The issue title. + /// The issue body. + /// The labels to apply. + /// Token to cancel the operation. + /// The filed issue. + Task File( + string repository, + string title, + string body, + IReadOnlyList labels, + CancellationToken cancellationToken = default); + + /// + /// Adds a comment to an existing issue. + /// + /// The repository name, without the owner. + /// The issue number. + /// The comment body. + /// Token to cancel the operation. + /// A representing the asynchronous operation. + Task Comment(string repository, int number, string body, CancellationToken cancellationToken = default); + + /// + /// Searches open issues for likely duplicates of a title. + /// + /// The repository name, without the owner. + /// The title to look for. + /// Token to cancel the operation. + /// + /// The most relevant open issues, best match first, or an empty list when the search is unavailable — + /// a duplicate check that fails must never stop someone reporting a problem. + /// + Task> FindSimilar( + string repository, + string title, + CancellationToken cancellationToken = default); +} diff --git a/Source/GitHub/IssueAnswerComment.cs b/Source/GitHub/IssueAnswerComment.cs new file mode 100644 index 0000000..02c33ff --- /dev/null +++ b/Source/GitHub/IssueAnswerComment.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Answering; + +namespace Cratis.Prompter.GitHub; + +/// +/// Composes the comment Prompter leaves on an issue it can answer. +/// +/// +/// Two things every comment says. Where the answer came from, as links a reader can check — an unsourced +/// answer on a tracker is worth less than no answer. And that a bot wrote it, plainly, along with how to stop +/// it: a maintainer who disagrees should not have to work out who to tell. +/// +public static class IssueAnswerComment +{ + /// + /// Builds the comment body. + /// + /// The grounded answer. + /// The label that stops Prompter answering an issue. + /// The comment body. + public static string For(Answer answer, string optOutLabel) + { + var citations = answer.Citations.ToArray(); + var sources = citations.Length > 0 + ? "\n\n**Sources**\n" + string.Join('\n', citations.Select(citation => $"- {citation}")) + : string.Empty; + + const string attribution = + "Answered by [Prompter](https://github.com/Cratis/Prompter) from the published documentation — " + + "it can be wrong, and a maintainer's word beats mine. Label this issue `"; + + return $"{answer.Text.Trim()}{sources}\n\n{attribution}{optOutLabel}` to stop me commenting on it."; + } +} diff --git a/Source/GitHub/IssueComposition.cs b/Source/GitHub/IssueComposition.cs new file mode 100644 index 0000000..b4840d9 --- /dev/null +++ b/Source/GitHub/IssueComposition.cs @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// Composes what actually gets filed: the issue body and its labels. +/// +/// +/// Two things every filed issue carries. A link back to the Discord conversation, so a maintainer can ask a +/// follow-up question of the person who hit the problem — that link is the whole follow-up mechanism, which +/// is why no Discord username is written into the issue: the thread already knows who was there, and a +/// public tracker does not need to. And a line saying Prompter filed it on someone's behalf, because an +/// issue whose provenance is unclear wastes the reader's first minute. +/// +public static class IssueComposition +{ + /// + /// The heading under which the drafted description is placed. + /// + public const string ContextHeading = "### Reported from Discord"; + + /// + /// Builds the issue body. + /// + /// The drafted issue. + /// A link to the Discord message or thread it came from, if available. + /// The body to file. + public static string Body(IssueDraft draft, string? conversationUrl) + { + var body = draft.Body.Trim(); + var provenance = string.IsNullOrWhiteSpace(conversationUrl) + ? "Filed from Discord by Prompter, on behalf of a community member." + : $"Filed from Discord by Prompter, on behalf of a community member — [the conversation]({conversationUrl.Trim()})."; + + return $"{body}\n\n{ContextHeading}\n\n{provenance}"; + } + + /// + /// Builds the labels for a filed issue: the configured provenance label plus one naming the kind of work. + /// + /// The drafted issue. + /// The GitHub options carrying the provenance label. + /// The labels to apply, without duplicates or empties. + /// + /// Labels that do not exist in the target repository are created by GitHub on use, so this never fails + /// because a repository has not been prepared. + /// + public static IReadOnlyList Labels(IssueDraft draft, GitHubOptions options) => + new[] { options.IssueLabel, LabelFor(draft.Kind) } + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + /// + /// Maps a kind of work to the label naming it. + /// + /// The kind of work. + /// The label, using the names GitHub's default label set already provides where they fit. + public static string LabelFor(IssueKind kind) => kind switch + { + IssueKind.Bug => "bug", + IssueKind.Feature => "enhancement", + IssueKind.Idea => "enhancement", + IssueKind.Documentation => "documentation", + _ => "question" + }; +} diff --git a/Source/GitHub/IssueDraft.cs b/Source/GitHub/IssueDraft.cs new file mode 100644 index 0000000..4449988 --- /dev/null +++ b/Source/GitHub/IssueDraft.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// An issue Prompter has drafted from a conversation but not yet filed. It exists only between the draft and +/// the reporter confirming it — nothing persists a draft, which is what keeps filing consent-in-the-moment +/// (decision D-13 stays intact because no conversation content is ever stored). +/// +/// The issue title. +/// The issue body, as the model wrote it — the surrounding context is added when filing. +/// The kind of work the issue represents. +/// The product the issue belongs to, used to route it to a repository. +public record IssueDraft(string Title, string Body, IssueKind Kind, string Product); diff --git a/Source/GitHub/IssueDraftParsing.cs b/Source/GitHub/IssueDraftParsing.cs new file mode 100644 index 0000000..40b2371 --- /dev/null +++ b/Source/GitHub/IssueDraftParsing.cs @@ -0,0 +1,96 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace Cratis.Prompter.GitHub; + +/// +/// Reads a drafted issue back out of the model's reply. +/// +/// +/// Tolerant on purpose. The prompt asks for bare JSON, but models wrap it in a code fence often enough that +/// refusing such a reply would fail a report the person already took the trouble to make. Anything that is +/// not recoverable returns so the caller can say "I could not draft that" rather than +/// filing something malformed. +/// +public static class IssueDraftParsing +{ + /// + /// Parses a drafted issue from the model's reply. + /// + /// The raw reply text. + /// The draft, or when the reply holds no usable JSON object. + public static IssueDraft? Parse(string? reply) + { + var json = ExtractJson(reply); + if (json is null) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + var title = ReadString(root, "title"); + var body = ReadString(root, "body"); + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(body)) + { + return null; + } + + return new IssueDraft( + title.Trim(), + body.Trim(), + ParseKind(ReadString(root, "kind")), + ReadString(root, "product")?.Trim() ?? string.Empty); + } + catch (JsonException) + { + // The reply looked like JSON but was not; the caller reports that it could not be drafted. + return null; + } + } + + /// + /// Maps the model's kind value onto . + /// + /// The value from the reply. + /// + /// The matching kind. An unrecognized or missing value becomes , the kind + /// that claims least: labelling a real bug an idea is a mislabel, while labelling an idea a bug puts it + /// in a triage queue that expects a reproduction. + /// + public static IssueKind ParseKind(string? kind) => kind?.Trim().ToLowerInvariant() switch + { + "bug" => IssueKind.Bug, + "feature" => IssueKind.Feature, + "documentation" or "docs" => IssueKind.Documentation, + _ => IssueKind.Idea + }; + + static string? ExtractJson(string? reply) + { + if (string.IsNullOrWhiteSpace(reply)) + { + return null; + } + + var text = reply.Trim(); + var start = text.IndexOf('{', StringComparison.Ordinal); + var end = text.LastIndexOf('}'); + + return start >= 0 && end > start ? text[start..(end + 1)] : null; + } + + static string? ReadString(JsonElement root, string property) => + root.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/Source/GitHub/IssueDraftPrompt.cs b/Source/GitHub/IssueDraftPrompt.cs new file mode 100644 index 0000000..09b44d4 --- /dev/null +++ b/Source/GitHub/IssueDraftPrompt.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Prompter.GitHub; + +/// +/// The prompt that turns a Discord conversation into a drafted issue. +/// +/// +/// The model writes the issue; the reporter approves it. That order matters: people describe problems in +/// chat far better than they fill in issue templates, and the thing standing between a good report and the +/// tracker is usually the transcription work, not the willingness. +/// +public static class IssueDraftPrompt +{ + /// + /// The system prompt. Frozen so it can be prompt-cached the same way the answering prompt is. + /// + public const string SystemText = + """ + You turn Cratis community conversations into GitHub issues that a maintainer can act on. + + Return ONLY a JSON object, no prose and no code fence, with exactly these fields: + {"title": "...", "body": "...", "kind": "bug|feature|idea|documentation", "product": "..."} + + Rules: + - title: one line, specific, no ticket-speak. "Projection stops after a rename" beats "Bug in projections". + - body: markdown. Lead with what the person is trying to do and what happened instead. Include any + version, error text, or code they gave, verbatim. Never invent reproduction steps, versions, stack + traces, or API names that were not in the conversation - an issue that contains a fabricated detail + costs a maintainer more than no issue at all. If something important is missing, add a short + "Unknown from the conversation" list naming what a maintainer will need to ask. + - kind: "bug" when something is broken; "feature" when a concrete capability or API is missing; + "idea" when it is a direction rather than a request; "documentation" when the behavior exists but + is undocumented or unfindable. + - product: one of chronicle, arc, fundamentals, components, cli, documentation. Use an empty string + when the conversation genuinely does not say - guessing routes the issue to the wrong maintainers. + - Use American English. + """; + + /// + /// Builds the user message describing the conversation to draft from. + /// + /// What the person wrote, and any surrounding context worth including. + /// An optional steer from the reporter, such as what they think it is. + /// The user message text. + public static string UserMessage(string conversation, string? hint) + { + var message = new StringBuilder("Conversation:\n\n"); + message.Append(conversation.Trim()); + + if (!string.IsNullOrWhiteSpace(hint)) + { + message.Append("\n\nThe reporter adds: ").Append(hint.Trim()); + } + + message.Append("\n\nDraft the issue as JSON."); + + return message.ToString(); + } +} diff --git a/Source/GitHub/IssueDrafting.cs b/Source/GitHub/IssueDrafting.cs new file mode 100644 index 0000000..9352f29 --- /dev/null +++ b/Source/GitHub/IssueDrafting.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Anthropic.Models.Messages; +using Microsoft.Extensions.AI; + +namespace Cratis.Prompter.GitHub; + +/// +/// Drafts an issue from a conversation, using the same chat client that answers questions. +/// +/// The chat client the draft is generated with. +public class IssueDrafting(IChatClient chatClient) : IIssueDrafting +{ + /// + public async Task Draft(string conversation, string? hint, CancellationToken cancellationToken = default) + { + // The system prompt is frozen, so mark it cacheable exactly as answering does (D-5). + var systemMessage = new ChatMessage( + ChatRole.System, + [new TextContent(IssueDraftPrompt.SystemText).WithCacheControl(new CacheControlEphemeral())]); + + var response = await chatClient.GetResponseAsync( + [systemMessage, new(ChatRole.User, IssueDraftPrompt.UserMessage(conversation, hint))], + cancellationToken: cancellationToken); + + return IssueDraftParsing.Parse(response.Text); + } +} diff --git a/Source/GitHub/IssueEvents.cs b/Source/GitHub/IssueEvents.cs new file mode 100644 index 0000000..57306d1 --- /dev/null +++ b/Source/GitHub/IssueEvents.cs @@ -0,0 +1,100 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; + +namespace Cratis.Prompter.GitHub; + +/// +/// Reads webhook deliveries and decides what Prompter does about them. +/// +public static class IssueEvents +{ + /// + /// The delivery action this cares about. + /// + public const string OpenedAction = "opened"; + + /// + /// Parses a newly-opened issue out of a delivery payload. + /// + /// The raw delivery body. + /// + /// The opened issue, or when the payload is not a well-formed issues.opened + /// delivery. Everything else GitHub sends — edits, comments, labels, pull requests — is ignored silently, + /// which is what lets a repository point its whole webhook here rather than a narrow event selection. + /// + public static OpenedIssue? ParseOpened(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("action", out var action) || + action.GetString() != OpenedAction || + !root.TryGetProperty("issue", out var issue) || + !root.TryGetProperty("repository", out var repository)) + { + return null; + } + + // A pull request arrives on the issues event too, carrying a pull_request member. Answering one + // would put a documentation reply on a code review. + if (issue.TryGetProperty("pull_request", out _)) + { + return null; + } + + var labels = issue.TryGetProperty("labels", out var labelArray) && labelArray.ValueKind == JsonValueKind.Array + ? labelArray.EnumerateArray() + .Select(label => label.TryGetProperty("name", out var name) ? name.GetString() : null) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => name!) + .ToArray() + : []; + + var openedByBot = issue.TryGetProperty("user", out var user) && + user.TryGetProperty("type", out var userType) && + string.Equals(userType.GetString(), "Bot", StringComparison.OrdinalIgnoreCase); + + return new OpenedIssue( + repository.TryGetProperty("full_name", out var fullName) ? fullName.GetString() ?? string.Empty : string.Empty, + issue.TryGetProperty("number", out var number) ? number.GetInt32() : 0, + issue.TryGetProperty("title", out var title) ? title.GetString() ?? string.Empty : string.Empty, + issue.TryGetProperty("body", out var body) ? body.GetString() ?? string.Empty : string.Empty, + issue.TryGetProperty("html_url", out var url) ? url.GetString() ?? string.Empty : string.Empty, + openedByBot, + labels); + } + catch (JsonException) + { + // Malformed payloads are not worth a failed response: the signature already proved the sender, + // so this is GitHub sending something unexpected, which is theirs to change, not ours to reject. + return null; + } + } + + /// + /// Determines whether Prompter should answer an issue. + /// + /// The opened issue. + /// The GitHub options carrying the allowlist and the opt-out label. + /// when the issue may be answered; otherwise . + /// + /// Answering is opt-in per repository, skips bots (so two assistants cannot talk to each other), and + /// honors the opt-out label. Whether the answer is actually posted is decided later, by whether Prompter + /// can ground it: a refusal is never posted, because silence on a tracker costs nothing and a hedging + /// comment costs a maintainer's attention. + /// + public static bool ShouldAnswer(OpenedIssue issue, GitHubOptions options) => + !issue.OpenedByBot && + options.AnsweringRepositories.Any(allowed => string.Equals(allowed, issue.Repository, StringComparison.OrdinalIgnoreCase)) && + !issue.Labels.Any(label => string.Equals(label, options.OptOutLabel, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Source/GitHub/IssueKind.cs b/Source/GitHub/IssueKind.cs new file mode 100644 index 0000000..add2bba --- /dev/null +++ b/Source/GitHub/IssueKind.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// The kind of work an issue represents. Kept deliberately coarse: it decides a label and how the body is +/// framed, and a reporter should never have to think about taxonomy to report something. +/// +public enum IssueKind +{ + /// + /// Something is broken or behaves against its documented contract. + /// + Bug = 0, + + /// + /// A capability that does not exist and is being asked for concretely — including a missing API. + /// + Feature = 1, + + /// + /// A direction worth considering, not yet a concrete request. + /// + Idea = 2, + + /// + /// The behavior exists but is undocumented, wrongly documented, or impossible to find. + /// + Documentation = 3 +} diff --git a/Source/GitHub/IssueNotification.cs b/Source/GitHub/IssueNotification.cs new file mode 100644 index 0000000..400a138 --- /dev/null +++ b/Source/GitHub/IssueNotification.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; + +namespace Cratis.Prompter.GitHub; + +/// +/// Composes the message announcing a new issue in a maintainer channel. +/// +/// +/// The plain "issue opened" notification is better served by GitHub's own Discord webhook, which needs no +/// software from us and keeps working when Prompter does not. This exists for the one thing that webhook +/// cannot say: whether the documentation already answers the issue. That single line is the difference +/// between a notification and triage — it tells a maintainer whether to expect a five-minute reply or a real +/// gap in the product. +/// +public static class IssueNotification +{ + /// + /// Builds the announcement. + /// + /// The opened issue. + /// + /// Whether Prompter answered it from the documentation. when answering was not + /// attempted, because the repository has not opted in. + /// + /// The message to post. + public static string For(OpenedIssue issue, bool? answered) + { + var verdict = answered switch + { + true => "Prompter answered it from the docs.", + false => "Prompter could not answer it from the docs — this may be a real gap.", + null => string.Empty + }; + + var header = string.Create( + CultureInfo.InvariantCulture, + $"**New issue in {issue.Repository}** — [#{issue.Number} {issue.Title}]({issue.Url})"); + + return verdict.Length > 0 ? $"{header}\n{verdict}" : header; + } +} diff --git a/Source/GitHub/IssueRouting.cs b/Source/GitHub/IssueRouting.cs new file mode 100644 index 0000000..44576fe --- /dev/null +++ b/Source/GitHub/IssueRouting.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// Decides which repository an issue belongs in, from the product a draft names. +/// +/// +/// Q-7 settled on the owning product repository rather than one shared inbox, which means a wrong guess puts +/// a report in front of the wrong maintainers. Routing therefore never invents a repository: an unknown or +/// missing product falls back to the configured default, and the reporter sees the target in the preview and +/// can change it before anything is filed. +/// +public static class IssueRouting +{ + /// + /// Resolves the repository for a product. + /// + /// The product named by the draft, which may be empty or unrecognized. + /// The GitHub options carrying the routing table and the default. + /// The repository name, without the owner. + public static string RepositoryFor(string? product, GitHubOptions options) + { + if (string.IsNullOrWhiteSpace(product)) + { + return options.DefaultRepository; + } + + return options.Repositories.TryGetValue(product.Trim(), out var repository) + ? repository + : options.DefaultRepository; + } + + /// + /// Determines whether a product routes to a repository of its own, as opposed to falling back to the + /// default. The preview says so explicitly when it does not, because "we could not tell which product + /// this is" is something the reporter can fix in one click and nobody else can. + /// + /// The product named by the draft. + /// The GitHub options carrying the routing table. + /// when the product is recognized; otherwise . + public static bool IsRouted(string? product, GitHubOptions options) => + !string.IsNullOrWhiteSpace(product) && options.Repositories.ContainsKey(product.Trim()); + + /// + /// Lists the repositories an issue may be filed in, for the reporter to choose from when the routing is + /// wrong. + /// + /// The GitHub options carrying the routing table and the default. + /// The distinct repository names, ordered, with the default first. + public static IReadOnlyList Choices(GitHubOptions options) => + [ + options.DefaultRepository, + .. options.Repositories.Values + .Where(repository => !string.Equals(repository, options.DefaultRepository, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + ]; +} diff --git a/Source/GitHub/Issues.cs b/Source/GitHub/Issues.cs new file mode 100644 index 0000000..523c990 --- /dev/null +++ b/Source/GitHub/Issues.cs @@ -0,0 +1,97 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Cratis.Prompter.GitHub; + +/// +/// Files and comments on GitHub issues over the REST API. +/// +/// The client, configured with the API base address and bearer token. +/// The Prompter options carrying the owner and routing. +/// Logger for diagnostics. +public class Issues( + HttpClient httpClient, + IOptions options, + ILogger logger) : IIssues +{ + /// + public async Task File( + string repository, + string title, + string body, + IReadOnlyList labels, + CancellationToken cancellationToken = default) + { + var owner = options.Value.GitHub.Owner; + var response = await httpClient.PostAsJsonAsync( + $"repos/{owner}/{repository}/issues", + new { title, body, labels }, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + var number = root.GetProperty("number").GetInt32(); + var url = root.GetProperty("html_url").GetString() ?? string.Empty; + + logger.FiledIssue(repository, number); + + return new FiledIssue(number, repository, url); + } + + /// + public async Task Comment(string repository, int number, string body, CancellationToken cancellationToken = default) + { + var owner = options.Value.GitHub.Owner; + var response = await httpClient.PostAsJsonAsync( + $"repos/{owner}/{repository}/issues/{number.ToString(CultureInfo.InvariantCulture)}/comments", + new { body }, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + logger.CommentedOnIssue(repository, number); + } + + /// + public async Task> FindSimilar( + string repository, + string title, + CancellationToken cancellationToken = default) + { + try + { + var owner = options.Value.GitHub.Owner; + var query = Uri.EscapeDataString($"repo:{owner}/{repository} is:issue is:open {title}"); + using var document = await httpClient.GetFromJsonAsync( + $"search/issues?q={query}&per_page=3", + cancellationToken); + + if (document is null || !document.RootElement.TryGetProperty("items", out var items)) + { + return []; + } + + return [.. items.EnumerateArray().Select(item => new ExistingIssue( + item.GetProperty("number").GetInt32(), + item.GetProperty("title").GetString() ?? string.Empty, + item.GetProperty("html_url").GetString() ?? string.Empty))]; + } + catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException) + { + // A duplicate check is a courtesy, not a gate. Search is the most rate-limited part of the API, + // so a failure here must never stop someone reporting a problem — the report goes ahead without + // the "this looks like #123" hint. + logger.DuplicateSearchFailed(exception, repository); + + return []; + } + } +} diff --git a/Source/GitHub/IssuesLogging.cs b/Source/GitHub/IssuesLogging.cs new file mode 100644 index 0000000..a692a84 --- /dev/null +++ b/Source/GitHub/IssuesLogging.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Logging; + +namespace Cratis.Prompter.GitHub; + +internal static partial class IssuesLogging +{ + [LoggerMessage(LogLevel.Information, "Filed issue #{Number} in {Repository}")] + internal static partial void FiledIssue(this ILogger logger, string repository, int number); + + [LoggerMessage(LogLevel.Information, "Commented on issue #{Number} in {Repository}")] + internal static partial void CommentedOnIssue(this ILogger logger, string repository, int number); + + [LoggerMessage(LogLevel.Debug, "Could not search {Repository} for similar issues; filing without a duplicate hint")] + internal static partial void DuplicateSearchFailed(this ILogger logger, Exception exception, string repository); +} diff --git a/Source/GitHub/OpenedIssue.cs b/Source/GitHub/OpenedIssue.cs new file mode 100644 index 0000000..9d694fc --- /dev/null +++ b/Source/GitHub/OpenedIssue.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// A newly-opened issue, as read from a webhook delivery. +/// +/// The full name of the repository, as owner/name. +/// The issue number. +/// The issue title. +/// The issue body, which may be empty. +/// The issue's web address. +/// Whether the issue was opened by a bot account. +/// The labels the issue carries at the moment it was opened. +public record OpenedIssue( + string Repository, + int Number, + string Title, + string Body, + string Url, + bool OpenedByBot, + IReadOnlyList Labels) +{ + /// + /// Gets the repository name without its owner. + /// + public string RepositoryName => Repository.Contains('/', StringComparison.Ordinal) + ? Repository[(Repository.IndexOf('/', StringComparison.Ordinal) + 1)..] + : Repository; + + /// + /// Gets the issue as a single question to answer: the title carries the intent, the body the detail. + /// + public string AsQuestion => string.IsNullOrWhiteSpace(Body) ? Title : $"{Title}\n\n{Body}"; +} diff --git a/Source/GitHub/PendingIssue.cs b/Source/GitHub/PendingIssue.cs new file mode 100644 index 0000000..c5a425b --- /dev/null +++ b/Source/GitHub/PendingIssue.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.GitHub; + +/// +/// A drafted issue waiting for its reporter to confirm it. +/// +/// The drafted issue. +/// The repository it will be filed in. +/// A link back to the conversation it came from, if available. +public record PendingIssue(IssueDraft Draft, string Repository, string? ConversationUrl); diff --git a/Source/GitHub/PendingIssues.cs b/Source/GitHub/PendingIssues.cs new file mode 100644 index 0000000..9393d04 --- /dev/null +++ b/Source/GitHub/PendingIssues.cs @@ -0,0 +1,102 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using System.Globalization; + +namespace Cratis.Prompter.GitHub; + +/// +/// Holds drafted issues between showing the preview and the reporter confirming it. +/// +/// +/// In memory only, and deliberately so. A Discord custom id is capped at 100 characters, far too small to +/// carry an issue body, so the preview's buttons carry a short token that resolves back to the draft here. +/// Nothing is written to the database: a draft that is never confirmed leaves no trace, which is what keeps +/// filing consent-in-the-moment under D-13. A restart loses pending drafts — the reporter is told the draft +/// expired and can run the command again, which is a far better failure than persisting conversation text. +/// +/// The clock expiry is measured against. +public class PendingIssues(TimeProvider timeProvider) +{ + /// + /// The most drafts held at once, after which the oldest are dropped. A bound rather than a limit anyone + /// should reach: it stops a burst of unconfirmed drafts growing the process indefinitely. + /// + public const int Capacity = 200; + + /// + /// How long a draft stays available for confirmation. + /// + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15); + + readonly ConcurrentDictionary _drafts = new(StringComparer.Ordinal); + long _next; + + /// + /// Holds a draft and returns the token identifying it. + /// + /// The drafted issue. + /// The repository the preview offered to file it in. + /// A link back to the conversation, if available. + /// The token to put on the preview's buttons. + public string Hold(IssueDraft draft, string repository, string? conversationUrl) + { + Evict(); + + var sequence = Interlocked.Increment(ref _next); + var token = sequence.ToString(CultureInfo.InvariantCulture); + _drafts[token] = new Pending(draft, repository, conversationUrl, timeProvider.GetUtcNow(), sequence); + + return token; + } + + /// + /// Takes a held draft, removing it so a double-click cannot file the same issue twice. + /// + /// The token from the clicked button. + /// The held draft, or when it is unknown or has expired. + public PendingIssue? Take(string token) + { + if (!_drafts.TryRemove(token, out var pending)) + { + return null; + } + + return timeProvider.GetUtcNow() - pending.HeldAt > Lifetime + ? null + : new PendingIssue(pending.Draft, pending.Repository, pending.ConversationUrl); + } + + void Evict() + { + var now = timeProvider.GetUtcNow(); + + foreach (var (token, pending) in _drafts) + { + if (now - pending.HeldAt > Lifetime) + { + _drafts.TryRemove(token, out _); + } + } + + if (_drafts.Count < Capacity) + { + return; + } + + // Ordered by insertion, not by time: several drafts can be held within one clock tick, and + // "oldest" has to mean the one held first rather than whichever the timestamps happen to tie on. + foreach (var (token, _) in _drafts.OrderBy(entry => entry.Value.Sequence).Take(_drafts.Count - Capacity + 1)) + { + _drafts.TryRemove(token, out _); + } + } + + sealed record Pending( + IssueDraft Draft, + string Repository, + string? ConversationUrl, + DateTimeOffset HeldAt, + long Sequence); +} diff --git a/Source/GitHub/WebhookAuth.cs b/Source/GitHub/WebhookAuth.cs new file mode 100644 index 0000000..34b562e --- /dev/null +++ b/Source/GitHub/WebhookAuth.cs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Cratis.Prompter.GitHub; + +/// +/// Authorizes GitHub webhook deliveries by verifying the HMAC signature GitHub sends with the payload. +/// +/// +/// GitHub signs the raw request body with the configured secret and sends the result as +/// X-Hub-Signature-256: sha256=<hex>. Verifying that signature — rather than checking a shared +/// secret in a header — is what proves the delivery came from GitHub and that the body was not altered on the +/// way. An unset secret refuses everything, the same posture the re-index endpoint takes. +/// +public static class WebhookAuth +{ + /// + /// The header carrying the signature of the payload. + /// + public const string SignatureHeader = "X-Hub-Signature-256"; + + /// + /// The header naming the event a delivery carries. + /// + public const string EventHeader = "X-GitHub-Event"; + + const string Prefix = "sha256="; + + /// + /// Determines whether a delivery is authentic. + /// + /// The value of the signature header, or when absent. + /// The exact bytes of the request body, as received. + /// The configured webhook secret, or /empty when unset. + /// + /// only when a secret is configured and the signature matches the payload; + /// otherwise . + /// + public static bool IsAuthentic(string? signature, ReadOnlySpan payload, string? secret) + { + if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(signature)) + { + return false; + } + + if (!signature.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var provided = signature[Prefix.Length..]; + var expected = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), payload)) + .ToLower(CultureInfo.InvariantCulture); + + // Both sides are fixed-length hex of a SHA-256 hash, so the constant-time compare runs over + // equal-length spans and leaks neither length nor how much matched. + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(provided.ToLower(CultureInfo.InvariantCulture)), + Encoding.UTF8.GetBytes(expected)); + } +} diff --git a/Source/GitHubOptions.cs b/Source/GitHubOptions.cs new file mode 100644 index 0000000..a39bf5e --- /dev/null +++ b/Source/GitHubOptions.cs @@ -0,0 +1,96 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter; + +/// +/// Options for the GitHub integration: filing issues from Discord, answering newly-opened issues, and +/// telling a maintainer channel when one appears. +/// +/// +/// Every capability here is off until its credential is configured, so a deployment that sets nothing +/// behaves exactly as before. See decision D-16 for the scope and its guardrails. +/// +public class GitHubOptions +{ + /// + /// Gets or sets the token used to file issues and post comments. A fine-grained personal access token + /// with Issues: Read and write on the target repositories is enough; a GitHub App installation + /// token works identically because both are sent as a bearer token. + /// + public string Token { get; set; } = string.Empty; + + /// + /// Gets or sets the GitHub API base address. + /// + public string ApiUrl { get; set; } = "https://api.github.com/"; + + /// + /// Gets or sets the organization the repositories belong to. + /// + public string Owner { get; set; } = "Cratis"; + + /// + /// Gets or sets the repository an issue lands in when the product cannot be determined. Documentation is + /// the safe default: a misrouted issue there is still visible to the people who triage the site, and + /// nothing about it looks like a product bug report that never gets read. + /// + public string DefaultRepository { get; set; } = "Documentation"; + + /// + /// Gets or sets the product-to-repository routing, keyed by the product name the classifier produces + /// (case-insensitive). Q-7 settled on the owning product repository rather than one shared inbox. + /// + public IDictionary Repositories { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["chronicle"] = "Chronicle", + ["arc"] = "Arc", + ["fundamentals"] = "Fundamentals", + ["components"] = "Components", + ["cli"] = "cli", + ["documentation"] = "Documentation", + }; + + /// + /// Gets or sets the label put on every issue Prompter files, so maintainers can tell community-reported + /// work from their own and filter it. + /// + public string IssueLabel { get; set; } = "from-discord"; + + /// + /// Gets or sets the label that opts an issue out of being answered. Applying it to an issue — or having + /// a maintainer apply it by default in a repository's templates — stops Prompter commenting there. + /// + public string OptOutLabel { get; set; } = "no-prompter"; + + /// + /// Gets or sets the shared secret GitHub signs webhook deliveries with. When empty, the webhook endpoint + /// refuses every request rather than accepting unsigned ones. + /// + public string WebhookSecret { get; set; } = string.Empty; + + /// + /// Gets or sets the repositories whose newly-opened issues Prompter may answer, as owner/name. + /// Empty means answer nowhere: answering is opt-in per repository, because a comment on someone else's + /// tracker is the most visible thing this bot does. + /// + public IList AnsweringRepositories { get; set; } = []; + + /// + /// Gets or sets the channel new issues are announced in. When unset, no announcement is posted — the + /// zero-code GitHub-to-Discord webhook covers the plain notification, and this exists for the enriched + /// one that says whether the documentation already answers the issue. + /// + public ulong? NotifyChannelId { get; set; } + + /// + /// Gets a value indicating whether issues can be filed. Filing needs a token; without one the + /// /issue command tells the user it is not configured instead of failing opaquely. + /// + public bool FilingEnabled => Token.Length > 0; + + /// + /// Gets a value indicating whether the webhook endpoint accepts deliveries. + /// + public bool WebhookEnabled => WebhookSecret.Length > 0; +} diff --git a/Source/Hosting/HostBuilderExtensions.cs b/Source/Hosting/HostBuilderExtensions.cs index a762235..6aa2834 100644 --- a/Source/Hosting/HostBuilderExtensions.cs +++ b/Source/Hosting/HostBuilderExtensions.cs @@ -4,6 +4,7 @@ using Anthropic; using Cratis.Prompter.Answering; using Cratis.Prompter.Embeddings; +using Cratis.Prompter.GitHub; using Cratis.Prompter.Ingestion; using Cratis.Prompter.Retrieval; using Cratis.Prompter.Storage; @@ -86,6 +87,23 @@ public static void AddPrompter(this IHostApplicationBuilder builder) return client.AsIChatClient(options.Anthropic.Model); }); + // GitHub: filing issues from Discord and answering newly-opened ones. The client is registered + // regardless of whether a token is configured - the command checks GitHubOptions.FilingEnabled and + // says so, which is a better failure than an unresolvable dependency at startup. + builder.Services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.GitHub.ApiUrl.TrimEnd('/') + "/"); + client.DefaultRequestHeaders.Authorization = new("Bearer", options.GitHub.Token); + client.DefaultRequestHeaders.Accept.Add(new("application/vnd.github+json")); + + // GitHub rejects requests without a user agent, and names the caller in rate-limit responses. + client.DefaultRequestHeaders.UserAgent.Add(new("Cratis-Prompter", "1.0")); + }); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Source/Operations/GitHubWebhook.cs b/Source/Operations/GitHubWebhook.cs new file mode 100644 index 0000000..27492fb --- /dev/null +++ b/Source/Operations/GitHubWebhook.cs @@ -0,0 +1,104 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Answering; +using Cratis.Prompter.GitHub; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NetCord.Gateway; +using NetCord.Rest; + +namespace Cratis.Prompter.Operations; + +/// +/// Handles a verified GitHub webhook delivery: answers a newly-opened issue from the documentation when it +/// can, and tells the maintainer channel either way. +/// +/// The answers Prompter can give. +/// Posts the answer back as a comment. +/// The gateway client, used for its REST access to the maintainer channel. +/// The Prompter options carrying the GitHub configuration. +/// Logger for diagnostics. +/// +/// The rule that matters here is silence on refusal. A tracker comment is the most visible thing +/// this bot does, and a hedging "I'm not sure, but…" on someone's bug report costs a maintainer more +/// attention than it saves. So an ungrounded answer is never posted — but it is still reported to the +/// maintainer channel, because "nothing in the docs covers this" is exactly the signal worth having. +/// +public class GitHubWebhook( + IAnswers answers, + IIssues issues, + GatewayClient gateway, + IOptions options, + ILogger logger) +{ + /// + /// Processes a newly-opened issue. + /// + /// The opened issue. + /// Token to cancel the operation. + /// A representing the asynchronous operation. + public async Task Handle(OpenedIssue issue, CancellationToken cancellationToken) + { + var github = options.Value.GitHub; + bool? answered = null; + + if (IssueEvents.ShouldAnswer(issue, github)) + { + answered = await TryAnswer(issue, github, cancellationToken); + } + + await TryNotify(issue, answered, github, cancellationToken); + } + + async Task TryAnswer(OpenedIssue issue, GitHubOptions github, CancellationToken cancellationToken) + { + try + { + var answer = await answers.For(new(issue.AsQuestion), "github-issue", cancellationToken); + + if (answer.IsRefusal) + { + logger.IssueNotAnswerable(issue.Repository, issue.Number); + return false; + } + + await issues.Comment( + issue.RepositoryName, + issue.Number, + IssueAnswerComment.For(answer, github.OptOutLabel), + cancellationToken); + + return true; + } + catch (Exception exception) + { + // The delivery was already accepted; a failure answering must not surface as a failed webhook, + // which would make GitHub retry and risk duplicate comments. + logger.AnsweringIssueFailed(exception, issue.Repository, issue.Number); + + return false; + } + } + + async Task TryNotify(OpenedIssue issue, bool? answered, GitHubOptions github, CancellationToken cancellationToken) + { + if (github.NotifyChannelId is not { } channelId) + { + return; + } + + try + { + await gateway.Rest.SendMessageAsync( + channelId, + new MessageProperties { Content = IssueNotification.For(issue, answered) }, + cancellationToken: cancellationToken); + } + catch (Exception exception) + { + // A missed announcement is not worth failing a delivery over. + logger.IssueNotificationFailed(exception, issue.Repository, issue.Number); + } + } +} diff --git a/Source/Operations/GitHubWebhookLogging.cs b/Source/Operations/GitHubWebhookLogging.cs new file mode 100644 index 0000000..5a46adf --- /dev/null +++ b/Source/Operations/GitHubWebhookLogging.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Logging; + +namespace Cratis.Prompter.Operations; + +internal static partial class GitHubWebhookLogging +{ + [LoggerMessage(LogLevel.Information, "The documentation does not answer {Repository}#{Number}; staying silent")] + internal static partial void IssueNotAnswerable(this ILogger logger, string repository, int number); + + [LoggerMessage(LogLevel.Error, "Answering {Repository}#{Number} failed")] + internal static partial void AnsweringIssueFailed(this ILogger logger, Exception exception, string repository, int number); + + [LoggerMessage(LogLevel.Warning, "Could not announce {Repository}#{Number} in the maintainer channel")] + internal static partial void IssueNotificationFailed(this ILogger logger, Exception exception, string repository, int number); +} diff --git a/Source/Operations/OperationsLogging.cs b/Source/Operations/OperationsLogging.cs index 12b6688..e21c614 100644 --- a/Source/Operations/OperationsLogging.cs +++ b/Source/Operations/OperationsLogging.cs @@ -24,4 +24,16 @@ internal static partial class OperationsLogging [LoggerMessage(LogLevel.Warning, "Reindex rejected: missing or invalid secret")] internal static partial void ReindexUnauthorized(this ILogger logger); + + [LoggerMessage(LogLevel.Warning, "A GitHub webhook delivery was refused: the signature did not verify")] + internal static partial void GitHubDeliveryUnauthorized(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Issue {Repository}#{Number} was opened")] + internal static partial void GitHubIssueOpened(this ILogger logger, string repository, int number); + + [LoggerMessage(LogLevel.Information, "Handling {Repository}#{Number} was cancelled by host shutdown")] + internal static partial void GitHubHandlingCancelled(this ILogger logger, string repository, int number); + + [LoggerMessage(LogLevel.Error, "Handling {Repository}#{Number} failed")] + internal static partial void GitHubHandlingFailed(this ILogger logger, Exception exception, string repository, int number); } diff --git a/Source/Operations/PrompterEndpoints.cs b/Source/Operations/PrompterEndpoints.cs index 28abb69..e06115e 100644 --- a/Source/Operations/PrompterEndpoints.cs +++ b/Source/Operations/PrompterEndpoints.cs @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Text; +using Cratis.Prompter.GitHub; using Cratis.Prompter.Ingestion; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -26,6 +28,8 @@ public static class PrompterEndpoints const string ReindexLogCategory = "Cratis.Prompter.Operations.Reindex"; + const string GitHubLogCategory = "Cratis.Prompter.Operations.GitHub"; + /// /// Maps GET /healthz and POST /reindex onto the endpoint routing of the given application. /// @@ -34,6 +38,65 @@ public static void MapPrompterEndpoints(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/healthz", CheckHealthAsync); endpoints.MapPost("/reindex", Reindex); + endpoints.MapPost("/github/webhook", ReceiveGitHubEvent); + } + + static async Task ReceiveGitHubEvent( + HttpRequest request, + IOptions options, + GitHubWebhook webhook, + IHostApplicationLifetime lifetime, + ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory.CreateLogger(GitHubLogCategory); + + // The signature covers the exact bytes GitHub sent, so the body is read raw and verified before + // anything parses it. + await using var buffer = new MemoryStream(); + await request.Body.CopyToAsync(buffer, cancellationToken); + var payload = buffer.ToArray(); + + if (!WebhookAuth.IsAuthentic( + request.Headers[WebhookAuth.SignatureHeader].ToString(), + payload, + options.Value.GitHub.WebhookSecret)) + { + logger.GitHubDeliveryUnauthorized(); + return Results.Json(new { status = "unauthorized" }, statusCode: StatusCodes.Status401Unauthorized); + } + + var issue = IssueEvents.ParseOpened(Encoding.UTF8.GetString(payload)); + if (issue is null) + { + // Every other event GitHub sends is accepted and ignored, so a repository can point its whole + // webhook here without curating an event list. + return Results.Json(new { status = "ignored" }, statusCode: StatusCodes.Status202Accepted); + } + + logger.GitHubIssueOpened(issue.Repository, issue.Number); + + // Answering takes seconds and GitHub expects a fast response, so the work runs detached under + // ApplicationStopping — a shutdown mid-answer cancels it cleanly instead of being killed abruptly. + _ = Task.Run( + async () => + { + try + { + await webhook.Handle(issue, lifetime.ApplicationStopping); + } + catch (OperationCanceledException) + { + logger.GitHubHandlingCancelled(issue.Repository, issue.Number); + } + catch (Exception exception) + { + logger.GitHubHandlingFailed(exception, issue.Repository, issue.Number); + } + }, + CancellationToken.None); + + return Results.Json(new { status = "accepted" }, statusCode: StatusCodes.Status202Accepted); } static async Task CheckHealthAsync( diff --git a/Source/Program.cs b/Source/Program.cs index c3afeee..bc66b46 100644 --- a/Source/Program.cs +++ b/Source/Program.cs @@ -68,6 +68,7 @@ .ValidateOnStart(); webBuilder.Services.AddSingleton(); +webBuilder.Services.AddSingleton(); webBuilder.Services.AddHostedService(); webBuilder.Services .AddDiscordGateway(options => diff --git a/Source/PrompterOptions.cs b/Source/PrompterOptions.cs index 567b84b..41f58a7 100644 --- a/Source/PrompterOptions.cs +++ b/Source/PrompterOptions.cs @@ -61,4 +61,10 @@ public class PrompterOptions /// Gets or sets the answering specific options. /// public AnsweringOptions Answering { get; set; } = new(); + + /// + /// Gets or sets the GitHub specific options, covering filing issues from Discord and answering + /// newly-opened ones. Every capability is off until its credential is configured. + /// + public GitHubOptions GitHub { get; set; } = new(); } diff --git a/Specs/Fakes/ControlledTime.cs b/Specs/Fakes/ControlledTime.cs new file mode 100644 index 0000000..0471fc1 --- /dev/null +++ b/Specs/Fakes/ControlledTime.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Prompter.Specs.Fakes; + +/// +/// A clock the specs move by hand, so expiry can be observed without waiting for it. +/// +public sealed class ControlledTime(DateTimeOffset now) : TimeProvider +{ + DateTimeOffset _now = now; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now = _now.Add(by); +} diff --git a/Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_no_sources.cs b/Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_no_sources.cs new file mode 100644 index 0000000..5e208ff --- /dev/null +++ b/Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_no_sources.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Answering; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueAnswerComment.when_composing; + +public class and_the_answer_has_no_sources : Specification +{ + string _comment = null!; + + void Because() => _comment = IssueAnswerComment.For(new("An answer.", [], 0.9, false, []), "no-prompter"); + + [Fact] void should_not_show_an_empty_sources_heading() => _comment.Contains("**Sources**", StringComparison.Ordinal).ShouldBeFalse(); + [Fact] void should_still_attribute_itself() => _comment.ShouldContain("Prompter"); +} diff --git a/Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_sources.cs b/Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_sources.cs new file mode 100644 index 0000000..ab01cc9 --- /dev/null +++ b/Specs/for_IssueAnswerComment/when_composing/and_the_answer_has_sources.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Answering; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueAnswerComment.when_composing; + +public class and_the_answer_has_sources : Specification +{ + string _comment = null!; + + void Because() => _comment = IssueAnswerComment.For( + new("Append events with the event log.", [new("https://cratis.io/chronicle/events")], 0.9, false, []), + "no-prompter"); + + [Fact] void should_lead_with_the_answer() => _comment.StartsWith("Append events", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_list_the_sources() => _comment.ShouldContain("https://cratis.io/chronicle/events"); + [Fact] void should_say_a_bot_wrote_it() => _comment.ShouldContain("Prompter"); + [Fact] void should_say_how_to_stop_it() => _comment.ShouldContain("no-prompter"); +} diff --git a/Specs/for_IssueButton/when_building_a_custom_id/and_the_action_files_the_issue.cs b/Specs/for_IssueButton/when_building_a_custom_id/and_the_action_files_the_issue.cs new file mode 100644 index 0000000..afa3e68 --- /dev/null +++ b/Specs/for_IssueButton/when_building_a_custom_id/and_the_action_files_the_issue.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; + +namespace Cratis.Prompter.Specs.for_IssueButton.when_building_a_custom_id; + +public class and_the_action_files_the_issue : Specification +{ + string _customId = null!; + + void Because() => _customId = IssueButton.CustomId(IssueButton.FileAction, "17"); + + [Fact] void should_encode_prefix_action_and_token() => _customId.ShouldEqual("issue:file:17"); + [Fact] void should_stay_within_discords_custom_id_limit() => (_customId.Length <= 100).ShouldBeTrue(); +} diff --git a/Specs/for_IssueButton/when_parsing/and_the_custom_id_cancels.cs b/Specs/for_IssueButton/when_parsing/and_the_custom_id_cancels.cs new file mode 100644 index 0000000..b12ca68 --- /dev/null +++ b/Specs/for_IssueButton/when_parsing/and_the_custom_id_cancels.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; + +namespace Cratis.Prompter.Specs.for_IssueButton.when_parsing; + +public class and_the_custom_id_cancels : Specification +{ + IssueButtonClick _click = null!; + + void Because() => _click = IssueButton.Parse(IssueButton.CustomId(IssueButton.CancelAction, "17"))!; + + [Fact] void should_decode_a_click() => _click.ShouldNotBeNull(); + [Fact] void should_not_file() => _click.Files.ShouldBeFalse(); +} diff --git a/Specs/for_IssueButton/when_parsing/and_the_custom_id_files_the_issue.cs b/Specs/for_IssueButton/when_parsing/and_the_custom_id_files_the_issue.cs new file mode 100644 index 0000000..f1f2ce5 --- /dev/null +++ b/Specs/for_IssueButton/when_parsing/and_the_custom_id_files_the_issue.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; + +namespace Cratis.Prompter.Specs.for_IssueButton.when_parsing; + +public class and_the_custom_id_files_the_issue : Specification +{ + IssueButtonClick _click = null!; + + void Because() => _click = IssueButton.Parse(IssueButton.CustomId(IssueButton.FileAction, "17"))!; + + [Fact] void should_decode_a_click() => _click.ShouldNotBeNull(); + [Fact] void should_round_trip_the_token() => _click.Token.ShouldEqual("17"); + [Fact] void should_know_it_files() => _click.Files.ShouldBeTrue(); +} diff --git a/Specs/for_IssueButton/when_parsing/and_the_custom_id_is_not_ours.cs b/Specs/for_IssueButton/when_parsing/and_the_custom_id_is_not_ours.cs new file mode 100644 index 0000000..abc6fc0 --- /dev/null +++ b/Specs/for_IssueButton/when_parsing/and_the_custom_id_is_not_ours.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; + +namespace Cratis.Prompter.Specs.for_IssueButton.when_parsing; + +public class and_the_custom_id_is_not_ours : Specification +{ + [Fact] void should_reject_a_foreign_prefix() => (IssueButton.Parse("feedback:up:1") is null).ShouldBeTrue(); + [Fact] void should_reject_an_unknown_action() => (IssueButton.Parse("issue:delete:1") is null).ShouldBeTrue(); + [Fact] void should_reject_a_missing_token() => (IssueButton.Parse("issue:file:") is null).ShouldBeTrue(); + [Fact] void should_reject_the_wrong_shape() => (IssueButton.Parse("issue:file") is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueComposition/when_building_a_body/and_the_conversation_is_linkable.cs b/Specs/for_IssueComposition/when_building_a_body/and_the_conversation_is_linkable.cs new file mode 100644 index 0000000..3dffb80 --- /dev/null +++ b/Specs/for_IssueComposition/when_building_a_body/and_the_conversation_is_linkable.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueComposition.when_building_a_body; + +public class and_the_conversation_is_linkable : Specification +{ + const string Url = "https://discord.com/channels/1/2"; + string _body = null!; + + void Because() => _body = IssueComposition.Body(new("Title", "The description.", IssueKind.Bug, "chronicle"), Url); + + [Fact] void should_keep_the_drafted_body() => _body.StartsWith("The description.", StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_say_where_it_came_from() => _body.ShouldContain(IssueComposition.ContextHeading); + [Fact] void should_link_back_to_the_conversation() => _body.ShouldContain(Url); + [Fact] void should_name_prompter_as_the_filer() => _body.ShouldContain("Prompter"); +} diff --git a/Specs/for_IssueComposition/when_building_a_body/and_there_is_no_conversation_link.cs b/Specs/for_IssueComposition/when_building_a_body/and_there_is_no_conversation_link.cs new file mode 100644 index 0000000..44482af --- /dev/null +++ b/Specs/for_IssueComposition/when_building_a_body/and_there_is_no_conversation_link.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueComposition.when_building_a_body; + +public class and_there_is_no_conversation_link : Specification +{ + string _body = null!; + + void Because() => _body = IssueComposition.Body(new("Title", "The description.", IssueKind.Bug, "chronicle"), null); + + [Fact] void should_still_say_where_it_came_from() => _body.ShouldContain(IssueComposition.ContextHeading); + [Fact] void should_not_leave_an_empty_link() => _body.Contains("[the conversation]", StringComparison.Ordinal).ShouldBeFalse(); +} diff --git a/Specs/for_IssueComposition/when_building_labels/and_the_issue_is_a_bug.cs b/Specs/for_IssueComposition/when_building_labels/and_the_issue_is_a_bug.cs new file mode 100644 index 0000000..c5557b7 --- /dev/null +++ b/Specs/for_IssueComposition/when_building_labels/and_the_issue_is_a_bug.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueComposition.when_building_labels; + +public class and_the_issue_is_a_bug : Specification +{ + readonly GitHubOptions _options = new(); + IReadOnlyList _labels = null!; + + void Because() => _labels = IssueComposition.Labels(new("Title", "Body", IssueKind.Bug, "chronicle"), _options); + + [Fact] void should_carry_the_provenance_label() => _labels.ShouldContain(_options.IssueLabel); + [Fact] void should_carry_the_kind_label() => _labels.ShouldContain("bug"); +} diff --git a/Specs/for_IssueComposition/when_building_labels/and_the_provenance_label_is_empty.cs b/Specs/for_IssueComposition/when_building_labels/and_the_provenance_label_is_empty.cs new file mode 100644 index 0000000..4e42b1d --- /dev/null +++ b/Specs/for_IssueComposition/when_building_labels/and_the_provenance_label_is_empty.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueComposition.when_building_labels; + +public class and_the_provenance_label_is_empty : Specification +{ + IReadOnlyList _labels = null!; + + void Because() => _labels = IssueComposition.Labels( + new("Title", "Body", IssueKind.Documentation, "documentation"), + new() { IssueLabel = string.Empty }); + + [Fact] void should_drop_the_empty_label() => _labels.Contains(string.Empty).ShouldBeFalse(); + [Fact] void should_still_carry_the_kind_label() => _labels.ShouldContain("documentation"); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing/and_the_body_is_blank.cs b/Specs/for_IssueDraftParsing/when_parsing/and_the_body_is_blank.cs new file mode 100644 index 0000000..b808233 --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing/and_the_body_is_blank.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing; + +public class and_the_body_is_blank : Specification +{ + IssueDraft? _draft; + + void Because() => _draft = IssueDraftParsing.Parse("""{"title":"A title","body":" ","kind":"bug"}"""); + + [Fact] void should_not_draft_anything() => (_draft is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_malformed.cs b/Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_malformed.cs new file mode 100644 index 0000000..b1eac69 --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_malformed.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing; + +public class and_the_json_is_malformed : Specification +{ + IssueDraft? _draft; + + void Because() => _draft = IssueDraftParsing.Parse("""{"title":"A title", "body":"""); + + [Fact] void should_not_draft_anything() => (_draft is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_wrapped_in_a_code_fence.cs b/Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_wrapped_in_a_code_fence.cs new file mode 100644 index 0000000..ba3f5d6 --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing/and_the_json_is_wrapped_in_a_code_fence.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing; + +public class and_the_json_is_wrapped_in_a_code_fence : Specification +{ + IssueDraft _draft = null!; + + void Because() => _draft = IssueDraftParsing.Parse( + """ + Here you go: + + ```json + {"title":"A title","body":"A body","kind":"feature","product":"arc"} + ``` + """)!; + + [Fact] void should_still_read_the_draft() => _draft.ShouldNotBeNull(); + [Fact] void should_read_the_title() => _draft.Title.ShouldEqual("A title"); + [Fact] void should_read_the_kind() => _draft.Kind.ShouldEqual(IssueKind.Feature); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing/and_the_reply_has_no_json.cs b/Specs/for_IssueDraftParsing/when_parsing/and_the_reply_has_no_json.cs new file mode 100644 index 0000000..de9daae --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing/and_the_reply_has_no_json.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing; + +public class and_the_reply_has_no_json : Specification +{ + IssueDraft? _draft; + + void Because() => _draft = IssueDraftParsing.Parse("I'm not sure what you mean."); + + [Fact] void should_not_draft_anything() => (_draft is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing/and_the_reply_is_bare_json.cs b/Specs/for_IssueDraftParsing/when_parsing/and_the_reply_is_bare_json.cs new file mode 100644 index 0000000..aacb7ca --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing/and_the_reply_is_bare_json.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing; + +public class and_the_reply_is_bare_json : Specification +{ + IssueDraft _draft = null!; + + void Because() => _draft = IssueDraftParsing.Parse( + """{"title":"Projection stops after a rename","body":"Steps.","kind":"bug","product":"chronicle"}""")!; + + [Fact] void should_read_the_title() => _draft.Title.ShouldEqual("Projection stops after a rename"); + [Fact] void should_read_the_body() => _draft.Body.ShouldEqual("Steps."); + [Fact] void should_read_the_kind() => _draft.Kind.ShouldEqual(IssueKind.Bug); + [Fact] void should_read_the_product() => _draft.Product.ShouldEqual("chronicle"); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing/and_the_title_is_missing.cs b/Specs/for_IssueDraftParsing/when_parsing/and_the_title_is_missing.cs new file mode 100644 index 0000000..142f501 --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing/and_the_title_is_missing.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing; + +public class and_the_title_is_missing : Specification +{ + IssueDraft? _draft; + + void Because() => _draft = IssueDraftParsing.Parse("""{"body":"A body","kind":"bug"}"""); + + [Fact] void should_not_draft_anything() => (_draft is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_one_of_the_known_kinds.cs b/Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_one_of_the_known_kinds.cs new file mode 100644 index 0000000..df2517b --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_one_of_the_known_kinds.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing_the_kind; + +public class and_it_is_one_of_the_known_kinds : Specification +{ + [Fact] void should_read_a_bug() => IssueDraftParsing.ParseKind("bug").ShouldEqual(IssueKind.Bug); + [Fact] void should_read_a_feature() => IssueDraftParsing.ParseKind("FEATURE").ShouldEqual(IssueKind.Feature); + [Fact] void should_read_documentation() => IssueDraftParsing.ParseKind(" documentation ").ShouldEqual(IssueKind.Documentation); + [Fact] void should_read_the_docs_shorthand() => IssueDraftParsing.ParseKind("docs").ShouldEqual(IssueKind.Documentation); +} diff --git a/Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_unrecognized.cs b/Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_unrecognized.cs new file mode 100644 index 0000000..2d33e1f --- /dev/null +++ b/Specs/for_IssueDraftParsing/when_parsing_the_kind/and_it_is_unrecognized.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftParsing.when_parsing_the_kind; + +public class and_it_is_unrecognized : Specification +{ + [Fact] void should_claim_the_least_by_calling_it_an_idea() => IssueDraftParsing.ParseKind("wishlist").ShouldEqual(IssueKind.Idea); + [Fact] void should_treat_missing_the_same_way() => IssueDraftParsing.ParseKind(null).ShouldEqual(IssueKind.Idea); +} diff --git a/Specs/for_IssueDraftPrompt/when_building_the_user_message/and_the_reporter_added_a_hint.cs b/Specs/for_IssueDraftPrompt/when_building_the_user_message/and_the_reporter_added_a_hint.cs new file mode 100644 index 0000000..a8db88e --- /dev/null +++ b/Specs/for_IssueDraftPrompt/when_building_the_user_message/and_the_reporter_added_a_hint.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftPrompt.when_building_the_user_message; + +public class and_the_reporter_added_a_hint : Specification +{ + string _message = null!; + + void Because() => _message = IssueDraftPrompt.UserMessage("It crashes on startup.", "I think it's the migration"); + + [Fact] void should_include_the_conversation() => _message.ShouldContain("It crashes on startup."); + [Fact] void should_include_the_hint() => _message.ShouldContain("I think it's the migration"); +} diff --git a/Specs/for_IssueDraftPrompt/when_building_the_user_message/and_there_is_no_hint.cs b/Specs/for_IssueDraftPrompt/when_building_the_user_message/and_there_is_no_hint.cs new file mode 100644 index 0000000..20e4498 --- /dev/null +++ b/Specs/for_IssueDraftPrompt/when_building_the_user_message/and_there_is_no_hint.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueDraftPrompt.when_building_the_user_message; + +public class and_there_is_no_hint : Specification +{ + string _message = null!; + + void Because() => _message = IssueDraftPrompt.UserMessage("It crashes on startup.", null); + + [Fact] void should_include_the_conversation() => _message.ShouldContain("It crashes on startup."); + [Fact] void should_not_mention_a_reporter_addition() => _message.Contains("The reporter adds", StringComparison.Ordinal).ShouldBeFalse(); + [Fact] void should_ask_for_json() => _message.ShouldContain("as JSON"); +} diff --git a/Specs/for_IssueEvents/when_deciding_to_answer/and_a_bot_opened_it.cs b/Specs/for_IssueEvents/when_deciding_to_answer/and_a_bot_opened_it.cs new file mode 100644 index 0000000..ff57e9d --- /dev/null +++ b/Specs/for_IssueEvents/when_deciding_to_answer/and_a_bot_opened_it.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_deciding_to_answer; + +public class and_a_bot_opened_it : Specification +{ + bool _shouldAnswer; + + void Because() => _shouldAnswer = IssueEvents.ShouldAnswer( + new("Cratis/Chronicle", 1, "A question", "Body", "url", true, []), + new() { AnsweringRepositories = ["Cratis/Chronicle"] }); + + [Fact] void should_not_talk_to_another_bot() => _shouldAnswer.ShouldBeFalse(); +} diff --git a/Specs/for_IssueEvents/when_deciding_to_answer/and_no_repository_opted_in.cs b/Specs/for_IssueEvents/when_deciding_to_answer/and_no_repository_opted_in.cs new file mode 100644 index 0000000..8e67fe4 --- /dev/null +++ b/Specs/for_IssueEvents/when_deciding_to_answer/and_no_repository_opted_in.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_deciding_to_answer; + +public class and_no_repository_opted_in : Specification +{ + bool _shouldAnswer; + + void Because() => _shouldAnswer = IssueEvents.ShouldAnswer( + new("Cratis/Chronicle", 1, "A question", "Body", "url", false, []), + new()); + + [Fact] void should_answer_nowhere_by_default() => _shouldAnswer.ShouldBeFalse(); +} diff --git a/Specs/for_IssueEvents/when_deciding_to_answer/and_the_issue_opted_out.cs b/Specs/for_IssueEvents/when_deciding_to_answer/and_the_issue_opted_out.cs new file mode 100644 index 0000000..dda5673 --- /dev/null +++ b/Specs/for_IssueEvents/when_deciding_to_answer/and_the_issue_opted_out.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_deciding_to_answer; + +public class and_the_issue_opted_out : Specification +{ + readonly GitHubOptions _options = new() { AnsweringRepositories = ["Cratis/Chronicle"] }; + bool _shouldAnswer; + + void Because() => _shouldAnswer = IssueEvents.ShouldAnswer( + new("Cratis/Chronicle", 1, "A question", "Body", "url", false, ["NO-PROMPTER"]), + _options); + + [Fact] void should_honor_the_label_whatever_its_casing() => _shouldAnswer.ShouldBeFalse(); +} diff --git a/Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_did_not_opt_in.cs b/Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_did_not_opt_in.cs new file mode 100644 index 0000000..391e7eb --- /dev/null +++ b/Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_did_not_opt_in.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_deciding_to_answer; + +public class and_the_repository_did_not_opt_in : Specification +{ + bool _shouldAnswer; + + void Because() => _shouldAnswer = IssueEvents.ShouldAnswer( + new("Cratis/Arc", 1, "A question", "Body", "url", false, []), + new() { AnsweringRepositories = ["Cratis/Chronicle"] }); + + [Fact] void should_stay_out_of_it() => _shouldAnswer.ShouldBeFalse(); +} diff --git a/Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_opted_in.cs b/Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_opted_in.cs new file mode 100644 index 0000000..0057120 --- /dev/null +++ b/Specs/for_IssueEvents/when_deciding_to_answer/and_the_repository_opted_in.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_deciding_to_answer; + +public class and_the_repository_opted_in : Specification +{ + readonly GitHubOptions _options = new() { AnsweringRepositories = ["Cratis/Chronicle"] }; + bool _shouldAnswer; + + void Because() => _shouldAnswer = IssueEvents.ShouldAnswer( + new("Cratis/Chronicle", 1, "A question", "Body", "url", false, []), + _options); + + [Fact] void should_answer() => _shouldAnswer.ShouldBeTrue(); +} diff --git a/Specs/for_IssueEvents/when_parsing_a_delivery/and_a_bot_opened_the_issue.cs b/Specs/for_IssueEvents/when_parsing_a_delivery/and_a_bot_opened_the_issue.cs new file mode 100644 index 0000000..9a8d5a3 --- /dev/null +++ b/Specs/for_IssueEvents/when_parsing_a_delivery/and_a_bot_opened_the_issue.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_parsing_a_delivery; + +public class and_a_bot_opened_the_issue : Specification +{ + OpenedIssue _issue = null!; + + void Because() => _issue = IssueEvents.ParseOpened( + """ + { + "action": "opened", + "repository": { "full_name": "Cratis/Chronicle" }, + "issue": { "number": 8, "title": "Automated", "user": { "type": "Bot" } } + } + """)!; + + [Fact] void should_recognize_the_bot() => _issue.OpenedByBot.ShouldBeTrue(); +} diff --git a/Specs/for_IssueEvents/when_parsing_a_delivery/and_an_issue_was_opened.cs b/Specs/for_IssueEvents/when_parsing_a_delivery/and_an_issue_was_opened.cs new file mode 100644 index 0000000..fd4dfb4 --- /dev/null +++ b/Specs/for_IssueEvents/when_parsing_a_delivery/and_an_issue_was_opened.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_parsing_a_delivery; + +public class and_an_issue_was_opened : Specification +{ + OpenedIssue _issue = null!; + + void Because() => _issue = IssueEvents.ParseOpened(""" + { + "action": "opened", + "repository": { "full_name": "Cratis/Chronicle" }, + "issue": { + "number": 42, + "title": "Projection stops after a rename", + "body": "It stops.", + "html_url": "https://github.com/Cratis/Chronicle/issues/42", + "user": { "type": "User" }, + "labels": [ { "name": "bug" } ] + } + } + """)!; + + [Fact] void should_read_the_repository() => _issue.Repository.ShouldEqual("Cratis/Chronicle"); + [Fact] void should_read_the_repository_name_without_its_owner() => _issue.RepositoryName.ShouldEqual("Chronicle"); + [Fact] void should_read_the_number() => _issue.Number.ShouldEqual(42); + [Fact] void should_read_the_title() => _issue.Title.ShouldEqual("Projection stops after a rename"); + [Fact] void should_read_the_labels() => _issue.Labels.ShouldContain("bug"); + [Fact] void should_not_mistake_a_person_for_a_bot() => _issue.OpenedByBot.ShouldBeFalse(); + [Fact] void should_ask_the_title_and_body_together() => _issue.AsQuestion.ShouldEqual("Projection stops after a rename\n\nIt stops."); +} diff --git a/Specs/for_IssueEvents/when_parsing_a_delivery/and_it_is_a_pull_request.cs b/Specs/for_IssueEvents/when_parsing_a_delivery/and_it_is_a_pull_request.cs new file mode 100644 index 0000000..bf736f6 --- /dev/null +++ b/Specs/for_IssueEvents/when_parsing_a_delivery/and_it_is_a_pull_request.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_parsing_a_delivery; + +public class and_it_is_a_pull_request : Specification +{ + OpenedIssue? _issue; + + void Because() => _issue = IssueEvents.ParseOpened( + """ + { + "action": "opened", + "repository": { "full_name": "Cratis/Chronicle" }, + "issue": { "number": 7, "title": "A pull request", "pull_request": { "url": "..." } } + } + """); + + [Fact] void should_not_answer_a_code_review() => (_issue is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueEvents/when_parsing_a_delivery/and_the_action_is_not_opened.cs b/Specs/for_IssueEvents/when_parsing_a_delivery/and_the_action_is_not_opened.cs new file mode 100644 index 0000000..f40fcce --- /dev/null +++ b/Specs/for_IssueEvents/when_parsing_a_delivery/and_the_action_is_not_opened.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_parsing_a_delivery; + +public class and_the_action_is_not_opened : Specification +{ + OpenedIssue? _issue; + + void Because() => _issue = IssueEvents.ParseOpened( + """{"action":"edited","repository":{"full_name":"Cratis/Chronicle"},"issue":{"number":1}}"""); + + [Fact] void should_ignore_it() => (_issue is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueEvents/when_parsing_a_delivery/and_the_payload_is_malformed.cs b/Specs/for_IssueEvents/when_parsing_a_delivery/and_the_payload_is_malformed.cs new file mode 100644 index 0000000..565ed89 --- /dev/null +++ b/Specs/for_IssueEvents/when_parsing_a_delivery/and_the_payload_is_malformed.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueEvents.when_parsing_a_delivery; + +public class and_the_payload_is_malformed : Specification +{ + OpenedIssue? _issue; + + void Because() => _issue = IssueEvents.ParseOpened("{ not json"); + + [Fact] void should_ignore_it() => (_issue is null).ShouldBeTrue(); +} diff --git a/Specs/for_IssueNotification/when_announcing_an_issue/and_answering_was_not_attempted.cs b/Specs/for_IssueNotification/when_announcing_an_issue/and_answering_was_not_attempted.cs new file mode 100644 index 0000000..f28d760 --- /dev/null +++ b/Specs/for_IssueNotification/when_announcing_an_issue/and_answering_was_not_attempted.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueNotification.when_announcing_an_issue; + +public class and_answering_was_not_attempted : Specification +{ + string _message = null!; + + void Because() => _message = IssueNotification.For( + new("Cratis/Chronicle", 42, "A question", "Body", "url", false, []), + answered: null); + + [Fact] void should_still_announce_it() => _message.ShouldContain("#42"); + [Fact] void should_claim_nothing_about_answering() => _message.Contains("docs", StringComparison.Ordinal).ShouldBeFalse(); +} diff --git a/Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_answered_it.cs b/Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_answered_it.cs new file mode 100644 index 0000000..d08b157 --- /dev/null +++ b/Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_answered_it.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueNotification.when_announcing_an_issue; + +public class and_prompter_answered_it : Specification +{ + string _message = null!; + + void Because() => _message = IssueNotification.For( + new("Cratis/Chronicle", 42, "A question", "Body", "https://github.com/Cratis/Chronicle/issues/42", false, []), + answered: true); + + [Fact] void should_name_the_repository() => _message.ShouldContain("Cratis/Chronicle"); + [Fact] void should_link_the_issue() => _message.ShouldContain("https://github.com/Cratis/Chronicle/issues/42"); + [Fact] void should_say_it_was_answered() => _message.ShouldContain("answered it from the docs"); +} diff --git a/Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_could_not_answer_it.cs b/Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_could_not_answer_it.cs new file mode 100644 index 0000000..d4836b3 --- /dev/null +++ b/Specs/for_IssueNotification/when_announcing_an_issue/and_prompter_could_not_answer_it.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueNotification.when_announcing_an_issue; + +public class and_prompter_could_not_answer_it : Specification +{ + string _message = null!; + + void Because() => _message = IssueNotification.For( + new("Cratis/Chronicle", 42, "A question", "Body", "url", false, []), + answered: false); + + [Fact] void should_flag_it_as_a_possible_gap() => _message.ShouldContain("may be a real gap"); +} diff --git a/Specs/for_IssuePreview/when_rendering/and_similar_issues_are_already_open.cs b/Specs/for_IssuePreview/when_rendering/and_similar_issues_are_already_open.cs new file mode 100644 index 0000000..ab78daf --- /dev/null +++ b/Specs/for_IssuePreview/when_rendering/and_similar_issues_are_already_open.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssuePreview.when_rendering; + +public class and_similar_issues_are_already_open : Specification +{ + string _preview = null!; + + void Because() => _preview = IssuePreview.Text( + new("A title", "A body", IssueKind.Bug, "chronicle"), + "Chronicle", + routed: true, + [new(123, "The same thing", "https://github.com/Cratis/Chronicle/issues/123")]); + + [Fact] void should_offer_the_existing_issue() => _preview.ShouldContain("#123 The same thing"); + [Fact] void should_link_it() => _preview.ShouldContain("https://github.com/Cratis/Chronicle/issues/123"); +} diff --git a/Specs/for_IssuePreview/when_rendering/and_the_body_is_enormous.cs b/Specs/for_IssuePreview/when_rendering/and_the_body_is_enormous.cs new file mode 100644 index 0000000..406865e --- /dev/null +++ b/Specs/for_IssuePreview/when_rendering/and_the_body_is_enormous.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssuePreview.when_rendering; + +public class and_the_body_is_enormous : Specification +{ + string _preview = null!; + + void Because() => _preview = IssuePreview.Text( + new("A title", new string('x', 5000), IssueKind.Bug, "chronicle"), + "Chronicle", + routed: true, + []); + + [Fact] void should_stay_within_discords_message_limit() => (_preview.Length <= 2000).ShouldBeTrue(); + [Fact] void should_show_that_it_was_shortened() => _preview.ShouldContain("…"); +} diff --git a/Specs/for_IssuePreview/when_rendering/and_the_product_was_not_recognized.cs b/Specs/for_IssuePreview/when_rendering/and_the_product_was_not_recognized.cs new file mode 100644 index 0000000..6008936 --- /dev/null +++ b/Specs/for_IssuePreview/when_rendering/and_the_product_was_not_recognized.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssuePreview.when_rendering; + +public class and_the_product_was_not_recognized : Specification +{ + string _preview = null!; + + void Because() => _preview = IssuePreview.Text( + new("A title", "A body", IssueKind.Idea, string.Empty), + "Documentation", + routed: false, + []); + + [Fact] void should_warn_that_the_routing_is_a_guess() => _preview.ShouldContain("couldn't tell which product"); +} diff --git a/Specs/for_IssuePreview/when_rendering/and_the_product_was_recognized.cs b/Specs/for_IssuePreview/when_rendering/and_the_product_was_recognized.cs new file mode 100644 index 0000000..bf711eb --- /dev/null +++ b/Specs/for_IssuePreview/when_rendering/and_the_product_was_recognized.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Discord; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssuePreview.when_rendering; + +public class and_the_product_was_recognized : Specification +{ + string _preview = null!; + + void Because() => _preview = IssuePreview.Text( + new("Projection stops after a rename", "It stops.", IssueKind.Bug, "chronicle"), + "Chronicle", + routed: true, + []); + + [Fact] void should_show_the_title() => _preview.ShouldContain("Projection stops after a rename"); + [Fact] void should_show_the_repository() => _preview.ShouldContain("Chronicle"); + [Fact] void should_show_the_kind() => _preview.ShouldContain("bug"); + [Fact] void should_show_the_body() => _preview.ShouldContain("It stops."); + [Fact] void should_say_nothing_is_filed_yet() => _preview.ShouldContain("Nothing is filed until you say so."); + [Fact] void should_not_warn_about_the_product() => _preview.Contains("couldn't tell which product", StringComparison.Ordinal).ShouldBeFalse(); +} diff --git a/Specs/for_IssueRouting/when_listing_choices/and_the_defaults_are_configured.cs b/Specs/for_IssueRouting/when_listing_choices/and_the_defaults_are_configured.cs new file mode 100644 index 0000000..d709c80 --- /dev/null +++ b/Specs/for_IssueRouting/when_listing_choices/and_the_defaults_are_configured.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueRouting.when_listing_choices; + +public class and_the_defaults_are_configured : Specification +{ + readonly GitHubOptions _options = new(); + IReadOnlyList _choices = null!; + + void Because() => _choices = IssueRouting.Choices(_options); + + [Fact] void should_offer_the_default_first() => _choices[0].ShouldEqual(_options.DefaultRepository); + [Fact] void should_not_repeat_the_default() => _choices.Count(choice => choice == _options.DefaultRepository).ShouldEqual(1); + [Fact] void should_offer_every_product_repository() => _choices.ShouldContain("Chronicle"); +} diff --git a/Specs/for_IssueRouting/when_routing_a_product/and_no_product_was_named.cs b/Specs/for_IssueRouting/when_routing_a_product/and_no_product_was_named.cs new file mode 100644 index 0000000..2ae3c66 --- /dev/null +++ b/Specs/for_IssueRouting/when_routing_a_product/and_no_product_was_named.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueRouting.when_routing_a_product; + +public class and_no_product_was_named : Specification +{ + readonly GitHubOptions _options = new(); + string _repository = null!; + + void Because() => _repository = IssueRouting.RepositoryFor(string.Empty, _options); + + [Fact] void should_fall_back_to_the_default() => _repository.ShouldEqual(_options.DefaultRepository); + [Fact] void should_not_consider_it_routed() => IssueRouting.IsRouted(string.Empty, _options).ShouldBeFalse(); + [Fact] void should_not_consider_null_routed() => IssueRouting.IsRouted(null, _options).ShouldBeFalse(); +} diff --git a/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_cased_differently.cs b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_cased_differently.cs new file mode 100644 index 0000000..550afbc --- /dev/null +++ b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_cased_differently.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueRouting.when_routing_a_product; + +public class and_the_product_is_cased_differently : Specification +{ + string _repository = null!; + + void Because() => _repository = IssueRouting.RepositoryFor("ChRoNiClE", new()); + + [Fact] void should_still_route() => _repository.ShouldEqual("Chronicle"); +} diff --git a/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_known.cs b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_known.cs new file mode 100644 index 0000000..e1ba2f7 --- /dev/null +++ b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_known.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueRouting.when_routing_a_product; + +public class and_the_product_is_known : Specification +{ + readonly GitHubOptions _options = new(); + string _repository = null!; + + void Because() => _repository = IssueRouting.RepositoryFor("chronicle", _options); + + [Fact] void should_route_to_the_owning_repository() => _repository.ShouldEqual("Chronicle"); + [Fact] void should_consider_it_routed() => IssueRouting.IsRouted("chronicle", _options).ShouldBeTrue(); +} diff --git a/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_surrounded_by_whitespace.cs b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_surrounded_by_whitespace.cs new file mode 100644 index 0000000..ce4f858 --- /dev/null +++ b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_surrounded_by_whitespace.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueRouting.when_routing_a_product; + +public class and_the_product_is_surrounded_by_whitespace : Specification +{ + string _repository = null!; + + void Because() => _repository = IssueRouting.RepositoryFor(" arc ", new()); + + [Fact] void should_still_route() => _repository.ShouldEqual("Arc"); +} diff --git a/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_unknown.cs b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_unknown.cs new file mode 100644 index 0000000..d5ae701 --- /dev/null +++ b/Specs/for_IssueRouting/when_routing_a_product/and_the_product_is_unknown.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_IssueRouting.when_routing_a_product; + +public class and_the_product_is_unknown : Specification +{ + readonly GitHubOptions _options = new(); + string _repository = null!; + + void Because() => _repository = IssueRouting.RepositoryFor("orleans", _options); + + [Fact] void should_fall_back_to_the_default() => _repository.ShouldEqual(_options.DefaultRepository); + [Fact] void should_not_consider_it_routed() => IssueRouting.IsRouted("orleans", _options).ShouldBeFalse(); +} diff --git a/Specs/for_PendingIssues/when_holding_a_draft/and_another_is_already_held.cs b/Specs/for_PendingIssues/when_holding_a_draft/and_another_is_already_held.cs new file mode 100644 index 0000000..c23808a --- /dev/null +++ b/Specs/for_PendingIssues/when_holding_a_draft/and_another_is_already_held.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Cratis.Prompter.Specs.Fakes; + +namespace Cratis.Prompter.Specs.for_PendingIssues.when_holding_a_draft; + +public class and_another_is_already_held : Specification +{ + readonly ControlledTime _time = new(DateTimeOffset.UnixEpoch); + PendingIssues _pending = null!; + string _first = null!; + string _second = null!; + + void Establish() => _pending = new(_time); + + void Because() + { + _first = _pending.Hold(new("First", "A body", IssueKind.Bug, "chronicle"), "Chronicle", null); + _second = _pending.Hold(new("Second", "A body", IssueKind.Bug, "chronicle"), "Chronicle", null); + } + + [Fact] void should_give_each_its_own_token() => _second.ShouldNotEqual(_first); + [Fact] void should_keep_them_apart() => _pending.Take(_first)!.Draft.Title.ShouldEqual("First"); +} diff --git a/Specs/for_PendingIssues/when_holding_many_drafts/and_the_capacity_is_exceeded.cs b/Specs/for_PendingIssues/when_holding_many_drafts/and_the_capacity_is_exceeded.cs new file mode 100644 index 0000000..862af82 --- /dev/null +++ b/Specs/for_PendingIssues/when_holding_many_drafts/and_the_capacity_is_exceeded.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Cratis.Prompter.Specs.Fakes; + +namespace Cratis.Prompter.Specs.for_PendingIssues.when_holding_many_drafts; + +public class and_the_capacity_is_exceeded : Specification +{ + readonly ControlledTime _time = new(DateTimeOffset.UnixEpoch); + PendingIssues _pending = null!; + string _first = null!; + string _last = null!; + + void Establish() => _pending = new(_time); + + void Because() + { + _first = _pending.Hold(new("First", "A body", IssueKind.Bug, "chronicle"), "Chronicle", null); + + for (var index = 0; index < PendingIssues.Capacity; index++) + { + _last = _pending.Hold(new("Another", "A body", IssueKind.Bug, "chronicle"), "Chronicle", null); + } + } + + [Fact] void should_drop_the_oldest() => (_pending.Take(_first) is null).ShouldBeTrue(); + [Fact] void should_keep_the_newest() => (_pending.Take(_last) is not null).ShouldBeTrue(); +} diff --git a/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_has_expired.cs b/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_has_expired.cs new file mode 100644 index 0000000..67b3661 --- /dev/null +++ b/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_has_expired.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Cratis.Prompter.Specs.Fakes; + +namespace Cratis.Prompter.Specs.for_PendingIssues.when_taking_a_held_draft; + +public class and_it_has_expired : Specification +{ + readonly ControlledTime _time = new(DateTimeOffset.UnixEpoch); + PendingIssues _pending = null!; + PendingIssue? _taken; + + void Establish() => _pending = new(_time); + + void Because() + { + var token = _pending.Hold(new("A title", "A body", IssueKind.Bug, "chronicle"), "Chronicle", null); + _time.Advance(PendingIssues.Lifetime + TimeSpan.FromMinutes(1)); + _taken = _pending.Take(token); + } + + [Fact] void should_be_gone() => (_taken is null).ShouldBeTrue(); +} diff --git a/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_is_still_fresh.cs b/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_is_still_fresh.cs new file mode 100644 index 0000000..0538c5d --- /dev/null +++ b/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_is_still_fresh.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Cratis.Prompter.Specs.Fakes; + +namespace Cratis.Prompter.Specs.for_PendingIssues.when_taking_a_held_draft; + +public class and_it_is_still_fresh : Specification +{ + readonly ControlledTime _time = new(DateTimeOffset.UnixEpoch); + PendingIssues _pending = null!; + PendingIssue _taken = null!; + + void Establish() => _pending = new(_time); + + void Because() + { + var token = _pending.Hold(new("A title", "A body", IssueKind.Bug, "chronicle"), "Chronicle", "https://discord.com/x"); + _taken = _pending.Take(token)!; + } + + [Fact] void should_return_the_draft() => _taken.ShouldNotBeNull(); + [Fact] void should_keep_the_title() => _taken.Draft.Title.ShouldEqual("A title"); + [Fact] void should_keep_the_repository() => _taken.Repository.ShouldEqual("Chronicle"); + [Fact] void should_keep_the_conversation_link() => _taken.ConversationUrl.ShouldEqual("https://discord.com/x"); +} diff --git a/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_was_already_taken.cs b/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_was_already_taken.cs new file mode 100644 index 0000000..7b9d215 --- /dev/null +++ b/Specs/for_PendingIssues/when_taking_a_held_draft/and_it_was_already_taken.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Cratis.Prompter.Specs.Fakes; + +namespace Cratis.Prompter.Specs.for_PendingIssues.when_taking_a_held_draft; + +public class and_it_was_already_taken : Specification +{ + readonly ControlledTime _time = new(DateTimeOffset.UnixEpoch); + PendingIssues _pending = null!; + PendingIssue? _second; + + void Establish() => _pending = new(_time); + + void Because() + { + var token = _pending.Hold(new("A title", "A body", IssueKind.Bug, "chronicle"), "Chronicle", null); + _pending.Take(token); + _second = _pending.Take(token); + } + + [Fact] void should_not_let_a_double_click_file_twice() => (_second is null).ShouldBeTrue(); +} diff --git a/Specs/for_PendingIssues/when_taking_a_held_draft/and_the_token_is_unknown.cs b/Specs/for_PendingIssues/when_taking_a_held_draft/and_the_token_is_unknown.cs new file mode 100644 index 0000000..81a439b --- /dev/null +++ b/Specs/for_PendingIssues/when_taking_a_held_draft/and_the_token_is_unknown.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.GitHub; +using Cratis.Prompter.Specs.Fakes; + +namespace Cratis.Prompter.Specs.for_PendingIssues.when_taking_a_held_draft; + +public class and_the_token_is_unknown : Specification +{ + PendingIssue? _taken; + + void Because() => _taken = new PendingIssues(new ControlledTime(DateTimeOffset.UnixEpoch)).Take("nope"); + + [Fact] void should_return_nothing() => (_taken is null).ShouldBeTrue(); +} diff --git a/Specs/for_WebhookAuth/when_verifying_a_delivery/and_no_secret_is_configured.cs b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_no_secret_is_configured.cs new file mode 100644 index 0000000..e9724d5 --- /dev/null +++ b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_no_secret_is_configured.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_WebhookAuth.when_verifying_a_delivery; + +public class and_no_secret_is_configured : Specification +{ + bool _authentic; + + void Because() => _authentic = WebhookAuth.IsAuthentic("sha256=abc", Encoding.UTF8.GetBytes("body"), string.Empty); + + [Fact] void should_refuse_every_caller() => _authentic.ShouldBeFalse(); +} diff --git a/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_payload_was_altered.cs b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_payload_was_altered.cs new file mode 100644 index 0000000..1bde5b5 --- /dev/null +++ b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_payload_was_altered.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_WebhookAuth.when_verifying_a_delivery; + +public class and_the_payload_was_altered : Specification +{ + const string Secret = "s3cret"; + bool _authentic; + + void Because() + { + var signed = Encoding.UTF8.GetBytes("the original body"); + var signature = "sha256=" + Convert.ToHexString( + HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), signed)).ToLower(CultureInfo.InvariantCulture); + + _authentic = WebhookAuth.IsAuthentic(signature, Encoding.UTF8.GetBytes("a tampered body"), Secret); + } + + [Fact] void should_refuse_the_delivery() => _authentic.ShouldBeFalse(); +} diff --git a/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_header_is_absent.cs b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_header_is_absent.cs new file mode 100644 index 0000000..f0df04d --- /dev/null +++ b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_header_is_absent.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_WebhookAuth.when_verifying_a_delivery; + +public class and_the_signature_header_is_absent : Specification +{ + bool _authentic; + + void Because() => _authentic = WebhookAuth.IsAuthentic(null, Encoding.UTF8.GetBytes("body"), "s3cret"); + + [Fact] void should_refuse_the_delivery() => _authentic.ShouldBeFalse(); +} diff --git a/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_not_prefixed.cs b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_not_prefixed.cs new file mode 100644 index 0000000..44aca93 --- /dev/null +++ b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_not_prefixed.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_WebhookAuth.when_verifying_a_delivery; + +public class and_the_signature_is_not_prefixed : Specification +{ + bool _authentic; + + void Because() => _authentic = WebhookAuth.IsAuthentic("deadbeef", Encoding.UTF8.GetBytes("body"), "s3cret"); + + [Fact] void should_refuse_the_delivery() => _authentic.ShouldBeFalse(); +} diff --git a/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_uppercase_hex.cs b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_uppercase_hex.cs new file mode 100644 index 0000000..26c5e71 --- /dev/null +++ b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_is_uppercase_hex.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using System.Text; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_WebhookAuth.when_verifying_a_delivery; + +public class and_the_signature_is_uppercase_hex : Specification +{ + const string Secret = "s3cret"; + readonly byte[] _payload = Encoding.UTF8.GetBytes("body"); + bool _authentic; + + void Because() + { + var signature = "sha256=" + Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), _payload)); + + _authentic = WebhookAuth.IsAuthentic(signature, _payload, Secret); + } + + [Fact] void should_still_accept_the_delivery() => _authentic.ShouldBeTrue(); +} diff --git a/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_matches.cs b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_matches.cs new file mode 100644 index 0000000..0cd707d --- /dev/null +++ b/Specs/for_WebhookAuth/when_verifying_a_delivery/and_the_signature_matches.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Cratis.Prompter.GitHub; + +namespace Cratis.Prompter.Specs.for_WebhookAuth.when_verifying_a_delivery; + +public class and_the_signature_matches : Specification +{ + const string Secret = "s3cret"; + readonly byte[] _payload = Encoding.UTF8.GetBytes("""{"action":"opened"}"""); + bool _authentic; + + void Because() + { + var signature = "sha256=" + Convert.ToHexString( + HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), _payload)).ToLower(CultureInfo.InvariantCulture); + + _authentic = WebhookAuth.IsAuthentic(signature, _payload, Secret); + } + + [Fact] void should_accept_the_delivery() => _authentic.ShouldBeTrue(); +}