Metadata-Version: 2.5
Name: invite-gate
Version: 0.3.0
Summary: Auto-accept allowlisted GitHub repository invitations; leave everyone else pending for human review.
Project-URL: Homepage, https://github.com/bkrabach/invite-gate
Project-URL: Repository, https://github.com/bkrabach/invite-gate
Project-URL: Issues, https://github.com/bkrabach/invite-gate/issues
Author-email: Brian Krabach <702425+bkrabach@users.noreply.github.com>
License-Expression: MIT
License-File: LICENSE
Keywords: automation,cli,github,invitations
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# invite-gate

Auto-accept GitHub repository invitations from senders you explicitly trust.
Leave every other invitation exactly where it is — pending, untouched, for
you to review by hand.

## Why this exists

If you're on a team (or a popular open-source project), you can end up with
a steady stream of repository invitations: teammates adding you to new
repos, bots inviting you to CI/automation repos, org-wide onboarding flows,
and occasionally strangers or compromised accounts trying their luck.

The tempting fix is a cron job that blanket-accepts everything. Don't do
that. Accepting an invitation from someone you don't know:

- Adds a repository you didn't choose into your GitHub account, which then
  shows up in your repo list, your notifications, and your contribution
  graph.
- Exposes you to code and CI/CD workflows you have not reviewed. GitHub
  Actions in a repo you've joined can run with *your* account's context in
  some flows, and a malicious repo is a real attack vector.
- Implies an association. Being a collaborator on a repo is visible, and it
  can look like an endorsement or affiliation you never intended.

`invite-gate` solves the actual problem: auto-accept invitations from a
short, explicit list of senders you already trust (teammates, your org, your
own bots), and leave literally everyone else pending so a human looks at it.
There is no "accept all" mode. There never will be — see
[Security notes](#security-notes).

## Install

```bash
# From PyPI
uv tool install invite-gate

# Or straight from GitHub
uv tool install git+https://github.com/bkrabach/invite-gate
```

You'll also need the [GitHub CLI](https://cli.github.com/) (`gh`) installed
and authenticated:

```bash
gh auth login
```

`invite-gate` never asks for or stores a token itself — it shells out to
`gh`, which manages your credential. See
[Security notes](#security-notes) for why this matters.

## Quickstart

```bash
# 1. Check gh is installed, authenticated, and correctly scoped
invite-gate doctor

# 2. Trust one or more senders (creates the allowlist if it doesn't exist yet)
invite-gate allow some-teammate some-org some-app[bot]

# 3. See what's pending right now
invite-gate status

# 4. Run the gate: auto-accept anything from an allowlisted sender,
#    leave the rest pending
invite-gate run

# 5. Wire it up to run periodically
invite-gate install-timer
```

## Commands

```
invite-gate status              # human-readable: pending invites split into "would auto-accept" and "needs your review"
invite-gate run [--dry-run]     # poll; accept allowlisted; leave rest pending; refresh cache; this is the timer entrypoint
invite-gate motd                # ultra-concise cached summary for shell startup; silent+exit 0 when nothing pending
invite-gate accept <ID>...      # accept specific invitation id(s)
invite-gate decline <ID>...     # decline (DELETE) specific invitation id(s)
invite-gate allow <LOGIN>...    # append login(s) to allowlist (idempotent); then accept any pending from them; report what it did
invite-gate list                # print current allowlist
invite-gate doctor              # check gh presence/auth/scope, config, state dir, cache age -- reports every check
invite-gate install-timer       # print (does NOT execute) ready-to-paste systemd/launchd units for scheduling `run`
invite-gate version             # print the installed version
invite-gate update [--check]    # check PyPI for a newer version and upgrade in place (--check only reports)
```

Global flags: `--json` (machine-readable output for `status`/`run`),
`--version`, `-h`/`--help`.

`status` and `run` both make a live call to GitHub (via `gh`) and refresh the
local pending cache as a side effect. `motd` never does — see below.

### `motd`: shell-startup summary

`motd` is meant to be wired into your shell's startup (`.bashrc`, `.zshrc`,
etc.) so you get a one-glance reminder of pending invitations every time you
open a terminal, without adding any latency to shell startup. To keep that
promise, `motd` **never makes a network call** — it only reads the cache
file that `run` and `status` last wrote. If that cache doesn't exist yet, it
prints nothing and exits 0. If it exists but is more than 24 hours old, it
still prints its summary, with a short note that the cache is stale.

Add a line like this to your shell profile:

```bash
invite-gate motd
```

Example output when there's something to review:

```
  2 GitHub invites need review:
    octocat/some-repo          from octocat         invite-gate accept/decline 12345
    acme/widgets                from acme-bot        invite-gate accept/decline 12346
  invite-gate allow <sender>  to auto-accept future invites from a sender
```

Output is capped at 10 rows (`… and N more (invite-gate status)` beyond
that), uses no color by default, and only uses color when stdout is a TTY
and `NO_COLOR` is unset.

## Config format

The allowlist is a plain text file, one GitHub login per line:

```
# invite-gate allowlist
some-teammate
some-org
some-app[bot]   # bot logins keep their brackets -- they're part of the login
```

Rules:

- `#` starts a comment; trailing comments (`login  # note`) are fine.
- Blank lines are ignored.
- Leading/trailing whitespace is stripped.
- Matching is **case-insensitive** (GitHub logins are case-insensitive).
- **No wildcards.** A literal `*` in the file is treated as a literal,
  unmatchable login and `invite-gate` prints a warning explaining why —
  see [Security notes](#security-notes).

Running `invite-gate allow <login>` creates the file (with a helpful
commented template) if it doesn't exist yet, and is idempotent — allowing an
already-allowed login is a no-op for the file, though it still checks for
and accepts any pending invitation from that sender.

A real-name-free example is shipped as
[`allowlist.example.txt`](./allowlist.example.txt).

### File locations (XDG)

`invite-gate` never hardcodes a home directory; it always resolves paths
through the XDG base directory variables (falling back to their documented
defaults):

| What | Path |
|---|---|
| Allowlist | `${XDG_CONFIG_HOME:-~/.config}/invite-gate/allowlist.txt` |
| Pending cache (used by `motd`) | `${XDG_STATE_HOME:-~/.local/state}/invite-gate/pending.json` |
| Activity log | `${XDG_STATE_HOME:-~/.local/state}/invite-gate/activity.log` |

The activity log records only: timestamp, action, repo full name, sender
login, and invitation id. Nothing else, ever — see
[Security notes](#security-notes).

## Scheduling

`invite-gate run` is the entrypoint you want on a timer. Run:

```bash
invite-gate install-timer
```

This **prints** (it does not execute) ready-to-paste unit files: a systemd
user service + timer for Linux, and a launchd plist for macOS. It detects
your platform and shows the relevant one first, followed by the exact enable
commands. Review the printed units, adjust the path to the `invite-gate`
binary if needed, and install them yourself.

By default the timer polls every 15 minutes. Override it with `--interval`:

```bash
invite-gate install-timer --interval 5min
invite-gate install-timer --interval 1h
```

`--interval` takes a number plus a required unit -- `s`/`sec`/`secs`/`second`/
`seconds`, `m`/`min`/`mins`/`minute`/`minutes`, `h`/`hr`/`hrs`/`hour`/`hours`,
or `d`/`day`/`days` (case-insensitive, decimals allowed, e.g. `1.5h`). A bare
number with no unit is rejected rather than guessed. The minimum is 60
seconds: `invite-gate run` makes a GitHub API call every time it fires, and
polling faster than once a minute risks tripping GitHub's secondary rate
limits for no benefit. There's no maximum, but GitHub collaborator
invitations expire after 7 days, so an interval measured in days risks
missing them -- keep it well under a day.

## Security notes

This tool exists specifically to be *conservative* about what it
auto-actions. The following are load-bearing, not incidental:

1. **`invite-gate` never handles a GitHub token.** Every GitHub call goes
   through the `gh` CLI (`gh api ...`). The tool never reads
   `GITHUB_TOKEN`/`GH_TOKEN` from the environment, never accepts a token as
   a flag, and never writes one anywhere. Before invoking `gh`, it
   explicitly strips `GITHUB_TOKEN` and `GH_TOKEN` from the subprocess
   environment, so `gh` always uses its own stored credential
   (`~/.config/gh/hosts.yml`) rather than an ambient token that might belong
   to a different identity. If `gh` is missing or unauthenticated,
   `invite-gate` fails loudly with an actionable message — it never
   degrades quietly.

2. **No wildcard / accept-all, ever.** There is no `*` support, no `--all`
   flag, and no config option that means "accept from everyone." If you put
   a literal `*` in your allowlist file, `invite-gate` treats it as a
   literal login string — which no real GitHub account can have — and
   prints a warning telling you why wildcards are deliberately unsupported.
   This is intentional and will not change: the entire point of the tool is
   precise, reviewed trust, not convenience at the cost of blanket
   acceptance.

3. **A missing or empty allowlist is not permission to act.** `invite-gate
   run` refuses to accept anything and exits non-zero with a clear message
   if the allowlist doesn't exist or has no entries. Read-only commands
   (`status`, `doctor`, `list`) still work fine with no config at all.

4. **Non-allowlisted invitations are never auto-actioned** — not accepted,
   not declined. They're recorded and surfaced for you to decide on, via
   `status` or `motd`.

5. **`motd` never makes a network call.** It only reads the cache file
   written by `run`/`status`, specifically so it's safe and fast to put in
   your shell startup. Missing cache -> silent, exit 0. Stale cache (>24h)
   -> still prints, with a short staleness note.

6. **Nothing sensitive is ever logged or printed.** The activity log
   records only timestamp, action, repo full name, sender login, and
   invitation id.

7. **All writes go to XDG paths.** No home directory is ever hardcoded;
   see [File locations](#file-locations-xdg).

8. **Any API failure exits non-zero.** There is no degraded mode, no silent
   fallback, and no retry-forever loop. If `gh` fails, you find out.

### Why `repo` scope?

`invite-gate` needs to read and act on `/user/repository_invitations`, which
requires the OAuth `repo` scope on your `gh` credential. `repo` is a broad
scope — it grants full read/write access to your repositories, not just
invitations — because GitHub doesn't expose a narrower scope for the
invitations endpoints. There isn't a way around this without GitHub adding a
finer-grained scope; `invite-gate` doesn't request anything beyond what `gh`
itself already asks for when you run `gh auth login`. `invite-gate doctor`
checks for this scope explicitly and tells you how to fix it if it's
missing.

## Development

```bash
git clone https://github.com/bkrabach/invite-gate
cd invite-gate
uv pip install -e ".[dev]"
python -m pytest
```

All tests mock subprocess calls to `gh` — the test suite never touches the
network.

## License

MIT © 2026 Brian Krabach. See [LICENSE](./LICENSE).
