gh2discord 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ name: publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: python -m pip install build
16
+ - run: python -m build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,18 @@
1
+ name: tests
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ strategy:
8
+ matrix:
9
+ os: [ubuntu-latest, windows-latest]
10
+ python: ["3.9", "3.12"]
11
+ runs-on: ${{ matrix.os }}
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: ${{ matrix.python }}
17
+ - run: python -m pip install -e ".[dev]"
18
+ - run: python -m pytest tests/ -q
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ozan Eski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: gh2discord
3
+ Version: 0.1.0
4
+ Summary: One-command GitHub to Discord notifications: manage repo webhooks that post straight to your Discord channels.
5
+ Project-URL: Homepage, https://github.com/ozaneski13/gh2discord
6
+ Project-URL: Issues, https://github.com/ozaneski13/gh2discord/issues
7
+ Author: Ozan Eski
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,discord,github,notifications,webhook
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Communications :: Chat
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.9
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # gh2discord
25
+
26
+ Wire GitHub repos to Discord channels with one command.
27
+
28
+ Discord can render GitHub webhook payloads natively — no bot, no middleware.
29
+ The only friction is the manual setup: create a Discord webhook, copy the URL,
30
+ open every repo's settings, paste the URL with a magic `/github` suffix.
31
+ **gh2discord** automates the GitHub side and remembers your Discord channels,
32
+ so tracking a new repo becomes:
33
+
34
+ ```bash
35
+ gh2discord track owner/repo
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pipx install gh2discord # recommended
42
+ # or
43
+ pip install gh2discord
44
+ ```
45
+
46
+ Zero runtime dependencies. Python 3.9+.
47
+
48
+ ## Setup (once)
49
+
50
+ 1. In Discord: **Channel settings → Integrations → Webhooks → New Webhook**,
51
+ copy the webhook URL.
52
+ 2. Register it:
53
+
54
+ ```bash
55
+ gh2discord channel add general https://discord.com/api/webhooks/<id>/<token> --default
56
+ ```
57
+
58
+ 3. Authenticate with GitHub — any one of:
59
+ - `gh` CLI already logged in (nothing to do; the token is picked up automatically), or
60
+ - `GITHUB_TOKEN` / `GH_TOKEN` environment variable.
61
+ - Classic PAT: `repo` or `admin:repo_hook` scope.
62
+ - Fine-grained PAT: **Webhooks (write)** + **Metadata (read)** on the repos you track.
63
+
64
+ You can only track repos you administer — GitHub allows webhook management
65
+ for repo admins only.
66
+
67
+ ## Usage
68
+
69
+ ```bash
70
+ gh2discord track owner/repo # default channel, all events
71
+ gh2discord track owner/repo --events push,release,issues,pull_request
72
+ gh2discord track owner/repo --channel alerts # per-repo channel
73
+ gh2discord list # tracked repos
74
+ gh2discord status # live hook health (last delivery)
75
+ gh2discord ping owner/repo # end-to-end delivery test
76
+ gh2discord untrack owner/repo # remove the hook
77
+ ```
78
+
79
+ `track` is idempotent: run it twice and you get one hook, not two. It also
80
+ repairs broken hooks in place (wrong content type, disabled, or stale events).
81
+ If the repo already has Discord hooks pointing at *different* webhooks,
82
+ gh2discord warns and leaves them alone unless you pass `--force`, which
83
+ retargets one and deletes the rest.
84
+
85
+ Multiple channels work without any bot: register each channel's webhook once
86
+ (`channel add`), then route repos with `--channel`.
87
+
88
+ ```bash
89
+ gh2discord channel add releases https://discord.com/api/webhooks/<id>/<token>
90
+ gh2discord track owner/lib --channel releases --events release
91
+ ```
92
+
93
+ ## Notes
94
+
95
+ - Config lives at `%APPDATA%\gh2discord\config.json` (Windows) or
96
+ `~/.config/gh2discord/config.json` (Linux/macOS). Discord webhook URLs are
97
+ secrets — the file is written with owner-only permissions on POSIX; don't
98
+ commit it anywhere.
99
+ - `--events all` (the default) sends everything GitHub emits; Discord silently
100
+ ignores event types it can't render. Use an explicit list for quieter channels.
101
+ - GitHub returns 404 (not 403) when your token lacks webhook access to a repo —
102
+ if a repo you own reports "not found", check your token scopes first.
103
+
104
+ ## Roadmap
105
+
106
+ - `--bot` mode: create the Discord channel + webhook automatically (bot token).
107
+ - Polling mode for repos you don't administer (releases/commits feeds).
108
+ - Org-wide webhook helper for organization accounts.
109
+
110
+ ## License & Attribution
111
+
112
+ MIT © [Ozan Eski](https://github.com/ozaneski13) — free for everyone and every
113
+ use, personal or commercial. The one requirement (per the MIT license terms):
114
+ if you copy, modify, or redistribute this code or a substantial portion of it,
115
+ you must keep the copyright notice — credit **Ozan Eski / gh2discord** and
116
+ link back to this repository.
@@ -0,0 +1,93 @@
1
+ # gh2discord
2
+
3
+ Wire GitHub repos to Discord channels with one command.
4
+
5
+ Discord can render GitHub webhook payloads natively — no bot, no middleware.
6
+ The only friction is the manual setup: create a Discord webhook, copy the URL,
7
+ open every repo's settings, paste the URL with a magic `/github` suffix.
8
+ **gh2discord** automates the GitHub side and remembers your Discord channels,
9
+ so tracking a new repo becomes:
10
+
11
+ ```bash
12
+ gh2discord track owner/repo
13
+ ```
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pipx install gh2discord # recommended
19
+ # or
20
+ pip install gh2discord
21
+ ```
22
+
23
+ Zero runtime dependencies. Python 3.9+.
24
+
25
+ ## Setup (once)
26
+
27
+ 1. In Discord: **Channel settings → Integrations → Webhooks → New Webhook**,
28
+ copy the webhook URL.
29
+ 2. Register it:
30
+
31
+ ```bash
32
+ gh2discord channel add general https://discord.com/api/webhooks/<id>/<token> --default
33
+ ```
34
+
35
+ 3. Authenticate with GitHub — any one of:
36
+ - `gh` CLI already logged in (nothing to do; the token is picked up automatically), or
37
+ - `GITHUB_TOKEN` / `GH_TOKEN` environment variable.
38
+ - Classic PAT: `repo` or `admin:repo_hook` scope.
39
+ - Fine-grained PAT: **Webhooks (write)** + **Metadata (read)** on the repos you track.
40
+
41
+ You can only track repos you administer — GitHub allows webhook management
42
+ for repo admins only.
43
+
44
+ ## Usage
45
+
46
+ ```bash
47
+ gh2discord track owner/repo # default channel, all events
48
+ gh2discord track owner/repo --events push,release,issues,pull_request
49
+ gh2discord track owner/repo --channel alerts # per-repo channel
50
+ gh2discord list # tracked repos
51
+ gh2discord status # live hook health (last delivery)
52
+ gh2discord ping owner/repo # end-to-end delivery test
53
+ gh2discord untrack owner/repo # remove the hook
54
+ ```
55
+
56
+ `track` is idempotent: run it twice and you get one hook, not two. It also
57
+ repairs broken hooks in place (wrong content type, disabled, or stale events).
58
+ If the repo already has Discord hooks pointing at *different* webhooks,
59
+ gh2discord warns and leaves them alone unless you pass `--force`, which
60
+ retargets one and deletes the rest.
61
+
62
+ Multiple channels work without any bot: register each channel's webhook once
63
+ (`channel add`), then route repos with `--channel`.
64
+
65
+ ```bash
66
+ gh2discord channel add releases https://discord.com/api/webhooks/<id>/<token>
67
+ gh2discord track owner/lib --channel releases --events release
68
+ ```
69
+
70
+ ## Notes
71
+
72
+ - Config lives at `%APPDATA%\gh2discord\config.json` (Windows) or
73
+ `~/.config/gh2discord/config.json` (Linux/macOS). Discord webhook URLs are
74
+ secrets — the file is written with owner-only permissions on POSIX; don't
75
+ commit it anywhere.
76
+ - `--events all` (the default) sends everything GitHub emits; Discord silently
77
+ ignores event types it can't render. Use an explicit list for quieter channels.
78
+ - GitHub returns 404 (not 403) when your token lacks webhook access to a repo —
79
+ if a repo you own reports "not found", check your token scopes first.
80
+
81
+ ## Roadmap
82
+
83
+ - `--bot` mode: create the Discord channel + webhook automatically (bot token).
84
+ - Polling mode for repos you don't administer (releases/commits feeds).
85
+ - Org-wide webhook helper for organization accounts.
86
+
87
+ ## License & Attribution
88
+
89
+ MIT © [Ozan Eski](https://github.com/ozaneski13) — free for everyone and every
90
+ use, personal or commercial. The one requirement (per the MIT license terms):
91
+ if you copy, modify, or redistribute this code or a substantial portion of it,
92
+ you must keep the copyright notice — credit **Ozan Eski / gh2discord** and
93
+ link back to this repository.
@@ -0,0 +1,129 @@
1
+ # gh2discord — Design
2
+
3
+ **Date:** 2026-08-04
4
+ **Status:** Approved (v0.1 scope)
5
+
6
+ ## Goal
7
+
8
+ One-command setup for GitHub → Discord notifications. Today the flow is manual:
9
+ create a Discord channel webhook, copy its URL, open the GitHub repo settings,
10
+ add a webhook pointing at `<discord-url>/github`. gh2discord automates the
11
+ GitHub side and remembers Discord channels, so tracking a new repo becomes:
12
+
13
+ ```
14
+ gh2discord track owner/repo
15
+ ```
16
+
17
+ Distributable tool (PyPI), not a personal script. Anyone with a GitHub token
18
+ and a Discord webhook can use it against their own repos and servers.
19
+
20
+ ## Non-goals (v0.1)
21
+
22
+ - Tracking repos the user does not administer (would require polling; out of scope).
23
+ - Creating Discord channels/webhooks via bot token (roadmap, phase 2).
24
+ - A hosted service or GitHub App. This is a local CLI.
25
+
26
+ ## Mechanism
27
+
28
+ Discord natively renders GitHub webhook payloads when the GitHub webhook
29
+ targets `https://discord.com/api/webhooks/<id>/<token>/github`. No middleware
30
+ is needed. The tool only manages GitHub repository webhooks via the REST API.
31
+
32
+ ## CLI surface
33
+
34
+ ```
35
+ gh2discord channel add <name> <discord-webhook-url> [--default]
36
+ gh2discord channel list
37
+ gh2discord channel remove <name>
38
+ gh2discord channel default <name>
39
+ gh2discord track <owner/repo> [--channel <name>] [--events e1,e2 | --events all] [--force]
40
+ gh2discord untrack <owner/repo>
41
+ gh2discord list
42
+ gh2discord status [<owner/repo>]
43
+ gh2discord ping <owner/repo>
44
+ ```
45
+
46
+ - `track` is idempotent: if the repo already has a hook pointing at the target
47
+ URL with `content_type: json`, correct events, and active state, nothing is
48
+ written; otherwise the hook is repaired in place (PATCH) instead of creating
49
+ a duplicate. Any *other* hook targeting a Discord webhook (with or without
50
+ the `/github` suffix — a bare Discord URL is always a broken GitHub hook)
51
+ counts as a conflict: the tool warns and aborts unless `--force`, which
52
+ retargets the first conflicting hook and deletes the rest. When the exact
53
+ hook already exists alongside extra Discord hooks, `track` reports them as
54
+ a warning and `--force` deletes them.
55
+ - `--events` defaults to `all` (GitHub `*`; Discord silently ignores event
56
+ types it cannot render).
57
+ - `untrack` deletes the hook (matched by recorded id, falling back to target
58
+ URL) and removes the local record. If no matching hook exists on GitHub,
59
+ the local record is still cleared (persisted) and the command exits 1.
60
+ - `status` reads live hook state incl. `last_response` (delivery health).
61
+ - `ping` triggers GitHub's webhook ping to verify delivery end to end.
62
+
63
+ ## Auth
64
+
65
+ Token resolution order: `GITHUB_TOKEN` → `GH_TOKEN` → `gh auth token`
66
+ (subprocess, if gh CLI is installed). No token storage by the tool.
67
+ Required: classic PAT with `repo` (or `admin:repo_hook`) scope, or
68
+ fine-grained PAT with repository **Webhooks: write** + **Metadata: read**.
69
+
70
+ ## Config
71
+
72
+ JSON at `%APPDATA%\gh2discord\config.json` (Windows) or
73
+ `$XDG_CONFIG_HOME/gh2discord/config.json` → `~/.config/gh2discord/config.json`
74
+ (POSIX, written with mode 0600 — Discord webhook URLs are secrets).
75
+
76
+ ```json
77
+ {
78
+ "version": 1,
79
+ "default_channel": "general",
80
+ "channels": { "general": "https://discord.com/api/webhooks/123/abc" },
81
+ "repos": { "owner/repo": { "channel": "general", "hook_id": 42, "events": ["*"] } }
82
+ }
83
+ ```
84
+
85
+ Channel URLs are stored canonically **without** the `/github` suffix (accepted
86
+ and stripped on input); the suffix is appended when creating GitHub hooks.
87
+ GitHub is the source of truth for hook state; `repos` is a local record that
88
+ `status`/`untrack` reconcile against live data.
89
+
90
+ Per-repo channels already work in v0.1: register additional channels with
91
+ `channel add`, then `track --channel <name>`. No Discord bot required.
92
+
93
+ ## Modules (SRP)
94
+
95
+ - `config.py` — load/save/validate config, path resolution. No network.
96
+ - `github.py` — token resolution + thin GitHub REST client (urllib), hooks
97
+ CRUD, pagination. No config knowledge.
98
+ - `commands.py` — business logic (track/untrack/status/ping/channel ops)
99
+ composing config + github. Returns structured results.
100
+ - `cli.py` — argparse, dispatch, human-readable output, exit codes.
101
+
102
+ Zero runtime dependencies (stdlib only). Python ≥ 3.9. Tests: pytest with a
103
+ fake transport injected into the client (no network in unit tests).
104
+
105
+ ## Error UX
106
+
107
+ - 401 → GitHub's message plus a token hint (set GITHUB_TOKEN/GH_TOKEN or
108
+ `gh auth login`).
109
+ - 404 on hooks endpoints → "repo not found **or** token lacks admin/webhook
110
+ access" (GitHub returns 404, not 403, for missing hook permissions).
111
+ - 3xx → repository renamed/transferred; redirects are never silently followed.
112
+ - 422 → invalid event name(s), echo GitHub's message.
113
+ - Console output degrades to `?` instead of crashing on legacy Windows code
114
+ pages (`reconfigure(errors="replace")`).
115
+ - Missing token / missing default channel → actionable one-line fix.
116
+
117
+ Exit codes: 0 ok, 1 API/config error, 2 usage error (argparse default).
118
+
119
+ ## Packaging
120
+
121
+ `pyproject.toml` (hatchling), console script `gh2discord`, MIT license,
122
+ English README. Install: `pipx install gh2discord` / `pip install gh2discord`.
123
+
124
+ ## Roadmap (post-v0.1)
125
+
126
+ - `--bot` mode: create Discord channel + webhook automatically (bot token,
127
+ Manage Webhooks permission).
128
+ - Polling mode for non-admin repos (releases.atom / Events API).
129
+ - Org-wide webhook helper for organization accounts.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gh2discord"
7
+ version = "0.1.0"
8
+ description = "One-command GitHub to Discord notifications: manage repo webhooks that post straight to your Discord channels."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ozan Eski" }]
13
+ keywords = ["github", "discord", "webhook", "notifications", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Version Control :: Git",
22
+ "Topic :: Communications :: Chat",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/ozaneski13/gh2discord"
27
+ Issues = "https://github.com/ozaneski13/gh2discord/issues"
28
+
29
+ [project.scripts]
30
+ gh2discord = "gh2discord.cli:main"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=7"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/gh2discord"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__, commands
7
+ from .config import ConfigError, load, normalize_repo, save
8
+ from .github import AuthError, GitHubClient, GitHubError, resolve_token
9
+
10
+
11
+ def _mask(url: str) -> str:
12
+ marker = "/api/webhooks/"
13
+ idx = url.find(marker)
14
+ if idx == -1:
15
+ return url
16
+ prefix = url[: idx + len(marker)]
17
+ parts = url[idx + len(marker):].split("/")
18
+ if len(parts) >= 2:
19
+ parts[1] = "***"
20
+ return prefix + "/".join(parts)
21
+
22
+
23
+ def _client() -> GitHubClient:
24
+ return GitHubClient(resolve_token())
25
+
26
+
27
+ def _cmd_channel_add(args) -> int:
28
+ cfg = load()
29
+ is_default = commands.channel_add(cfg, args.name, args.url, args.default)
30
+ save(cfg)
31
+ suffix = " (default)" if is_default else ""
32
+ print(f"channel '{args.name}' saved{suffix}")
33
+ return 0
34
+
35
+
36
+ def _cmd_channel_list(args) -> int:
37
+ cfg = load()
38
+ if not cfg["channels"]:
39
+ print("no channels configured; add one with: gh2discord channel add <name> <url>")
40
+ return 0
41
+ for name in sorted(cfg["channels"]):
42
+ marker = "*" if name == cfg.get("default_channel") else " "
43
+ print(f"{marker} {name} {_mask(cfg['channels'][name])}")
44
+ return 0
45
+
46
+
47
+ def _cmd_channel_remove(args) -> int:
48
+ cfg = load()
49
+ commands.channel_remove(cfg, args.name)
50
+ save(cfg)
51
+ print(f"channel '{args.name}' removed")
52
+ return 0
53
+
54
+
55
+ def _cmd_channel_default(args) -> int:
56
+ cfg = load()
57
+ commands.channel_default(cfg, args.name)
58
+ save(cfg)
59
+ print(f"default channel is now '{args.name}'")
60
+ return 0
61
+
62
+
63
+ def _cmd_track(args) -> int:
64
+ cfg = load()
65
+ repo = normalize_repo(args.repo)
66
+ events = commands.parse_events(args.events)
67
+ result = commands.track(
68
+ _client(), cfg, repo, channel=args.channel, events=events, force=args.force
69
+ )
70
+ save(cfg)
71
+ print(
72
+ f"{result.action}: {result.repo} -> #{result.channel} "
73
+ f"(hook {result.hook_id}, events: {', '.join(result.events)})"
74
+ )
75
+ for warning in result.warnings:
76
+ print(warning, file=sys.stderr)
77
+ return 0
78
+
79
+
80
+ def _cmd_untrack(args) -> int:
81
+ cfg = load()
82
+ repo = normalize_repo(args.repo)
83
+ deleted = commands.untrack(_client(), cfg, repo)
84
+ save(cfg)
85
+ if not deleted:
86
+ print(
87
+ f"no matching Discord hook found on {repo}; nothing deleted "
88
+ "(local record cleared)",
89
+ file=sys.stderr,
90
+ )
91
+ return 1
92
+ ids = ", ".join(str(i) for i in deleted)
93
+ print(f"untracked {repo} (deleted hook {ids})")
94
+ return 0
95
+
96
+
97
+ def _cmd_list(args) -> int:
98
+ cfg = load()
99
+ if not cfg["repos"]:
100
+ print("no repos tracked; start with: gh2discord track <owner/repo>")
101
+ return 0
102
+ for repo in sorted(cfg["repos"]):
103
+ rec = cfg["repos"][repo]
104
+ events = ", ".join(rec.get("events", ["*"]))
105
+ print(f"{repo} -> #{rec.get('channel')} (events: {events})")
106
+ return 0
107
+
108
+
109
+ def _cmd_status(args) -> int:
110
+ cfg = load()
111
+ repo = normalize_repo(args.repo) if args.repo else None
112
+ if repo is None and not cfg["repos"]:
113
+ print("no repos tracked; start with: gh2discord track <owner/repo>")
114
+ return 0
115
+ rows = commands.status(_client(), cfg, repo)
116
+ failed = False
117
+ for row in rows:
118
+ if "error" in row:
119
+ failed = True
120
+ print(f"{row['repo']}: ERROR - {row['error']}")
121
+ continue
122
+ state = "active" if row["active"] else "DISABLED"
123
+ delivery = f"{row['code']} {row['status']}" if row["code"] else "no deliveries yet"
124
+ events = ", ".join(row["events"])
125
+ print(
126
+ f"{row['repo']}: {state}, hook {row['hook_id']} -> {_mask(row['url'])}, "
127
+ f"last delivery: {delivery}, events: {events}"
128
+ )
129
+ return 1 if failed else 0
130
+
131
+
132
+ def _cmd_ping(args) -> int:
133
+ cfg = load()
134
+ repo = normalize_repo(args.repo)
135
+ hook_id = commands.ping(_client(), cfg, repo)
136
+ print(
137
+ f"ping sent to hook {hook_id} on {repo}; verify with 'gh2discord status {repo}' "
138
+ "or the repo's Settings -> Webhooks -> Recent Deliveries on GitHub"
139
+ )
140
+ return 0
141
+
142
+
143
+ def build_parser() -> argparse.ArgumentParser:
144
+ parser = argparse.ArgumentParser(
145
+ prog="gh2discord",
146
+ description="Wire GitHub repos to Discord channels with one command.",
147
+ )
148
+ parser.add_argument("--version", action="version", version=f"gh2discord {__version__}")
149
+ sub = parser.add_subparsers(dest="command", required=True)
150
+
151
+ channel = sub.add_parser("channel", help="manage Discord channel webhooks")
152
+ channel_sub = channel.add_subparsers(dest="channel_command", required=True)
153
+
154
+ add = channel_sub.add_parser("add", help="register a Discord webhook under a name")
155
+ add.add_argument("name")
156
+ add.add_argument("url", help="Discord webhook URL (with or without /github suffix)")
157
+ add.add_argument("--default", action="store_true", help="make this the default channel")
158
+ add.set_defaults(func=_cmd_channel_add)
159
+
160
+ clist = channel_sub.add_parser("list", help="list registered channels")
161
+ clist.set_defaults(func=_cmd_channel_list)
162
+
163
+ remove = channel_sub.add_parser("remove", help="remove a registered channel")
164
+ remove.add_argument("name")
165
+ remove.set_defaults(func=_cmd_channel_remove)
166
+
167
+ default = channel_sub.add_parser("default", help="set the default channel")
168
+ default.add_argument("name")
169
+ default.set_defaults(func=_cmd_channel_default)
170
+
171
+ track = sub.add_parser("track", help="add/refresh the GitHub webhook for a repo")
172
+ track.add_argument("repo", help="owner/repo or GitHub URL")
173
+ track.add_argument("--channel", help="channel name (default: the default channel)")
174
+ track.add_argument(
175
+ "--events",
176
+ default="all",
177
+ help="comma-separated GitHub event list, or 'all' (default: all)",
178
+ )
179
+ track.add_argument(
180
+ "--force",
181
+ action="store_true",
182
+ help="retarget an existing Discord hook that points elsewhere",
183
+ )
184
+ track.set_defaults(func=_cmd_track)
185
+
186
+ untrack = sub.add_parser("untrack", help="delete the repo's Discord webhook")
187
+ untrack.add_argument("repo")
188
+ untrack.set_defaults(func=_cmd_untrack)
189
+
190
+ lst = sub.add_parser("list", help="list locally tracked repos")
191
+ lst.set_defaults(func=_cmd_list)
192
+
193
+ status = sub.add_parser("status", help="show live hook state and delivery health")
194
+ status.add_argument("repo", nargs="?", help="owner/repo (default: all tracked)")
195
+ status.set_defaults(func=_cmd_status)
196
+
197
+ ping = sub.add_parser("ping", help="send a GitHub ping through the hook")
198
+ ping.add_argument("repo")
199
+ ping.set_defaults(func=_cmd_ping)
200
+
201
+ return parser
202
+
203
+
204
+ def main(argv: list | None = None) -> int:
205
+ for stream in (sys.stdout, sys.stderr):
206
+ try:
207
+ stream.reconfigure(errors="replace")
208
+ except (AttributeError, ValueError):
209
+ pass
210
+ parser = build_parser()
211
+ args = parser.parse_args(argv)
212
+ try:
213
+ return args.func(args)
214
+ except (ConfigError, AuthError, GitHubError) as exc:
215
+ print(f"error: {exc}", file=sys.stderr)
216
+ return 1
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())