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(): crypto when its X-Hub-Signature-256 matches the configured secret, forged otherwise.

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

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

Module Attributes

WATCHERS

Reader classes of a repository that cannot be listed, as GitHub.audience() words them.

Functions

signature(secret, body)

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

Classes

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>)[source]#

Bases: object

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

audience(ref, *, draft=None)[source]#

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() resolves it to public.

Return type:

Audience

property capabilities: Capabilities#

Issues, pull request conversations and discussions.

close(ref, *, reason=None, dry_run=False)[source]#

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

property deliveries: MutableMapping[str, Any]#

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

edit(ref, message_id, draft, *, dry_run=False)[source]#

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

Return type:

SendResult

label(ref, labels, *, dry_run=False)[source]#

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

parse_ref(id)[source]#

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

Return type:

ConversationRef

poll(ref, *, cursor=None, limit=None)[source]#

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)[source]#

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

Return type:

Draft

react(ref, message_id, reaction, *, dry_run=False)[source]#

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

Return type:

SendResult

read(ref, *, since=None, limit=None)[source]#

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

Return type:

list[Message]

reopen(ref, *, dry_run=False)[source]#

Reopen a closed issue, pull request or discussion; the plan reads the state back as for close().

Return type:

SendResult

send(ref, draft, *, dry_run=False)[source]#

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

unlabel(ref, labels, *, dry_run=False)[source]#

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

verify(headers, body)[source]#

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

Return type:

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() words them.

correspond.channels.github.signature(secret, body)[source]#

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

Return type:

str