Metadata-Version: 2.5
Name: papeete-actor-synchronous-messaging-github-issues
Version: 0.2.0
Summary: A GitHub Issues binding for papeete-actor-synchronous-messaging — a Mailbox adapter, poll-based and higher-latency by design.
Author-email: Papeete Consulting <yoann.remy@outlook.com>
License-Expression: MIT
Keywords: actor-model,agents,github,github-issues
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.11
Requires-Dist: papeete-actor-synchronous-messaging>=0.2.1
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: otel
Requires-Dist: opentelemetry-api>=1.27; extra == 'otel'
Description-Content-Type: text/markdown

# papeete-actor-synchronous-messaging-github-issues

A GitHub Issues binding for
[`papeete-actor-synchronous-messaging`](https://github.com/papeete-hub/papeete-actor-synchronous-messaging)
— one class, `GitHubIssuesMailbox`, filling the `Mailbox` port that package's own `mailbox.py`
already names: *"A queue, an HTTP surface, a file drop or a GitHub issue is a NEW BINDING, not a
variant of this one."*

```bash
pip install papeete-actor-synchronous-messaging-github-issues
```

## Why a separate package, and why now

[`papeete-actor-synchronous-messaging-http`](https://github.com/papeete-hub/papeete-actor-synchronous-messaging-http)'s
own `ADR-PASH-0001` flagged this binding and deliberately deferred it: *"GitHub issues was
discussed and deliberately deferred: a genuinely different, higher-latency shape, worth its own
repo once this one is proven."* The HTTP binding is proven; this is that repo. It is not a
drop-in clone of it — a blocking socket call and a poll loop over REST are different enough
mediums that this package earns its own repo the same way the HTTP binding did, rather than a
`transport=` flag on one binding.

## Where `papeete-actor-message` fits — nowhere in here, directly

Structuring *what* a message means (a name, an intent, a list of typed data references) is
`papeete-actor-message`'s job, consumed by the *core* package's `Card` — every door a `request`
or `query` opens already has its shape checked against a message that catalog declares, before
this binding ever sees a payload. This package depends on `papeete-actor-message` only
transitively, through `papeete-actor-synchronous-messaging`, exactly as
`papeete-actor-synchronous-messaging-http` does (`ADR-PASH-0002`). Its own job is narrower: move
a plain `(from, to, verb, door, payload)` call and its plain-dict reply through GitHub's Issues
API instead of a socket.

## `GitHubIssuesMailbox`

```python
from papeete_actor_synchronous_messaging.actor import Actor
from papeete_actor_synchronous_messaging_github_issues.mailbox import GitHubIssuesMailbox

box = GitHubIssuesMailbox(org="papeete-hub")   # token from GITHUB_TOKEN, or pass token=...
actor = Actor.from_card("path/to/actor-folder", mailbox=box)
box.serve_forever()                            # blocks, polling this actor's own repo
```

- **`register(actor) / deliver(*, from_, to, verb, door, payload)`** — the two methods
  `Mailbox` requires. `deliver()` is the outbound half: open one issue on `to`'s repo, body a
  single fenced JSON block `{"from": ..., "payload": {...}}` — no `verb`, no `door` in the body,
  those travel as labels instead (see below) — then poll that issue until it closes and parse
  the last comment as the reply.
- **`serve_forever()` / `poll_once()`** — the inbound half. Every `poll_interval` seconds, list
  open issues on the local actor's own repo labeled `papeete-request` **and** `to:<this actor's
  own name, lowercased>`; each becomes one `actor.receive()` call. Success posts the reply as a
  comment and closes the issue labeled `papeete-handled`; a `Refusal` at the membrane (undeclared
  door, wrong verb, `work` itself failing) posts `{"error": ...}` and closes it labeled
  `papeete-refused` instead — refuse, never repair, carried across the wire regardless of medium.
  `poll_once()` runs a single pass and returns how many issues it handled — what
  `serve_forever()` loops, and what a test drives directly for a deterministic check of the
  inbound half.
- **A door is a label, not a JSON field.** `deliver()` stamps `door:<door-id>` on every issue it
  opens; `register()` builds a `{door_id: verb}` table from the local actor's own card (the same
  table `HttpMailbox.register()` builds for its route dispatch) so `poll_once()` can recover
  `verb` from that label. A door id declared as both an action and a query is refused at
  `register()` — a flat label namespace has no field left to disambiguate it.
- **A repo is a shared project, not an implicit one-actor mailbox.** `to:<addressee, lowercased>`
  is what makes that safe: every issue carries it, and `poll_once()` always filters by it, so an
  actor sharing a repo with others only ever sees its own mail. Addressing itself resolves, in
  order: `peers={name: "owner/repo"}` first (an explicit, full override), then `peers={name:
  "other-org"}` — a bare org, no `/` — narrowing just the owner while the usual convention still
  applies under it, then `{org}/{repo}` when `repo=` is set at construction (several actors
  sharing one project, e.g. every actor of one `papeete-product` product), then `{org}/{name,
  lowercased}` by default — `ADR-PASGI-0001`'s original one-repo-per-actor convention, unchanged
  when `repo=` is left unset.
- **`product=`/`environment=` are optional triage labels**, stamped `product:<value>`/
  `env:<value>` on every issue — for a human filtering a shared repo's Issues tab, never read
  from `papeete-product`'s own `product.yaml` (this package still takes exactly one dependency).
- **The issue number is the correlation mechanism.** No separate id is minted — one call, one
  issue, the same way an HTTP request/response pair correlates itself.
- **Rate-limit-aware, not a flat interval.** The list call `poll_once()` makes and the
  single-issue poll `deliver()` makes are both conditional (`ETag`/`If-None-Match`) — a `304`
  when nothing changed costs nothing against the budget, confirmed against the real API. Once
  `X-RateLimit-Remaining` drops to `rate_limit_floor` (default 50), the next call sleeps until
  the real `X-RateLimit-Reset` instant instead of racing toward a 403.

```python
# several actors of one product, sharing one repo as their common inbox
box = GitHubIssuesMailbox(
    org="papeete-hub", repo="pash-table-service",
    product="pash-table-service", environment="local",
)
```

**No discovery endpoint, no live "what are your doors."** Same principle as the HTTP binding: a
peer's doors are static business knowledge the caller's own code carries.

**Genuinely higher latency, on purpose.** This binding never runs a server and never needs a
public endpoint — both directions are a plain REST client polling at `poll_interval`, which
trades real-time delivery for that simplicity. `timeout` bounds how long `deliver()` will wait
before raising `DeliveryError`; tune both to the actual GitHub API rate limit budget available
to the token in use.

## Observability

```bash
pip install "papeete-actor-synchronous-messaging-github-issues[otel]"
```

`deliver()` and `poll_once()`'s own dispatch are each wrapped in one OpenTelemetry span (CLIENT,
SERVER) plus a `door_calls_total`/`door_latency_seconds` metric tick, `outcome` one of
`accepted`/`refused`/`error` — the same shape
`papeete-actor-synchronous-messaging-http`'s own `_tracing.py` establishes, carried over
unchanged where the medium allows it. **No-op with nothing installed or configured**, since it's
the OTel API itself doing the work (`opentelemetry-api`, an optional extra — no SDK dependency
here at all); wiring an actual exporter is
[`papeete-observability`](https://github.com/papeete-hub/papeete-observability)'s job.

**The one genuine difference this medium forces:** trace context has no HTTP header to ride on,
so it travels folded into the wire body's own `trace` field instead — `{"from": ..., "payload":
{...}, "trace": {"traceparent": ...}}` — read back by the answering side the same way. The CLIENT
span covers `deliver()`'s *entire* open-issue-to-close round trip, not just one REST call, since
that whole span genuinely is this medium's latency (see `_tracing.py`'s own docstring).

## `examples/` — one real, deployable pair

[`examples/deterministic/`](./examples/deterministic) (`customer`/`waiter`) is the same
conversation `papeete-actor-synchronous-messaging-http`'s own `examples/deterministic/` proves,
retargeted at `GitHubIssuesMailbox` and, since `ADR-PASGI-0002` lets it, sharing one GitHub repo
as both actors' common inbox rather than one repo each. A poll-loop worker's own deploy shape —
a bare `Deployment`, no `Service`, no listening port at all — closes the "no deploy shape yet"
gap `ADR-PASGI-0001` left open. See `examples/README.md` for the exact build/run/deploy commands,
local and on Kubernetes; running it against a real (throwaway) repo is also what found and fixed
a real bug — see `_answer()`'s own docstring in `mailbox.py`.

## Test

```bash
uv run --extra dev pytest
```

No real GitHub, no token, no network: a small in-process fake standing in for the handful of
REST endpoints this binding actually calls (create/read/list/comment/close an issue) — the same
"prove the binding, not the vendor" approach the HTTP binding's tests take with two real
`127.0.0.1` sockets instead of a mock `Mailbox`.

## Releasing

Tag-triggered, via [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) —
the same lane `papeete-actor-synchronous-messaging-http` uses. **No API token is stored
anywhere:** GitHub mints a short-lived OIDC token per run and PyPI trades it for an upload token.

```bash
git tag v0.1.0 && git push origin v0.1.0     # .github/workflows/release.yml does the rest
```

### One-time setup

**1. A pending publisher on PyPI**, registered from the publisher side rather than a first
manual upload. At <https://pypi.org/manage/account/publishing/>, as a **GitHub** pending
publisher:

| Field | Value |
|---|---|
| PyPI Project Name | `papeete-actor-synchronous-messaging-github-issues` |
| Owner | `papeete-hub` |
| Repository name | `papeete-actor-synchronous-messaging-github-issues` |
| Workflow name | `release.yml` |
| Environment name | `pypi` |

All five must match exactly — PyPI checks the OIDC claims against them and rejects the upload
otherwise. `release.yml` already declares `permissions: id-token: write` and
`environment: pypi`, which is what makes those claims present.

**2. A `pypi` GitHub environment** on this repo — no secrets in it, it exists only so the OIDC
claim carries an environment name for PyPI to match. Protection rules are worth considering
(required reviewers, restrict deploys to `v*` tags) since a release is irreversible: PyPI never
allows re-uploading a version, even after a delete.

After the first successful release PyPI converts the pending publisher into a normal one
automatically; there is no second setup step.

### What a release asserts

The workflow builds, installs the wheel into a clean venv, and imports `GitHubIssuesMailbox`
from it before publishing — a build that can't actually be imported fails the release instead of
shipping a package nobody can use. It then re-installs the exact version just published, from
PyPI itself, polling for CDN propagation rather than trusting the upload step's own exit code.

## Licence

MIT.
