LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
Tags: python, github, git, devjournal
Published: 2021-02-04 12:40:21

Finally, time has come for us to do the thing we were supposed to do ages ago - have characters who can post on GitHub.  I am using the excellent [pygithub](https://pygithub.readthedocs.io/) library which gives comprehensive access to the GitHub API.

Each character can post Issues (which is how quests are started), read replies, send comments, set and read emoji reacts. I think a large portion of the quests can be conveyed through this alone. In future we will need to support making Pull Requests and reading files too, but this'll get us started for now.

# New Character object
The new character object embodies a specific GitHub account, and fetches it's token from secrets management discussed last post. It looks like this:

```py

class Character:
    def __init__(self, secret_name: str):
        self.token = fetch_secret(secret_name)
        self.github = Github(self.token)

    def __getattribute__(self, name):
        attr = super().__getattribute__(name)
        if hasattr(attr, "__call__"):

            def newfunc(*args, **kwargs):
                try:
                    return attr(*args, **kwargs)
                except GithubException as err:
                    raise CharacterError(f"Character error: {err}") from err

            return newfunc
        else:
            return attr

    @cached_property
    def user_id(self) -> int:
        """ Get own ID """
        user = self.github.get_user()
        return user.id

    def repo_get(self, repo: str) -> Repository:
        """ Get a repo, translating it's URL """
        repo_name = "/".join(repo.split("/")[-2:])
        return self.github.get_repo(repo)

    def issue_get(self, repo: str, issue_id: int) -> Issue:
        """ Get an issue """
        return self.repo_get(repo).get_issue(number=issue_id)

    def issue_post(self, repo: str, title: str, body: str) -> int:
        """ Post an issue in a repo, returns issue number """
        issue = self.github.get_repo(repo).create_issue(title=title, body=body)
        return issue.number

    def issue_close(self, repo: str, issue_id: int) -> None:
        """ Close an issue in a repo """
        issue = self.issue_get(repo, issue_id)
        issue.edit(state="closed")

    def issue_reaction_get_from_user(self, repo: str, issue_id: int, user_id: int) -> List[ReactionType]:
        ...

    def issue_reaction_create(self, repo: str, issue_id: int, reaction: ReactionType) -> None:
        ...

    def issue_comment_get_from_user_since(self, repo: str, issue_id: int, user_id: int, since: Union[datetime, NotSetType]) -> Dict[int, str]:
        ...

    def issue_comment_get_from_user(self, repo: str, issue_id: int, user_id: int) -> Dict[int, str]:
        ...
    def issue_comment_create(self, repo: str, issue_id: int, body: str) -> int:
        ...

    def issue_comment_reaction_create(self, repo: str, issue_id: int, comment_id: int, reaction: ReactionType) -> None:
        ...

    def issue_comment_reactions_get_from_user(self, repo: str, issue_id: int, comment_id: int, user_id: int) -> List[ReactionType]:
        ...

    def issue_comment_delete(self, repo: str, issue_id: int, comment_id: int) -> None:
        ...
```

There's a lot of duplicate code relating to fetching the `repo` and later the `issue`, I wonder to myself whether I should reduce these by using decorators and injecting the dependency, or forcing end-use to set up Repo and Issue objects. However in favour of simpler end-use where these functions will be called somewhat in isolation without an option to re-use any underlying Repo/Issue objects, this will be fine.

The `__getattribute__` dunder near the top of the class serves as a wrapper for all the other methods, to re-raise `GithubException` exceptions (which any of the methods can raise), into `CharacterError` exceptions. This helps decouple this `character` class from any Github-specific implementations, should we decide to support Gitlab in the future.

# Integration Tests
For integration tests, I'm going to actually use the API to hit GitHub. At some point, we have to test this to ensure things are working, and unless we build our own mock GitHub, the simplest awy to do this is to actually just create issues in a private repository (so nobody else can see it).

To reduce the amount of API hitting we do, I've collapsed everything into a single test with what I think is the minimum set of calls to ensure all the features are working (there are a couple other tests to test failures as well)

```py

def test_issue_flow(random_id):
    """All tests in one - this is desirable to minimize the number of times
    we're hitting GitHub API. So they're all combined in a single test"""
    issue_name = "Test_" + random_id

    # post it
    issue_id = character_garry.issue_post(TEST_REPO, issue_name, "This is a test post")
    assert issue_id

    # check it's not closed
    issue = character_garry.issue_get(TEST_REPO, issue_id)
    assert issue.state != "closed"

    # set an emoji on it
    character_garry.issue_reaction_create(TEST_REPO, issue_id, ReactionType.ROCKET)

    # check emojis on it
    reactions = character_garry.issue_reaction_get_from_user(
        TEST_REPO, issue_id, character_garry.user_id
    )
    assert ReactionType.ROCKET in reactions

    # create comment
    comment_id = character_garry.issue_comment_create(
        TEST_REPO, issue_id, "This is a test comment"
    )
    assert comment_id

    # get it
    comments = character_garry.issue_comment_get_from_user(
        TEST_REPO, issue_id, character_garry.user_id
    )
    assert comment_id in comments.keys()

    # create reaction on it
    character_garry.issue_comment_reaction_create(
        TEST_REPO, issue_id, comment_id, ReactionType.HEART
    )

    # get reactions from it
    reactions = character_garry.issue_comment_reactions_get_from_user(
        TEST_REPO, issue_id, comment_id, character_garry.user_id
    )
    assert ReactionType.HEART in reactions

    # delete comment
    character_garry.issue_comment_delete(TEST_REPO, issue_id, comment_id)

    # check deleted
    comments = character_garry.issue_comment_get_from_user(
        TEST_REPO, issue_id, character_garry.user_id
    )
    assert comment_id not in comments.keys()

    # close it
    character_garry.issue_close(TEST_REPO, issue_id)

    # check it closed
    issue = character_garry.issue_get(TEST_REPO, issue_id)
    assert issue.state == "closed"
```

This flow goes through a series of creation of issues, comments, an dreactions, using `character_garry`'s account.

If we didn't delete the created tests, they'd look like this:

![Test issues being created](https://dev-to-uploads.s3.amazonaws.com/i/s9ooejwntd3mjgtohn6i.png)

![Test comments](https://dev-to-uploads.s3.amazonaws.com/i/myc3o7vudzfubcm1b1ad.png)

___
With this function in place, we can finally show quests to a user, and read their input!