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 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:
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)
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:
With this function in place, we can finally show quests to a user, and read their input!
Top comments (0)