Skip to content

Interactive buttons on alert messages with ephemeral responses #109

Description

@jfberry

Summary

Add support for Discord button components (and later, Telegram inline keyboards) attached to alert messages. Clicking a button either renders an ephemeral response template (clicker-only follow-up message) or dispatches a named action (mute, unsubscribe, redeliver, etc.) using the snapshot stored at delivery time.

This issue also covers the !mute / !unmute / /mute / /unmute commands that share the same mute infrastructure as button-triggered actions — buttons aren't the only way users mute alerts, and command users need a way back from a mute they applied without buttons.

Depends on #108 (snapshot store). #110 (TOML DTS format) is the natural authoring surface for non-trivial button declarations but is not strictly required for v1.

Quick start

If you're an operator looking at this fresh and want one mute button on your raid template, here's the minimum change.

1. Turn on snapshots in config.toml:

[snapshots]
enabled = true

2. Add a buttons array to your raid DTS entry (any format — JSON shown here):

{
  "id": "1",
  "type": "raid",
  "platform": "discord",
  "language": "en",
  "template": { "embed": { "title": "{{levelName}} {{fullName}} at {{gymName}}", ... } },
  "buttons": [
    {
      "id": "mute_gym",
      "label": "Mute this gym (1h)",
      "style": "danger",
      "action": "mute",
      "scope": "gym",
      "params": { "duration_min": 60 }
    }
  ]
}

3. Reload DTS: POST /api/dts/reload (or restart).

4. Next raid alert in a DM will have a "Mute this gym (1h)" button. Clicking it silences alerts from that gym for the user, for 1 hour. The mute appears in !tracked output under the user's tracking list.

That's the whole adoption path. Everything else in this issue covers what becomes possible beyond this minimal example.

Use cases

Response-template buttons (render a different view of the same alert):

  • "Show PVP details" — full PVP breakdown without polluting the channel.
  • "Directions from my location" — distance/bearing computed for the clicker, not the original target.
  • "Show full move details" — for templates that ship a compact view by default.
  • "Re-display this alert in my DM" — re-render the exact original template using Snapshot.TemplateSelected against the clicker.

Action buttons (state change, with ephemeral confirmation):

  • "Mute this gym (1h)" — temporary mute scoped to the gym in the snapshot view.
  • "Mute the rule that fired this" — temporary mute scoped to one or more Snapshot.TrackingUIDs.
  • "Mute this area (4h)" — temporary mute scoped to a matched area from Snapshot.MatchedAreas.
  • "Unsubscribe from this tracking rule" — permanent removal of the matching tracking rule(s).

Click handler flow

  1. Discord InteractionCreate event arrives in the existing discordgo gateway handler (already running for commands/reconciliation).
  2. Parse custom_id(messageID, actionID).
  3. Load Snapshot from the snapshot store (Snapshot enrichment store for post-alert rendering #108) keyed by messageID. On miss → ephemeral "this alert has expired."
  4. Resolve the button definition by (actionID, Snapshot.TemplateType, Snapshot.Platform, Snapshot.Language) against the currently-loaded DTS. On miss → ephemeral "this button is no longer available."
  5. Apply applies_to check against Snapshot.TargetType (defence-in-depth — the button shouldn't have been rendered in the first place if the target type was incompatible). On fail → ephemeral "this button doesn't apply here."
  6. Apply visible_to check against the clicker. On fail → ephemeral "this button isn't for you."
  7. Dispatch:
    • If the button has a response field (response_template_id, response_template_inline, or response_text — see Optional TOML format for DTS templates #110) → render and respond ephemeral.
    • If the button has action → invoke the named action handler with params plus snapshot context. Handler returns an ephemeral confirmation message.
  8. Respond within Discord's 3-second interaction window. For longer-running actions, defer the interaction and follow up.

Button declaration

Inline in the DTS entry. See #110 for the cleaner TOML form and the full response-shape vocabulary. Minimal JSON example (works today):

{
  "id": "1",
  "type": "raid",
  "platform": "discord",
  "language": "en",
  "template": { ... },
  "buttons": [
    {
      "id": "pvp",
      "label": "Show PVP details",
      "style": "secondary",
      "response_template_id": "raid_pvp_details",
      "show_if": "{{hasPVP}}"
    },
    {
      "id": "mute_gym",
      "label": "Mute this gym (1h)",
      "style": "danger",
      "action": "mute",
      "scope": "gym",
      "params": { "duration_min": 60 },
      "applies_to": ["dm"],
      "visible_to": "target"
    },
    {
      "id": "unsub_rule",
      "label": "Unsubscribe from this rule",
      "style": "danger",
      "action": "unsubscribe",
      "scope": "tracking",
      "applies_to": ["dm"],
      "visible_to": "target"
    }
  ]
}

A button must declare exactly one response field (response_template_id / response_template_inline / response_text) or an action. Multiple/none → DTS load error.

Action-specific configuration (durations, target overrides, anything else the handler needs) goes in a generic params sub-object — the DTS schema doesn't need to know about action-specific fields, and new actions don't need schema changes.

Action types

A small set of named actions in v1; the registry is extensible.

Action Effect params fields Allowed scope values
mute Add an in-memory mute entry that filters matching alerts for the duration duration_min gym, pokestop, station, pokemon, tracking, area, everything
unsubscribe Permanently delete the tracking rule(s) identified by scope (none) tracking only
redeliver Re-render the original alert using Snapshot.TemplateSelected and send to the clicker's DM (none) (none — uses snapshot directly)
render Render an arbitrary template (alternative to the response-template fields) template_id (special value "$same" uses Snapshot.TemplateSelected) (none)

Action handlers live in internal/buttonactions/ (new package). Each registers a handler by name; unknown actions → ephemeral error at click time.

unsubscribe is scope=tracking only. Other scopes (gym, pokemon, etc.) can't be unsubscribed because we'd need a tracking-rule-level exemption model ("this rule but NOT for gym X"), which doesn't exist. Mute covers those cases instead.

mute vs unsubscribe: mute is time-bound and additive (existing tracking rule remains; the mute table filters it out for a window); unsubscribe deletes the tracking rule entirely. Operators choose which to expose based on UX intent.

A combined "Manage subscription" UX (one button → menu of mute durations + unsubscribe option) needs response messages that themselves have buttons — see #110's "buttons in responses" discussion; not in v1.

Scope dispatch

The scope field on an action button identifies which snapshot field carries the target identity for that action. The action handler reads the corresponding field at click time.

scope value Reads from Used for
gym Snapshot.View["gym_id"] Mute scoped to a specific gym
pokestop Snapshot.View["pokestop_id"] Mute scoped to a quest/lure/invasion/incident source
station Snapshot.View["station_id"] Mute scoped to a max battle station
pokemon Snapshot.View["pokemon_id"] Mute scoped to a pokemon species
tracking Snapshot.TrackingUIDs[] Mute / unsubscribe the specific tracking rule(s) that fired this alert
area Snapshot.MatchedAreas[] Mute scoped to one of the geographic areas this alert was in
everything Snapshot.Target (DM only) Self-scoped actions (mute everything for this user temporarily)

scope is action-handler-specific in interpretation — scope = "gym" means different things to mute and (hypothetically) a gym_info action. The mapping in the table is what the v1 mute/unsubscribe handlers expect.

Multi-value scopes (tracking, area) — the handler applies to all entries by default, listing the affected rules/areas in the ephemeral confirmation. Selective disambiguation (a follow-up menu) is a v2 enhancement.

Mute storage — in-memory

The mute state is held in an in-memory map. Not persisted across processor restarts. Rationale:

  • Mutes are short-lived by design (typically minutes to hours).
  • Restart frequency is low; users losing a 30-minute mute on a restart is acceptable.
  • An in-memory representation avoids new DB schema, a new migration, and the matcher having to do an extra table join on every event.
  • Users can always re-apply via command or button.

Shape:

type MuteEntry struct {
    HumanID    string  // who muted (or whose alerts are muted)
    ScopeType  string  // "gym" / "pokestop" / ... matching button scope values
    ScopeValue string  // the corresponding id from Snapshot
    ExpiresAt  int64
}

Held in map[humanID][]MuteEntry with a per-human slice for O(1) lookup during matching. Background sweep prunes expired entries.

Matcher placement

The mute filter runs as a new step in the existing matching pipeline:

match against tracking rules
  → filterBlocked    (existing — rate-limit pre-filter)
  → filterValidation (existing — area / community validation)
  → filterMuted      (NEW — drop users whose mutes match this event)
  → render
  → dispatch

filterMuted runs per matched user: walk the user's MuteEntry slice and check whether any entry matches the event. Entity scopes (gym, pokestop, station, pokemon, area) compare against the event's properties; tracking scope compares MuteEntry.ScopeValue against the matched tracking UID. If any mute fires for that user, drop them from the matched list.

For deliveries that match multiple tracking UIDs at once (pokemon with basic + great PVP + ultra PVP), one MuteEntry per UID — the user can mute "the great-league PVP rule" without suppressing the basic IV match. The filter is per-UID, not per-event.

Profile context for mutes

  • Entity mutes (gym, pokestop, station, pokemon, area, everything) apply across all of a user's profiles. They're a user-level statement ("I don't want alerts for this gym right now"). Profile switching doesn't affect them.
  • UID-scoped mutes (tracking) are naturally profile-scoped because tracking rule UIDs are unique per (human_id, profile_no, rule). Muting UID 45 only suppresses the row with that UID, which belongs to one profile.

This split falls out of the data model — profile and scope are orthogonal axes; entity mutes operate on event properties (which don't depend on profile), UID mutes operate on rule rows (which do).

Command-line access — !mute / !unmute / /mute / /unmute

Same infrastructure as button-triggered mute, accessible to users without button-enabled templates and as a way back from mutes applied via either path. Slash forms parallel the chat forms.

The syntax follows the same dual-form pattern as the existing !untrack raid!raid remove duality. Both !<action> <type> and !<type> <action> are accepted and produce identical effects — operators don't need to learn a new vocabulary; mute reuses the filter parsers each tracking type already has for its remove subcommand.

Tracking-rule vs entity mute — disambiguation via id:X

A unified !mute command handles both. The conflict between "mute the species pikachu" and "mute the specific tracking rule that matches pikachu" is resolved by the id:X form — UIDs are visible in !tracked output, so users can always reach the specific rule.

Per-tracking-rule mutes

# Default-type (pokemon) — symmetric with bare !untrack
!mute pikachu                          # entity mute on pokemon species (no UID = property mute)
!mute id:45                            # mute a specific tracking rule by UID (type-agnostic)
!track mute pikachu                    # alternate form, mirrors `!track remove`

# Per-type — symmetric with `!untrack raid` ≡ `!raid remove`
!mute raid id:12
!raid mute id:12                       # alternate form, mirrors `!raid remove`
!mute pokemon id:45
!pokemon mute id:45                    # (if the corresponding command alias exists)

# Duration flag works on any of these (defaults to configurable default, e.g. 1h)
!mute raid id:12 duration:2h

The per-type form (!raid mute …) is a thin routing alias — it prepends the type and delegates to the unified !mute raid … handler. It does not accept the full per-type filter vocabulary (level:5 team:valor etc.) that the corresponding remove subcommand does. Tracking-rule mutes are UID-targeted by design, and !tracked already exposes the UIDs operators need to mute specific rules.

Property-based mutes (broader than a single tracking rule)

These mute alerts by event property regardless of which tracking rule fires. The first positional token after !mute is a scope noun; the scope's value is positional and may need quotes for multi-word entries. (The : notation is reserved for parameters like duration: and id: — same convention as the rest of the command vocabulary.)

!mute gym "Victoria Park Entrance" [duration:1h]   # gym name (multi-word quoted)
!mute gym 1a15c33709c147fd85eeb9e6bb1e1c14.16      # or by hex ID
!mute area "Downtown" [duration:1h]
!mute pokestop <pokestop_id> [duration:1h]         # hex .16 form
!mute station <station_id> [duration:1h]            # max battle station, hex .16
!mute pokemon <pokemon_name|id> [duration:1h]      # any rule firing for this species
!mute everything [duration:30m]                     # self-mute all alerts for the caller

The handler dispatches on whether the first positional token is a known tracking type (raid, pokemon, etc.) or a known scope noun (gym, area, etc.). Both write to the same in-memory mute map.

Resolver helpers are reused from the existing commands: gym uses the same lookup as !raid gym:… (accepts hex or quoted name), pokemon uses the existing pokemon resolver (aliases, translations, evolution chains), area uses the user-permitted-area validator.

Unmute

!unmute id:45                          # unmute by UID (type-agnostic)
!unmute raid id:12                     # per-type
!raid unmute id:12                     # alternate form
!unmute gym "Victoria Park Entrance"   # remove a property-based mute
!unmute all                            # clear all mutes for the caller
!unmute everything                     # alias for !unmute all

Listing mutes — via !tracked

There is no separate !mutes command. Active mutes are surfaced as part of the existing !tracked output, beneath the tracking rules they suppress (for UID-scoped mutes) or in a "Property mutes" section at the bottom (for entity-scoped mutes). One place to see "what alerts will I receive."

Sketch of the augmented !tracked output:

Tracking pokemon:
  [id:12] Pikachu IV>90       🔇 muted (1h 23m left)
  [id:13] Charizard CP>2500
  ...

Tracking raids:
  [id:45] Level 5
  ...

Property mutes (apply across all tracking):
  gym:Victoria Park Entrance       (32m left)
  pokemon:Pikachu                  (1h 12m left)
  area:Downtown                    (2h 8m left)

Names are shown when resolvable; hex IDs are shown verbatim otherwise.

This keeps users from having to learn a new command and makes "what's currently silenced" visible alongside "what's being tracked" — the two questions are naturally answered together.

Resolution rules

  • Pokemon names resolve via the existing pokemon resolver (translations, aliases, evolution chains).
  • Gym names/ids resolve via the same scanner DB lookup !raid and friends use.
  • Area names match against the user's permitted areas (community-aware under area_security).
  • Tracking UIDs are exposed by !tracked (the [id:XX] suffix per rule).

Admin overrides and channel mutes

Admin overrides (user:<id> / name:<webhook>) follow the existing BuildTarget mechanic — admins can mute another user's alerts or mute a channel's alerts without giving up the regular target resolution.

Channel muting is command-only, not button. Real-estate concern: putting a "Mute this channel" button on every alert would clutter every message for the much smaller audience that has channel-admin authority. Channel admins run !mute from within the channel (or with explicit override) when they need it.

Target type filtering — applies_to

Some actions only make sense for certain target types. The applies_to field on a button lists the Snapshot.TargetType values for which the button is rendered.

applies_to value Meaning
dm Only DM deliveries
channel Only channel deliveries
webhook Only webhook deliveries
any (default) All target types

A list combines multiple: applies_to = ["dm", "channel"].

Defaults are action-level, not schema-level

To avoid operators having to remember to write applies_to = ["dm"] on every mute button, defaults are set per action rather than as a global schema default:

Button kind Default applies_to
action = "mute" ["dm"]
action = "unsubscribe" ["dm"]
action = "redeliver" ["any"]
action = "render" (ephemeral response only) ["any"]
response_template_id / response_template_inline / response_text (ephemeral response only) ["any"]

The rule: any button that mutates state defaults to DM-only; any button that just sends an ephemeral response defaults to anywhere. Operators can override per-button by writing applies_to explicitly. Channel admins manage channel-level mutes via the command surface (!mute with channel target), not buttons — clutter on every alert is the wrong tradeoff for the rare admin use case.

Buttons that fail applies_to are not attached to the message at all (render-time filter). The click-time check is defence-in-depth in case state has changed.

Conditional visibility — show_if

Buttons can declare a Handlebars expression evaluated against the snapshot view at alert render time. If the expression is falsy, the button is not attached. Evaluated once, before delivery — not at click time.

[[entry.buttons]]
id = "pvp"
label = "Show PVP details"
response_template_id = "raid_pvp_details"
show_if = "{{hasPVP}}"      # only attach if the alert has PVP data

This avoids "this button doesn't apply" ephemerals for predictable conditions and keeps the message UI clean.

Visibility — visible_to

visible_to controls who can click the button. The check runs at click time.

Value Allowed clickers
target (default) Only the human who received the original message. For DMs, this is the only person who can see the message anyway. For channels, the snapshot has the channel as Targettarget means "the channel is allowed to receive interactions on this message," which effectively means anyone with channel access.
admin Only Poracle admins (discord.admins config).
registered Anyone registered as a Poracle user, in any guild Poracle is in.
anyone No check; anyone with channel access.

visible_to is enforced server-side at click — buttons are always visually present, but clicks from disallowed users get an ephemeral "this button isn't for you."

Buttons are not hidden visually based on visible_to because Discord doesn't support per-user button visibility on a shared message. This is a deliberate limitation.

Permission model — buttons vs commands

command_security (the existing config gating which roles can run which commands) applies to commands but not buttons:

  • !mute / !unmute honor command_security like any other command. Operators can restrict mute commands to specific roles.
  • Button clicks (action = "mute" etc.) bypass command_security. The DTS attachment is the operator's authorization decision — by including a button in a template, the operator is saying "anyone passing visible_to may use this." Layering command_security on top would create surprising behaviour (button visible but unclickable for users who can't run the equivalent command).

Click-time gating for buttons is applies_to + visible_to only. Action handlers may additionally enforce "the clicker must be the snapshot's Target" for destructive actions (mute, unsubscribe), but this is per-handler policy, not command_security.

Custom_id format

Since each delivery has its own snapshot keyed by messageID, the custom_id only needs to identify the message and the button:

poracle:btn:<messageID>:<actionID>
  • messageID — Discord snowflake, ~19 chars.
  • actionID — short token matching the button's id in DTS (8–16 chars in practice).
  • Total ~40 chars; comfortable headroom against Discord's 100-char limit.

Stateless on Discord's side. The button definition is looked up from currently-loaded DTS at click time — if the operator removed or renamed it via DTS reload, respond ephemerally with "this button is no longer available." No server-side button registry needed.

Telegram

Telegram inline keyboards work very differently from Discord buttons — callback_data is capped at 64 bytes, the UX patterns are different, and it's not clear whether the same use cases hold for Telegram users.

Rather than spec a Telegram variant in this issue, #112 invites end-user input on what Telegram button UX should look like and whether there's demand. If Telegram parity lands, it'll be designed against that input rather than retrofitted onto the Discord shape.

Telegram operators see no buttons in v1. The mute/unsubscribe commands still work for them — those don't depend on buttons.

Canonical error messages

User-facing strings on click-time failures. All go through the i18n bundle (msg.button.*) so translators can pick them up:

Key Default English When
msg.button.expired "This alert has expired." Snapshot not found (TTL expired, never written, or snapshot store error).
msg.button.unavailable "This button is no longer available." Button definition removed from DTS since the alert fired.
msg.button.wrong_target "This button doesn't apply here." applies_to mismatch (defence-in-depth; shouldn't normally fire because such buttons aren't rendered).
msg.button.unauthorized "This button isn't for you." visible_to check failed for the clicker.
msg.button.action_failed "Couldn't complete that action: {0}" Action handler returned an error; placeholder filled with the handler's message.
msg.button.cooldown "Slow down — try that again in a moment." Per-user click cooldown hit (default 5s).

The keys land before the buttons feature ships, so translators have them in their next bundle rather than being chased after first locale ticket arrives.

Response template lookup

Response templates referenced by response_template_id are looked up using the existing 6-priority DTS selection chain, with one new template type: type = "buttonResponse".

# A shared response template — referenced from buttons via response_template_id = "coordinates"
[[entry]]
id = "coordinates"
type = "buttonResponse"
platform = "discord"
language = "en"
description = "Generic coordinates card"

template = """..."""

Lookup at click time uses (type="buttonResponse", id=<the ref>, platform=Snapshot.Platform, language=Snapshot.Language). Same selection chain (language fallback → platform fallback → configured-locale fallback → any-language fallback) as alert templates. No new resolution machinery needed.

Alert-type-specific response templates — when a response template only makes sense for one alert type (e.g. a PVP details template that depends on monster fields), the operator has two options:

  1. Namespace by id: id = "monster_pvp_details" — clear naming convention, no schema change.
  2. Inline: use response_template_inline = """...""" in the button itself (see Optional TOML format for DTS templates #110) — template lives with its trigger, no separate entry needed.

No magic alert-type scoping in the schema. Convention or inlining handles it.

Exact-template re-render

Snapshot.TemplateSelected lets a button re-render the exact template the user originally saw, even if the operator has changed the default template chain since. Snapshot.TemplateRequested is also available for callers that want to re-resolve through the selection chain instead — useful if the alert hit a fallback at original render time and the operator has since fixed the gap.

Use cases:

  • "Re-send this alert" / "Send to my DM" buttons — render the same view the channel saw, deliver to the clicker's DM.
  • "Refresh this alert" — re-render with current enrichment (RSVPs, etc.), edit the original message.
  • "Compare with current" — render both the snapshotted view and a freshly-enriched view side by side (debugging aid).

The full template selection chain still runs as a fallback if (TemplateType, TemplateSelected) is no longer present — graceful degradation rather than hard failure.

Configuration

No new top-level config section in v1. Defaults coded in:

  • Mute default duration: 1h when the user omits duration: on a command (or params.duration_min on a button).
  • Click cooldown: 5s per (clicker, message, button) to prevent accidental double-fires.

If operators want tuning knobs later, they fit naturally under a [buttons] section. For v1, defaults are enough.

Metrics

The key operator question is "which buttons are people actually using" — supports template iteration. Surface per-template click counts:

  • poracle_button_clicks_total{template_type, template_id, button_id, result="ok|expired|unauthorized|error"} — main counter for operator visibility into button usage.
  • poracle_button_actions_total{action, result="ok|error"} — action handler outcomes (mute, unsubscribe, redeliver, render).
  • poracle_mute_entries_active — gauge of in-memory mute count.
  • poracle_mute_hits_total{scope} — counter of alerts dropped during filterMuted.

Open questions

  • Per-user rate limiting for clicks. Button responses don't count against the alert bucket. Need at least a per-user per-message click cooldown (e.g. 1 click per button per 5s) to prevent accidental double-clicks and intentional spam.
  • Action handler authentication beyond visible_to / applies_to. For destructive actions (mute, unsubscribe), require the clicker to be the snapshot's Target even if visible_to is broader. v1: yes, per-action policy in the handler.
  • Multi-value scope disambiguation. When scope = "tracking" and Snapshot.TrackingUIDs has 3 entries (basic + great + ultra PVP), apply-to-all is the v1 default with confirmation in the ephemeral response listing the affected rules. Follow-up menu (drill-down) deferred.
  • Discord button limits. 5 buttons per row, 5 rows per message = 25 buttons max. Practical UX limit is much lower. Document operator-facing guidance.
  • Discoverability. /api/dts/fields/{type} already lists fields. Should it also list available actions and their params? Probably yes, with a new /api/dts/actions endpoint enumerating registered action handlers.
  • Mute default duration. What's a sensible default when !mute pokemon pikachu has no explicit duration? Probably 1h, configurable.

Sequencing

Branch dependency: This work builds on the slash-commands-design and raid-rsvp branches; both merge before this lands. Slash forms of !mute / !unmute integrate with whatever slash-command infrastructure exists at merge time.

  1. Snapshot store (Snapshot enrichment store for post-alert rendering #108) lands first.
  2. Mute infrastructure: in-memory mute map, matcher integration, background sweep. Can land before buttons — it has the command-line surface as its first consumer.
  3. !mute / !unmute commands (plus slash equivalents) wired against the mute map. !tracked extended to display active mutes alongside tracking rules.
  4. DTS schema extension: parse buttons[] in JSON entries; expose via /api/dts/templates payloads. Validation: exactly one of response-field / action, valid scope/visibility/applies_to values, show_if parses as Handlebars.
  5. Render path: emit Discord components block when buttons are configured for the resolved template, filtered by applies_to and show_if.
  6. InteractionCreate handler in internal/discordbot/ → snapshot lookup → render or action dispatch → ephemeral reply.
  7. Action handler registry in internal/buttonactions/ with mute, unsubscribe, render, redeliver actions.
  8. buttonResponse template type recognised by the loader; response template lookup at click time.
  9. Operator docs + a worked example (probably "Show PVP details" button on raid template).
  10. (Later) TOML DTS support (Optional TOML format for DTS templates #110) for cleaner button declaration.
  11. (Later) Telegram parity (separate milestone).
  12. (Later, v2) Buttons in response messages (drill-down menus combining mute + unsubscribe + duration choices).

Out of scope (for v1)

  • Persisted mutes (in-memory only; restart clears them).
  • Buttons attached to response messages (drill-down UX combining mute/unsubscribe choices).
  • Telegram inline keyboards (separate follow-up).
  • Cross-message persistence (a button that summarises the last 10 alerts).
  • Hot-tier caching on snapshot reads.

Dependencies

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions