# correspond.channels.github

GitHub: issues, pull request conversations and discussions, through the `gh` CLI.

correspond never holds a GitHub token: every call is `gh api`, authenticated as whatever
account `gh` is logged in as on this machine, so writes act as that user. Request bodies
go to `gh` on stdin, never on its command line, so a long comment cannot hit the argument
limit and never shows in the process table.

### References

- `github:owner/repo`: the repository. `read` lists recent open issues and pull
  requests, `listen` polls its issue, comment, review comment and discussion activity,
  `send` opens an issue (a title is required), or a discussion when `Draft.native`
  names a `category`.
- `github:owner/repo#N`: an issue, a pull request, or a discussion. GitHub numbers the
  three in one sequence; correspond asks which. Reading or listening to a pull request
  includes its review comments on the diff, each threaded under the comment that opened
  its thread (`reply_to`), and a reply to one of them joins that thread.
  `label` and `unlabel` work on an issue
  or pull request only: GitHub discussions have categories, not labels. `close` and
  `reopen` work on all three: an issue or pull request through `PATCH .../issues/N`,
  a discussion through GraphQL `closeDiscussion` / `reopenDiscussion`. Each kind
  takes its own close reasons (issue: `completed`, `not_planned`, `duplicate`;
  discussion: `resolved`, `outdated`, `duplicate`, with `completed` accepted as
  `resolved`).

Opening an issue takes `Draft.native` keys `labels`, `assignees` and `milestone`,
set in the creating request so watchers are notified once. GitHub silently drops an
assignee who cannot be assigned, so the plan reports what the created issue actually
carries (`landed`) and what did not stick (`not_applied`). `Draft.native`
`category` (a discussion category’s name or slug) opens a discussion instead, through
GraphQL `createDiscussion`; it takes none of the issue fields.

Message ids match the anchors in GitHub’s own URLs: `issue-N` (the opening post of issue
or pull request N), `issuecomment-ID`, `discussion_rID` (a review comment on a pull
request’s diff), `discussion-N`, `discussioncomment-ID`.

Messages read through the API are `platform` (GitHub authenticated the account), with the
author’s `author_association` (`OWNER`, `MEMBER`, `CONTRIBUTOR`, `NONE`, …) as
`authority`. A webhook delivery is graded by [`GitHub.verify()`](#correspond.channels.github.GitHub.verify): `crypto` when its
`X-Hub-Signature-256` matches the configured secret, `forged` otherwise.

[`GitHub.audience()`](#correspond.channels.github.GitHub.audience) says who can read a conversation: the repository’s readership,
from its visibility, and its collaborators when the account may list them.

```pycon
>>> GitHub().parse_ref("OctoCat/Hello-World#1").encoded
'github:octocat/hello-world#1'
>>> signature("a-secret", b"{}")
'sha256=...'
```

### Module Attributes

| [`WATCHERS`](#correspond.channels.github.WATCHERS)   | Reader classes of a repository that cannot be listed, as [`GitHub.audience()`](#correspond.channels.github.GitHub.audience) words them.   |
|-------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|

### Functions

| [`signature`](#correspond.channels.github.signature)(secret, body)   | The `X-Hub-Signature-256` value GitHub sends for `body` under `secret`.   |
|----------------------------------------------------------------------------|---------------------------------------------------------------------------|

### Classes

| [`GitHub`](#correspond.channels.github.GitHub)(\*[, run, gh, recent_discussions, ...])   | GitHub through `gh`: read, listen, send, edit, react, verify webhook deliveries, and say who reads a conversation.   |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|

### *class* correspond.channels.github.GitHub(\*, run=<function run>, gh='gh', recent_discussions=3, deliveries=None, clock=<built-in function time_ns>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

GitHub through `gh`: read, listen, send, edit, react, verify webhook deliveries, and say who reads a conversation.

#### audience(ref, , draft=None)

Who can read a repository’s issues, pull requests and discussions, asked of GitHub now.

Every conversation in a repository has the repository’s readership. A draft changes
nothing: a mention decides who is notified, not who can read. One
`gh api repos/{owner}/{repo}` call gives the visibility and the owner’s type:

- `public`: scope `public`; readers are not listed.
- `private`: scope `named` when a user owns it, `org` otherwise.
- `internal`: scope `org`, with every member of the enterprise as a class.
- a 404 or a 403: `public`, defaulted. GitHub answers 404 both for a repository
  that does not exist and for one the account cannot see.

For a private or internal repository, collaborators (team members, members reading
through the base permission, and owners included) are listed only when the account
can write to it, since GitHub lists them to no one else. The list is a lower bound
and the audience is never `complete`: installed apps and webhooks read the
repository without being listable through `gh`, and an organisation’s
security-manager teams read every repository. An organisation’s base permission is
read when the account may see it (owners only), and otherwise assumed to be the
documented default, read; `read`, `write` and `admin` reach the same readers
and give the same class. The `gh` account itself is never consulted, so the answer,
and its hash, do not depend on who asks. Every repository emails bodies to watchers
and participants, and nothing sent is retractable. Nothing is cached. A rate limit or
a transport failure raises; [`correspond.ops.audience()`](correspond.ops.html.md#correspond.ops.audience) resolves it to public.

* **Return type:**
  [`Audience`](correspond.model.html.md#correspond.model.Audience)

#### *property* capabilities *: [Capabilities](correspond.model.html.md#correspond.model.Capabilities)*

Issues, pull request conversations and discussions.

#### close(ref, , reason=None, dry_run=False)

Close an issue or pull request (`PATCH .../issues/N`) or a discussion (GraphQL).

`reason` is the kind’s own: an issue’s `state_reason` (`completed`,
`not_planned`, `duplicate`) or a discussion’s (`resolved`, `outdated`,
`duplicate`; `completed` is accepted as `resolved`); a pull request takes
none. The plan reads the state back: `was` (before) and `now` /
`now_reason` (what the response says), so closing an already-closed
conversation is visible as such.

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### *property* deliveries *: [MutableMapping](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [Any](https://docs.python.org/3/library/typing.html#typing.Any)]*

What the webhook receiver stored (read when `delivery` is `webhook`).

#### edit(ref, message_id, draft, , dry_run=False)

Replace the body of an issue, a comment, a review comment, a discussion or a discussion comment.

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### label(ref, labels, , dry_run=False)

Add labels to an issue or pull request (`POST .../labels`).

GitHub creates a label that does not already exist in the repository rather than
rejecting it, so `landed` in the plan is mostly a confirmation: the requested
names the response’s own (now-current) label list actually carries, which is read
back and reported the way an assignee is not (a login that is not a member of the
repository is silently dropped from `assignees`; a label name never is). The
response lists every label now on the issue, including ones this call never
mentioned, so `landed` is filtered to the requested names, not the full list.

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### parse_ref(id)

`owner/repo` or `owner/repo#N`, lower-cased (GitHub names are case-insensitive).

* **Return type:**
  [`ConversationRef`](correspond.model.html.md#correspond.model.ConversationRef)

#### poll(ref, , cursor=None, limit=None)

Issue, comment, review comment, review and discussion activity since `cursor`; a first poll looks back a day.

The cursor is opaque JSON: the update time the next poll starts from (`t`; GitHub’s
`since` includes it), the creation time from which an issue still counts as new
(`l`, which stays behind while the issues list is being read in parts), and the
events already delivered at `t` (`seen`). So neither `limit` nor several changes
within one second can make a poll repeat itself, and a list cut short (more than
`MAX_PAGES` pages changed) is resumed from where it was cut. A bare ISO time is also
accepted as a cursor.

Discussions have no `since` filter, so a repository’s are walked newest activity
first until a page reaches one older than the cursor (`_active_discussions()`),
and each active one is read whole; one discussion is read whole on every poll. A
discussion counts as new when it was created at or after the cursor, and a
discussion comment is `message.updated` when it was created before it.

#### prepare(ref, draft)

The draft as it will post: `notify` handles checked, the missing mentions added. Pure.

* **Return type:**
  [`Draft`](correspond.model.html.md#correspond.model.Draft)

#### react(ref, message_id, reaction, , dry_run=False)

Add one of `REACTIONS` to an issue, a comment, a review comment, a discussion or a discussion comment.

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### read(ref, , since=None, limit=None)

An issue, pull request or discussion with its comments; for a repository, recent open issues and pull requests.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Message`](correspond.model.html.md#correspond.model.Message)]

#### reopen(ref, , dry_run=False)

Reopen a closed issue, pull request or discussion; the plan reads the state back as for [`close()`](#correspond.channels.github.GitHub.close).

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### send(ref, draft, , dry_run=False)

Comment on an issue, pull request or discussion; on a repository, open an issue (`title` required), or a discussion with native `category`.

`draft.reply_to` answers a discussion comment, or a review comment on a pull
request’s diff (`discussion_rID`), joining its thread.

`draft.notify` handles (`octocat`, `org/team`) the text does not @mention yet
are mentioned on a closing `cc` line, which GitHub notifies; the plan lists them
under `mentions_added`.

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### unlabel(ref, labels, , dry_run=False)

Remove labels from an issue or pull request.

GitHub has no bulk removal: one `DELETE .../labels/{name}` per label. A label
already absent from the issue answers 404 the same way a missing issue does, so the
issue’s existence is checked first (`_require_issue()`) and every 404 after that
is read as “was not on the issue”, not as an error.

* **Return type:**
  [`SendResult`](correspond.model.html.md#correspond.model.SendResult)

#### verify(headers, body)

`crypto` when `X-Hub-Signature-256` matches the webhook secret, `forged` otherwise.

* **Return type:**
  [`Authenticity`](correspond.model.html.md#correspond.model.Authenticity)

### correspond.channels.github.WATCHERS *= 'watchers and participants receive the body by email'*

Reader classes of a repository that cannot be listed, as [`GitHub.audience()`](#correspond.channels.github.GitHub.audience) words them.

### correspond.channels.github.signature(secret, body)

The `X-Hub-Signature-256` value GitHub sends for `body` under `secret`.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)
