Skip to content

Commit 4e1ee4b

Browse files
feat(notifications): add opt-in Discord webhook via 'gac discord' subcommands (#76)
* feat(notifications): add Discord webhook integration for commit notifications - Introduce discord_webhook module with embed-style notifications featuring repo name, branch, commit hash, and message in a GitHub-style card format - Add interactive Discord webhook configuration to gac init workflow with options to keep, replace, or remove existing webhook URLs - Integrate webhook notifications into both single and grouped commit workflows - Implement graceful error handling: network failures are logged but never block successful commits - Support configurable timeout (5 seconds) and environment-based URL storage via GAC_DISCORD_WEBHOOK_URL * test(notifications): add comprehensive tests for Discord webhook feature - Add unit tests for webhook URL parsing, embed building, and message truncation logic - Test HTTP error handling to verify failures are swallowed gracefully - Test GitError handling to ensure webhook falls back to sensible defaults - Update existing init CLI tests to account for new Discord webhook prompt in the configuration flow - Verify embed payload structure matches Discord API requirements * docs: document Discord webhook notifications and bump version to 3.36.0 - Add Discord Webhook Notifications section to USAGE.md with setup instructions and behavior details - Document GAC_DISCORD_WEBHOOK_URL environment variable configuration - Note that webhook failures are logged at WARNING level and never block commits - Update CHANGELOG with v3.36.0 release notes describing the new feature - Bump version from 3.35.2 to 3.36.0 * refactor(discord): move Discord setup from gac init to dedicated gac discord subcommands Per review feedback, gac init is restored to upstream and Discord webhook configuration now lives in a dedicated 'gac discord' command group: - gac discord setup interactively configure the webhook URL - gac discord show display whether a webhook is configured (masked) - gac discord test send a test notification - gac discord remove delete the configured URL This keeps the init flow unchanged, gives Discord its own ergonomic home, and adds an explicit 'test' command for users to verify their setup without making a real commit.
1 parent dd11c7b commit 4e1ee4b

10 files changed

Lines changed: 638 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
> _Generated by [KittyLog](https://kittylog.app)_
44

5+
## [v3.36.0] - Unreleased
6+
7+
### Added
8+
9+
- Discord webhook notifications: configure `GAC_DISCORD_WEBHOOK_URL` and gac posts a GitHub-style embed to your Discord channel after every successful commit. Works for both single and grouped commit workflows. Webhook failures are logged but never block your commit.
10+
- New `gac discord` subcommand group: `gac discord setup`, `gac discord show`, `gac discord test`, and `gac discord remove`. `gac init` is unchanged — Discord setup lives only under `gac discord`.
11+
512
## [v3.26.0] - 2026-05-06
613

714
### Added

docs/en/USAGE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,37 @@ When disabled, gac skips all stats recording — no file reads or writes occur.
633633

634634
---
635635

636+
## Discord Webhook Notifications
637+
638+
gac can ping a Discord channel every time you land a commit, using a webhook URL from your channel's integration settings. The integration is **opt-in**: it does nothing until you explicitly configure a webhook URL.
639+
640+
### Setup
641+
642+
Use the dedicated `discord` subcommand group:
643+
644+
```bash
645+
uvx gac discord setup # interactively configure a webhook URL
646+
uvx gac discord show # show whether a webhook is configured (URL masked)
647+
uvx gac discord test # send a test notification to the configured webhook
648+
uvx gac discord remove # remove the configured webhook URL
649+
```
650+
651+
Alternatively, set the variable directly in `$HOME/.gac.env` (or `./.gac.env`):
652+
653+
```bash
654+
GAC_DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/XXXX/YYYY'
655+
```
656+
657+
### Behavior
658+
659+
- Fires after each successful commit (single and grouped workflows). Skipped on `--dry-run` and `--message-only`.
660+
- Posts a GitHub-style **embed** with a green stripe, repo + branch as the author row, the commit subject as the title, the commit body as the description, and the short SHA in the footer.
661+
- Uses the gac avatar and the username `gac`.
662+
- Webhook failures are logged at WARNING and **never** block your commit.
663+
- Leave `GAC_DISCORD_WEBHOOK_URL` unset (or blank) to disable. `gac init` is unaffected — Discord setup lives only under `gac discord`.
664+
665+
---
666+
636667
## Getting Help
637668

638669
- For MCP server setup (AI agent integration), see [docs/MCP.md](MCP.md)

src/gac/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Version information for gac package."""
22

3-
__version__ = "3.35.2"
3+
__version__ = "3.36.0"

src/gac/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from gac.config_cli import config as config_cli
1818
from gac.constants import Languages, Logging
1919
from gac.diff_cli import diff as diff_cli
20+
from gac.discord_cli import discord as discord_cli
2021
from gac.editor_cli import editor as editor_cli
2122
from gac.errors import handle_error
2223
from gac.init_cli import init as init_cli
@@ -234,6 +235,7 @@ def cli(
234235
cli.add_command(auth_cli)
235236
cli.add_command(config_cli)
236237
cli.add_command(diff_cli)
238+
cli.add_command(discord_cli)
237239
cli.add_command(init_cli)
238240
cli.add_command(editor_cli)
239241
cli.add_command(language_cli)

src/gac/discord_cli.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""CLI for managing the Discord webhook integration.
2+
3+
Subcommands:
4+
- ``gac discord setup`` — interactively configure (or replace) the webhook URL.
5+
- ``gac discord remove`` — delete the webhook URL from $HOME/.gac.env.
6+
- ``gac discord show`` — display whether a webhook is configured (URL masked).
7+
- ``gac discord test`` — send a test notification to the configured webhook.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import os
13+
from pathlib import Path
14+
15+
import click
16+
import questionary
17+
from dotenv import load_dotenv, set_key, unset_key
18+
19+
from gac.discord_webhook import ENV_KEY, notify_commit
20+
21+
GAC_ENV_PATH = Path.home() / ".gac.env"
22+
23+
24+
def _mask_url(url: str) -> str:
25+
"""Return a masked preview of a URL so it's safe to display."""
26+
if len(url) <= 30:
27+
return url
28+
return f"{url[:30]}…"
29+
30+
31+
def _load_existing_url() -> str | None:
32+
"""Read the current webhook URL from $HOME/.gac.env, if any."""
33+
if not GAC_ENV_PATH.exists():
34+
return None
35+
load_dotenv(GAC_ENV_PATH, override=True)
36+
value = os.getenv(ENV_KEY)
37+
if value is None:
38+
return None
39+
value = value.strip()
40+
return value or None
41+
42+
43+
def _prompt_for_url() -> str | None:
44+
"""Prompt the user for a webhook URL. Returns None on cancel / invalid."""
45+
response = questionary.text("Discord webhook URL:").ask()
46+
if response is None:
47+
click.echo("Cancelled.")
48+
return None
49+
url: str = str(response).strip()
50+
if not url:
51+
click.echo("No URL provided. Cancelled.")
52+
return None
53+
if not url.startswith(("http://", "https://")):
54+
click.echo("That doesn't look like a URL (must start with http:// or https://).")
55+
return None
56+
return url
57+
58+
59+
def _save_url(url: str) -> None:
60+
"""Persist the webhook URL to $HOME/.gac.env."""
61+
GAC_ENV_PATH.touch(exist_ok=True)
62+
set_key(str(GAC_ENV_PATH), ENV_KEY, url)
63+
os.environ[ENV_KEY] = url # keep current process in sync, e.g. for `discord test`
64+
click.echo("Discord webhook saved.")
65+
66+
67+
@click.group()
68+
def discord() -> None:
69+
"""Manage the Discord webhook integration for commit notifications."""
70+
pass
71+
72+
73+
@discord.command()
74+
def setup() -> None:
75+
"""Interactively configure a Discord webhook URL."""
76+
click.echo("Discord Webhook Setup")
77+
click.echo(
78+
"gac can ping a Discord channel every time you make a commit, using a\n"
79+
"webhook URL from your channel's integration settings.\n"
80+
)
81+
82+
existing = _load_existing_url()
83+
if existing:
84+
choice = questionary.select(
85+
f"A Discord webhook is already configured ({_mask_url(existing)}). What now?",
86+
choices=[
87+
"Keep current webhook",
88+
"Replace with a new webhook URL",
89+
"Remove the webhook",
90+
],
91+
use_shortcuts=True,
92+
use_arrow_keys=True,
93+
use_jk_keys=False,
94+
).ask()
95+
96+
if choice is None or choice.startswith("Keep"):
97+
click.echo("Keeping existing Discord webhook.")
98+
return
99+
if choice.startswith("Remove"):
100+
unset_key(str(GAC_ENV_PATH), ENV_KEY)
101+
os.environ.pop(ENV_KEY, None)
102+
click.echo("Removed Discord webhook.")
103+
return
104+
# Otherwise fall through to prompt for a new URL.
105+
106+
url = _prompt_for_url()
107+
if url is None:
108+
return
109+
_save_url(url)
110+
111+
112+
@discord.command()
113+
def remove() -> None:
114+
"""Remove the configured Discord webhook URL."""
115+
existing = _load_existing_url()
116+
if existing is None:
117+
click.echo("No Discord webhook is currently configured.")
118+
return
119+
unset_key(str(GAC_ENV_PATH), ENV_KEY)
120+
os.environ.pop(ENV_KEY, None)
121+
click.echo("Removed Discord webhook.")
122+
123+
124+
@discord.command()
125+
def show() -> None:
126+
"""Display whether a Discord webhook is configured (URL masked)."""
127+
existing = _load_existing_url()
128+
if existing is None:
129+
click.echo("No Discord webhook configured.")
130+
click.echo("Run 'uvx gac discord setup' to configure one.")
131+
return
132+
click.echo(f"Discord webhook configured: {_mask_url(existing)}")
133+
134+
135+
@discord.command()
136+
def test() -> None:
137+
"""Send a test notification to the configured Discord webhook."""
138+
existing = _load_existing_url()
139+
if existing is None:
140+
click.echo("No Discord webhook configured.")
141+
click.echo("Run 'uvx gac discord setup' to configure one first.")
142+
return
143+
144+
test_message = (
145+
"test: 🐶 Biscuit barking from gac discord test\n\n"
146+
"If you can see this in your Discord channel, the webhook is wired up correctly!"
147+
)
148+
if notify_commit(test_message):
149+
click.echo("✓ Test notification sent successfully.")
150+
else:
151+
click.echo("✗ Failed to send test notification. Check the URL and your network.")

src/gac/discord_webhook.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Discord webhook notifications for successful gac commits.
2+
3+
Posts a polished embed-style notification to a user-configured Discord webhook
4+
every time a commit lands. Configuration lives in ``$HOME/.gac.env`` under the
5+
``GAC_DISCORD_WEBHOOK_URL`` key and is set up via ``gac init``.
6+
7+
If the URL is unset, every function here is a no-op. Network failures are
8+
swallowed (logged at warning level) so we never block a successful commit on a
9+
flaky webhook.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import os
16+
from collections.abc import Callable
17+
from typing import Any
18+
19+
import httpx
20+
21+
from gac.errors import GitError
22+
from gac.git import get_commit_hash, get_current_branch, get_repo_root
23+
24+
logger = logging.getLogger(__name__)
25+
26+
# Provided by the user — the cute lil' emoji avatar for the webhook.
27+
GAC_AVATAR_URL = "https://cdn.discordapp.com/emojis/1444427616160321679.webp"
28+
GAC_USERNAME = "gac"
29+
WEBHOOK_TIMEOUT_SECONDS = 5.0
30+
ENV_KEY = "GAC_DISCORD_WEBHOOK_URL"
31+
32+
# A pleasant green for "commit succeeded" (matches GitHub's merge-green vibe).
33+
EMBED_COLOR = 0x2DA44E
34+
35+
# Discord embed field limits (per official docs).
36+
_MAX_TITLE_LEN = 256
37+
_MAX_DESCRIPTION_LEN = 4096
38+
39+
40+
def _repo_name() -> str:
41+
"""Best-effort repo name from the repo root; falls back to ``unknown``."""
42+
try:
43+
return os.path.basename(get_repo_root()) or "unknown"
44+
except GitError:
45+
return "unknown"
46+
47+
48+
def _safe_git(call: Callable[[], str], default: str = "unknown") -> str:
49+
"""Call a git helper; on GitError return the default. Don't block commits."""
50+
try:
51+
return call()
52+
except GitError:
53+
return default
54+
55+
56+
def _truncate(text: str, limit: int) -> str:
57+
"""Truncate text to ``limit`` chars, appending an ellipsis if cut."""
58+
if len(text) <= limit:
59+
return text
60+
return text[: limit - 1] + "…"
61+
62+
63+
def _split_subject_body(commit_message: str) -> tuple[str, str]:
64+
"""Split a commit message into (subject, body) using the conventional blank-line rule."""
65+
stripped = commit_message.strip()
66+
if not stripped:
67+
return "", ""
68+
parts = stripped.split("\n", 1)
69+
subject = parts[0].strip()
70+
body = parts[1].strip() if len(parts) > 1 else ""
71+
return subject, body
72+
73+
74+
def _build_embed(commit_message: str) -> dict[str, Any]:
75+
"""Build a Discord embed payload that mimics GitHub-style commit cards."""
76+
repo = _repo_name()
77+
branch = _safe_git(get_current_branch)
78+
full_hash = _safe_git(get_commit_hash, default="")
79+
short_hash = full_hash[:7] if full_hash else "unknown"
80+
81+
subject, body = _split_subject_body(commit_message)
82+
title = _truncate(subject or "(no subject)", _MAX_TITLE_LEN)
83+
description = _truncate(body, _MAX_DESCRIPTION_LEN) if body else ""
84+
85+
embed: dict[str, Any] = {
86+
"title": title,
87+
"color": EMBED_COLOR,
88+
"author": {"name": f"{repo} · {branch}", "icon_url": GAC_AVATAR_URL},
89+
"footer": {"text": f"commit {short_hash}"},
90+
}
91+
if description:
92+
embed["description"] = description
93+
return embed
94+
95+
96+
def get_webhook_url() -> str | None:
97+
"""Return the configured webhook URL, or None if not set / blank."""
98+
raw = os.getenv(ENV_KEY)
99+
if raw is None:
100+
return None
101+
value = raw.strip()
102+
return value or None
103+
104+
105+
def notify_commit(commit_message: str) -> bool:
106+
"""Send a commit notification to the configured Discord webhook.
107+
108+
Returns ``True`` if a message was sent successfully, ``False`` otherwise
109+
(including when no webhook is configured). Never raises.
110+
"""
111+
url = get_webhook_url()
112+
if not url:
113+
return False
114+
115+
payload = {
116+
"username": GAC_USERNAME,
117+
"avatar_url": GAC_AVATAR_URL,
118+
"embeds": [_build_embed(commit_message)],
119+
}
120+
121+
try:
122+
response = httpx.post(url, json=payload, timeout=WEBHOOK_TIMEOUT_SECONDS)
123+
response.raise_for_status()
124+
except httpx.HTTPError as e:
125+
logger.warning("Discord webhook notification failed: %s", e)
126+
return False
127+
return True

src/gac/grouped_commit_executor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import subprocess
1616
from typing import Any, NamedTuple
1717

18+
from gac.discord_webhook import notify_commit as notify_commit_discord
1819
from gac.errors import AIError, ConfigError, GitError
1920
from gac.git import detect_rename_mappings, get_staged_files, run_git_command
2021
from gac.postprocess import clean_commit_message
@@ -88,6 +89,7 @@ def execute_grouped_commits(
8889
)
8990
execute_commit(cleaned_message, no_verify, hook_timeout, signoff)
9091
record_commit(model=model)
92+
notify_commit_discord(cleaned_message)
9193
console.print(f"[green]✓ Commit {idx}/{num_commits} created[/green]")
9294
except (AIError, ConfigError, GitError, subprocess.SubprocessError, OSError) as e:
9395
restore_needed = True

src/gac/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from gac.ai_utils import count_tokens
1010
from gac.commit_executor import CommitExecutor
1111
from gac.config import GACConfig
12+
from gac.discord_webhook import notify_commit as notify_commit_discord
1213
from gac.errors import AIError, ConfigError, handle_error
1314
from gac.git import run_lefthook_hooks, run_pre_commit_hooks
1415
from gac.git_state_validator import GitStateValidator
@@ -134,6 +135,7 @@ def _execute_single_commit_workflow(ctx: WorkflowContext, config: GACConfig) ->
134135
if not ctx.flags.dry_run:
135136
record_commit(model=ctx.model)
136137
record_gac(model=ctx.model, files=len(ctx.git_state.staged_files))
138+
notify_commit_discord(commit_message)
137139

138140
# Push if requested
139141
if ctx.flags.push:

0 commit comments

Comments
 (0)