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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,17 @@ CI/CD via GitHub Actions:
- **`pr-validation.yml`**: on PRs, runs `dotnet build` + `dotnet test --filter "Category!=Integration"`

The Dockerfile is a standard multi-stage build (SDK → publish → mcr.microsoft.com/dotnet/runtime).

## Agent skills

### Issue tracker

Issues are tracked in GitHub Issues for this repository (`cgrpa/TheSexy6BotWorker`) via `gh`. See `docs/agents/issue-tracker.md`.

### Triage labels

Triage uses the default five-label vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.

### Domain docs

Domain docs are configured as multi-context (`CONTEXT-MAP.md` + per-context `CONTEXT.md` and ADRs). See `docs/agents/domain.md`.
53 changes: 53 additions & 0 deletions docs/agents/domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Domain Docs

Configured layout for this repo: **multi-context**.

How the engineering skills should consume this repo's domain documentation when exploring the codebase.

## Before exploring, read these

- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists - it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** - read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.

If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.

## File structure

Single-context repo (most repos):

```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```

Multi-context repo (presence of `CONTEXT-MAP.md` at the root):

```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```

## Use the glossary's vocabulary

When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.

If the concept you need isn't in the glossary yet, that's a signal - either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).

## Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:

> _Contradicts ADR-0007 (event-sourced orders) - but worth reopening because..._
22 changes: 22 additions & 0 deletions docs/agents/issue-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Issue tracker: GitHub

Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.

## Conventions

- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`

Infer the repo from `git remote -v` - `gh` does this automatically when run inside a clone.

## When a skill says "publish to the issue tracker"

Create a GitHub issue.

## When a skill says "fetch the relevant ticket"

Run `gh issue view <number> --comments`.
15 changes: 15 additions & 0 deletions docs/agents/triage-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Triage Labels

The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.

| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |

When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.

Edit the right-hand column to match whatever vocabulary you actually use.
10 changes: 8 additions & 2 deletions src/dotnet/TheSexy6BotWorker/DiscordWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@
{
private readonly ILogger<DiscordWorker> _logger;
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _hostEnvironment;
private DiscordClient _client;
public DiscordWorker(ILogger<DiscordWorker> logger, IConfiguration configuration)

public DiscordWorker(

Check warning on line 29 in src/dotnet/TheSexy6BotWorker/DiscordWorker.cs

View workflow job for this annotation

GitHub Actions / test-dotnet / test

Non-nullable field '_client' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
ILogger<DiscordWorker> logger,
IConfiguration configuration,
IHostEnvironment hostEnvironment)
{
_logger = logger;
_configuration = configuration;
_hostEnvironment = hostEnvironment;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
Expand All @@ -35,14 +41,14 @@
Guard.Against.NullOrEmpty(_configuration["DiscordToken"], "DiscordToken");

var builder = DiscordClientBuilder.CreateDefault(
_configuration["DiscordToken"],

Check warning on line 44 in src/dotnet/TheSexy6BotWorker/DiscordWorker.cs

View workflow job for this annotation

GitHub Actions / test-dotnet / test

Possible null reference argument for parameter 'token' in 'DiscordClientBuilder DiscordClientBuilder.CreateDefault(string token, DiscordIntents intents, IServiceCollection? serviceCollection = null)'.
DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents);


builder.ConfigureServices(services =>
{
// Determine environment prefix for bot commands
var environmentPrefix = Environment.GetEnvironmentVariable("Environment") == "Development" ? "test-" : "";
var environmentPrefix = _hostEnvironment.IsDevelopment() ? "test-" : "";

// Register bot registry and configurations
services.AddSingleton(sp =>
Expand Down Expand Up @@ -88,7 +94,7 @@
var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.AddGoogleAIGeminiChatCompletion(
modelId: "gemini-2.5-flash-lite",
apiKey: _configuration["GeminiKey"],

Check warning on line 97 in src/dotnet/TheSexy6BotWorker/DiscordWorker.cs

View workflow job for this annotation

GitHub Actions / test-dotnet / test

Possible null reference argument for parameter 'apiKey' in 'IKernelBuilder GoogleAIKernelBuilderExtensions.AddGoogleAIGeminiChatCompletion(IKernelBuilder builder, string modelId, string apiKey, GoogleAIVersion apiVersion = GoogleAIVersion.V1_Beta, string? serviceId = null, HttpClient? httpClient = null)'.
serviceId: "gemini");

kernelBuilder.AddOpenAIChatCompletion(
Expand Down
5 changes: 4 additions & 1 deletion src/dotnet/TheSexy6BotWorker/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ public static int Main(string[] args)
.ToArray();

var builder = Host.CreateApplicationBuilder(hostArgs);
builder.Configuration.AddUserSecrets<Program>();
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>();
}

if (!isSmokeTest)
{
Expand Down
75 changes: 71 additions & 4 deletions src/terraform/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,78 @@
# Terraform: Azure Key Vault Remote Secret Contract

## Local Terraform Plan

1. Set subscription context:
`export ARM_SUBSCRIPTION_ID="<your-subscription-id>"`
2. Initialize:
`terraform init -backend-config=local.tfbackend`
3. Plan:
`terraform plan`

Before running tf plan:
1. Set environment subscription
run `export ARM_SUBSCRIPTION_ID=your-subscription-id`
## Secret Contract

2. `terraform init -backend-config=local.tfbackend`
- Required runtime keys are:
- `DiscordToken`
- `GeminiKey`
- `GrokKey`
- `PerplexityApiKey`
- Terraform maps those keys to Container App secret aliases:
- `DiscordToken -> discord-token`
- `GeminiKey -> gemini-key`
- `GrokKey -> grok-key`
- `PerplexityApiKey -> perplexity-api-key`
- Remote runtime uses Key Vault references only (no secret values in Terraform config/state).

## Enforcement Controls

- `required_secret_names`:
- Defaults to the four runtime keys above.
- Must exactly match the alias-map keys (parity check enforced by precondition).
- `enforce_required_secret_presence`:
- Defaults to `true`.
- When `true`, Terraform fails fast if required secrets are missing/disabled in Key Vault.
- When `false`, Terraform allows bootstrap mode and only wires currently present+enabled secrets.

## Bootstrap / Rotation Script

Use `scripts/upsert-required-secrets.sh` to set all required secrets in Key Vault.

Example:

```bash
KEY_VAULT_NAME="stg-uks-discordbot-kv" \
DISCORD_TOKEN="..." \
GEMINI_KEY="..." \
GROK_KEY="..." \
PERPLEXITY_API_KEY="..." \
./scripts/upsert-required-secrets.sh
```

Notes:

- The script defaults to all-or-nothing updates.
- Missing values are prompted securely when interactive.
- Use `--non-interactive` in CI/automation.
- Use `--allow-partial` only for explicit recovery workflows.

## Post-Rotation Refresh (Deterministic)

Container Apps can take time to pick up rotated values automatically. For deterministic cutover, restart active revisions:

```bash
APP_NAME="<container-app-name>"
RESOURCE_GROUP="<resource-group-name>"

for REVISION in $(az containerapp revision list \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--query "[?properties.active].name" \
--output tsv); do
az containerapp revision restart \
--name "$APP_NAME" \
--resource-group "$RESOURCE_GROUP" \
--revision "$REVISION"
done
```


34 changes: 34 additions & 0 deletions src/terraform/container_app.tf
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,54 @@ resource "azurerm_container_app" "this" {
identity = "System"
}

dynamic "secret" {
for_each = local.container_app_secret_aliases

content {
name = secret.value
identity = "System"
key_vault_secret_id = format("%ssecrets/%s", azurerm_key_vault.this.vault_uri, secret.key)
}
}

// bootstrap - pipeline owns image
template {
container {
name = local.name_prefix
image = "mcr.microsoft.com/k8se/quickstart:latest"
cpu = 0.25
memory = "0.5Gi"

dynamic "env" {
for_each = local.container_app_secret_aliases

content {
name = env.key
secret_name = env.value
}
}
}
}

lifecycle {
ignore_changes = [
template[0].container[0].image,
]

precondition {
condition = length(setsubtract(local.required_secret_keys, local.alias_map_keys)) == 0 && length(setsubtract(local.alias_map_keys, local.required_secret_keys)) == 0
error_message = "required_secret_names must match alias-map keys exactly. Expected keys: ${join(", ", sort(keys(local.required_secret_alias_map)))}."
}

precondition {
condition = !var.enforce_required_secret_presence || length(local.missing_required_secret_names) == 0
error_message = "Required Key Vault secrets are missing: ${join(", ", local.missing_required_secret_names)}."
}

precondition {
condition = !var.enforce_required_secret_presence || length(local.disabled_required_secret_names) == 0
error_message = "Required Key Vault secrets are disabled: ${join(", ", local.disabled_required_secret_names)}."
}
}

identity {
Expand Down
32 changes: 16 additions & 16 deletions src/terraform/container_registry.tf
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
resource "azurerm_container_registry" "this" {
name = "${local.name_prefix_no_dash}acr"
resource_group_name = azurerm_resource_group.this.name
location = var.location
sku = "Basic"
admin_enabled = false
tags = local.common_tags
identity {
type = "SystemAssigned"
}
name = "${local.name_prefix_no_dash}acr"
resource_group_name = azurerm_resource_group.this.name
location = var.location
sku = "Basic"
admin_enabled = false
tags = local.common_tags

identity {
type = "SystemAssigned"
}
}

resource "azurerm_role_assignment" "acr_push" {
scope = azurerm_container_registry.this.id
role_definition_name = "AcrPush"
principal_id = data.azurerm_client_config.this.object_id
scope = azurerm_container_registry.this.id
role_definition_name = "AcrPush"
principal_id = data.azurerm_client_config.this.object_id
}

resource "azurerm_role_assignment" "acr_pull" {
scope = azurerm_container_registry.this.id
role_definition_name = "AcrPull"
principal_id = azurerm_container_app.this.identity[0].principal_id
scope = azurerm_container_registry.this.id
role_definition_name = "AcrPull"
principal_id = azurerm_container_app.this.identity[0].principal_id
}

6 changes: 5 additions & 1 deletion src/terraform/data.tf
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
data "azurerm_client_config" "this" {}
data "azurerm_client_config" "this" {}

data "azurerm_key_vault_secrets" "required" {
key_vault_id = azurerm_key_vault.this.id
}
6 changes: 3 additions & 3 deletions src/terraform/environment.tfvars
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
application = "discordbot"
location = "uksouth"
application = "discordbot"
location = "uksouth"
location_short = "uks"
environment = "__ENVIRONMENT__"
environment = "__ENVIRONMENT__"

#these populate:
# name_prefix in format "${var.environment}-${var.application}"
Expand Down
30 changes: 18 additions & 12 deletions src/terraform/key_vault.tf
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
resource "azurerm_key_vault" "this" {
name = "${local.name_prefix}-kv"
resource_group_name = azurerm_resource_group.this.name
location = var.location
tenant_id = data.azurerm_client_config.this.tenant_id
purge_protection_enabled = false
sku_name = "standard"
enable_rbac_authorization = true
name = "${local.name_prefix}-kv"
resource_group_name = azurerm_resource_group.this.name
location = var.location
tenant_id = data.azurerm_client_config.this.tenant_id
purge_protection_enabled = false
sku_name = "standard"
enable_rbac_authorization = true

tags = local.common_tags
tags = local.common_tags
}

resource "azurerm_role_assignment" "kv_pipeline_access" {
scope = azurerm_key_vault.this.id
role_definition_name = "Key Vault Secrets User"
principal_id = data.azurerm_client_config.this.object_id
}
scope = azurerm_key_vault.this.id
role_definition_name = "Key Vault Secrets User"
principal_id = data.azurerm_client_config.this.object_id
}

resource "azurerm_role_assignment" "kv_container_app_access" {
scope = azurerm_key_vault.this.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_container_app.this.identity[0].principal_id
}
2 changes: 1 addition & 1 deletion src/terraform/law.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ resource "azurerm_log_analytics_workspace" "this" {
resource_group_name = azurerm_resource_group.this.name
sku = "PerGB2018"
retention_in_days = 30
daily_quota_gb = 1
daily_quota_gb = 1

tags = local.common_tags
}
Loading
Loading