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.
- gh2discord-0.1.0/.github/workflows/publish.yml +33 -0
- gh2discord-0.1.0/.github/workflows/test.yml +18 -0
- gh2discord-0.1.0/.gitignore +7 -0
- gh2discord-0.1.0/LICENSE +21 -0
- gh2discord-0.1.0/PKG-INFO +116 -0
- gh2discord-0.1.0/README.md +93 -0
- gh2discord-0.1.0/docs/DESIGN.md +129 -0
- gh2discord-0.1.0/pyproject.toml +36 -0
- gh2discord-0.1.0/src/gh2discord/__init__.py +1 -0
- gh2discord-0.1.0/src/gh2discord/__main__.py +6 -0
- gh2discord-0.1.0/src/gh2discord/cli.py +220 -0
- gh2discord-0.1.0/src/gh2discord/commands.py +210 -0
- gh2discord-0.1.0/src/gh2discord/config.py +103 -0
- gh2discord-0.1.0/src/gh2discord/github.py +173 -0
- gh2discord-0.1.0/tests/test_cli.py +15 -0
- gh2discord-0.1.0/tests/test_commands.py +268 -0
- gh2discord-0.1.0/tests/test_config.py +157 -0
- gh2discord-0.1.0/tests/test_github.py +160 -0
|
@@ -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
|
gh2discord-0.1.0/LICENSE
ADDED
|
@@ -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,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())
|