Skip to content

Add minimal GitHub profile reviews API#161

Draft
Copilot wants to merge 5 commits intomainfrom
copilot/implement-github-profile-api
Draft

Add minimal GitHub profile reviews API#161
Copilot wants to merge 5 commits intomainfrom
copilot/implement-github-profile-api

Conversation

Copy link
Copy Markdown

Copilot AI commented Feb 5, 2026

All Latest PR Feedback Addressed

API Code Improvements:

  • Removed all redundant docstrings from helper functions
  • Removed all redundant comments (replaced with informative logging)
  • Used walrus operator in parse_eggtray_url for cleaner code
  • Changed parse_summary_json to raise instead of log when JSON is malformed
  • Simplified GitHub client creation - pass GitHub(api_key) directly (handles None internally)
  • Changed request.app["github_api_key"] to request.app["github_client"]

Test Improvements:

  • Renamed fixture from cli to api
  • Converted fixture file from JSON to markdown (eggtray_comment.md)
  • Used pytest.mark.parametrize for all pure function tests with multiple inputs
  • Simplified mocks - use MagicMock(attr=value) instead of separate assignments
  • Removed all test docstrings (names are descriptive enough)
  • Added empty lines between setup and asserts
  • Reduced number of asserts per test (whole response assertions)
  • Removed mock_ prefix from variables - named by what they represent
  • Moved RequestFailed import to top of file
  • Simplified test structure - direct fixture loading with Path
  • Pass github_client in app fixture to avoid patching
  • Added test for invalid JSON parsing (raises exception)

Dependencies:

  • Removed beautifulsoup4 (not needed)

Documentation (AGENTS.md):

  • Added guideline about redundant docstrings
  • Expanded walrus operator examples
  • Added pytest.mark.parametrize guideline
  • Added test structure guidelines (empty lines, minimal asserts)
  • Added guideline against test docstrings
  • Added guideline for naming in tests (no mock_ prefix)
  • Added pathlib preference over os.path
  • Added directness over abstraction in tests

All 62 tests passing ✓

Original prompt

This section details on the original issue you should resolve

<issue_title>Implement minimal GitHub profile reviews API</issue_title>
<issue_description>I decided that the backend part of juniorguru/junior.guru#1630 (comment) will live as part of the chick project. This means repurposing the project not only as a Discord bot, but also as a minimal backend API which serves tasks beyond Discord as well.

  • Edit README so that it reflects the pivot:
    • "A real-time, synchronous junior.guru Discord bot and API."
    • The purpose of the API is to provide better user experience for website visitors, as the main website is a purely static site. If it doesn't work, the site should fall back to letting the user to do the job themselves manually.
    • The same as the Discord bot part, the API should have no state. It should only transfer information to and from other services, such as GitHub.
    • In places where we refer to the whole project, let's use "chick" instead of "bot" as a common name: "The main asynchronous codebase should monitor whether chick is up and running. If it's not, it should fail the build, but non-critically (similar to checking broken links in HTML)."
  • GITHUB_API_KEY will be needed for the API part to work.

Currently chick already contains a HTTP server, which reports uptime status. Keep that. Apart from that, let's add two more routes:

  • "POST /checks/{github_username}" (no data) creates a new GitHub Issue on https://github.com/juniorguru/eggtray similar to Zpětná vazba na profil @bashynx eggtray#260, but the initial text will be "Z webu junior.guru přišel požadavek na zpětnou vazbu k profilu @{username}." It returns JSON with two items: URL of the issue and its number as an integer.
  • "GET /checks/{issue number} tries to load the issue from GitHub API and returns 404 if it doesn't exist. If it exists, it returns 202 if the check didn't finish yet. It returns 200 if the check did finish (no summary comment is present), and alonside it will return the JSON from the last summary comment available.

Use the https://github.com/yanyongyu/githubkit library as an SDK for working with GitHub API. Be resilient - prefer try/except around requests and logging error over raising (unless in debug mode) as it's important that the service stays alive and doesn't crash.</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

…checks/{issue_number}

Co-authored-by: honzajavorek <283441+honzajavorek@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement minimal GitHub profile reviews API Add minimal GitHub profile reviews API Feb 5, 2026
Copilot AI requested a review from honzajavorek February 5, 2026 16:40
Copy link
Copy Markdown
Member

@honzajavorek honzajavorek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at my comments. Also:

  • When your work is done, always run uv run ruff format ., uv run ruff check --fix ., and uv run pytest before you really call it done.

Add this to AGENTS.md (if not present already, create it).

…prove error handling, parse JSON from comments

Co-authored-by: honzajavorek <283441+honzajavorek@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Feb 6, 2026

Look at my comments. Also:

  • When your work is done, always run uv run ruff format ., uv run ruff check --fix ., and uv run pytest before you really call it done....

Created AGENTS.md with instructions to run ruff format, ruff check --fix, and pytest before completing tasks. All commands have been run - 48 tests passing. (d8b35cf)

Copy link
Copy Markdown
Member

@honzajavorek honzajavorek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments. Also see this advice on how to test githubkit code from the creator of the library - source yanyongyu/githubkit#98 (comment):

As you mentioned, the easiest way is to record the response data and save it (dict, JSON file, etc.). You can export the api response data by resp.text (raw str data) or resp.json() (json parsed dict/list).

After save the response data, you could rebuild the validated data with powerful Pydantic methods (Docs). For example:

from pydantic import TypeAdapter
from githubkit.versions.latest.models import SocialAccount

accounts: list[SocialAccount] = TypeAdapter(list[SocialAccount]).validate_json(json_content)
accounts: list[SocialAccount] = TypeAdapter(list[SocialAccount]).validate_python(python_list)

Record this approach to AGENTS.md as preferred over mocking and use it in tests where possible.

@honzajavorek
Copy link
Copy Markdown
Member

Also don't forget about #161 (comment)

…gging, add fixtures

Co-authored-by: honzajavorek <283441+honzajavorek@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Feb 6, 2026

See my comments. Also see this advice on how to test githubkit code from the creator of the library - source yanyongyu/githubkit#98 (comment):

As you mention...

Recorded githubkit testing approach to AGENTS.md with guidance to prefer fixtures over mocking, including Pydantic TypeAdapter examples from library creator. (c683f98)

…thub_client, fixture as markdown

Co-authored-by: honzajavorek <283441+honzajavorek@users.noreply.github.com>
Copy link
Copy Markdown
Author

Copilot AI commented Feb 6, 2026

Also don't forget about #161 (comment)

The referenced comment (2773332879) appears to be about using pytest.mark.parametrize, which has been fully addressed in this commit. (43bc415)

Copy link
Copy Markdown
Member

@honzajavorek honzajavorek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to remove some commas to see if the formatter squashes the lines, so apply those suggestions and let's see, but if the code folds and the formatter re-adds the commas, it's okay.

Let's rename github_client to github everywhere.



def parse_eggtray_url(url: str) -> tuple[str, str]:
if not (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, this is laughable. Please. I'll write it for you, but put it in your AGENTS.md somehow and learn how to use it.

if match := ...:
    return match.groups()
raise ValueError(...)


def extract_actions_url(comment_body: str) -> str | None:
if actions_match := re.search(
r"https://github\.com/juniorguru/eggtray/actions/runs/(\d+)",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized this won't work if we set different eggtray repo url. But it would be good enough if we just treat owner and repo as placeholders, we don't need to match them exactly. So don't add any arguments, just allow the owner and repo be variable in the regular expression.

response_data = {"status": CheckStatus.COMPLETE}

if summary_data := parse_summary_json(comment_body):
response_data["data"] = summary_data
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's ok to set these properties even if they evaluate as None, so simplify it like this:

response_data = {"status": ..., "summary": parse_summary_json(comment_body), "actions_url": extract_actions_url(comment_body)}

Note that I also suggest renaming "data" to "summary".

)
return json_response(
{"status": CheckStatus.PENDING},
status=202,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
status=202,
status=202

raise
return json_response(
{"error": "Internal server error", "debug_id": error_hash},
status=500,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
status=500,
status=500

assert call_kwargs["owner"] == "juniorguru"
assert call_kwargs["repo"] == "eggtray"
assert "testuser" in call_kwargs["title"]
assert "testuser" in call_kwargs["body"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert "testuser" in call_kwargs["body"]
assert "@testuser" in call_kwargs["body"]

This is actually important to test.

issue_data = MagicMock(state="open")
github_client.rest.issues.get.return_value = MagicMock(parsed_data=issue_data)
github_client.rest.issues.list_comments.return_value = MagicMock(
parsed_data=[MagicMock(body="Just a regular comment")]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could have it as a pytest fixture:

@pytest.fixture
def regular_comment():
    return MagicMock(body="Just a regular comment")

Then use it across the tests.

issue_data = MagicMock(state="closed")
github_client.rest.issues.get.return_value = MagicMock(parsed_data=issue_data)
github_client.rest.issues.list_comments.return_value = MagicMock(
parsed_data=[MagicMock(body=fixture_content.read_text())]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be another pytest fixture maybe? summary_comment?

@@ -0,0 +1,37 @@
Tak jsem si poctivě prošlo celý profil [github.com/bashynx](https://github.com/bashynx) a tady je moje zpětná vazba 🔬

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this file to summary_comment.md

async def test_get_check_status_complete_with_fixture(api):
fixture_content = Path(__file__).parent / "fixtures" / "eggtray_comment.md"
github_client = api.app["github_client"]
issue_data = MagicMock(state="closed")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's parametrize this test with just two values, and let's test that it works completely the same with state="closed" as with state="open". That way we'll verify the mechanics are dependent on summary comment and not state of the issue.

@honzajavorek honzajavorek marked this pull request as ready for review February 6, 2026 13:02
@honzajavorek honzajavorek marked this pull request as draft February 6, 2026 13:24
@honzajavorek
Copy link
Copy Markdown
Member

@copilot Address my comments.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR repurposes the chick project from a pure Discord bot into a Discord bot + API, adding two new API endpoints for GitHub profile reviews. The API provides progressive enhancement for the static junior.guru website, allowing users to request and check GitHub profile reviews via the eggtray automation system.

Changes:

  • Added POST /checks/{github_username} endpoint to create GitHub issues for profile review requests
  • Added GET /checks/{issue_number} endpoint to check the status and retrieve results of profile reviews
  • Updated project description and README to reflect the bot + API architecture

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
pyproject.toml Added githubkit and pytest-aiohttp dependencies
uv.lock Dependency lock file updates for new packages
README.md Updated description to reflect "Discord bot and API" pivot, added API endpoints to features list
AGENTS.md New file documenting code style conventions for AI agents (logging, walrus operators, minimal docstrings, test structure)
src/jg/chick/main.py Added GitHub API key and eggtray URL configuration options, web app initialization
src/jg/chick/web.py Removed (functionality moved to api.py)
src/jg/chick/api.py New API module with uptime endpoint and two GitHub profile review endpoints, plus helper functions for parsing and error handling
tests/test_api.py Comprehensive test coverage for all new API functionality using pytest parametrize and realistic fixtures
tests/fixtures/eggtray_comment.md Real GitHub comment fixture with JSON summary data for testing

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +204
web = Application()
routes = RouteTableDef()


def parse_eggtray_url(url: str) -> tuple[str, str]:
if not (
match := re.match(
r"https?://github\.com/([^/]+)/([^/]+)(?:/.*)?$", url.rstrip("/")
)
):
raise ValueError(f"Invalid GitHub repository URL: {url}")
return match.groups()


def find_summary_comment(comments: list) -> str | None:
for comment in reversed(comments):
if "```json" in (comment.body or ""):
return comment.body
return None


def parse_summary_json(comment_body: str) -> dict | None:
if json_match := re.search(r"```json\s*\n(.*?)\n```", comment_body, re.DOTALL):
return json.loads(json_match.group(1))
return None


def extract_actions_url(comment_body: str) -> str | None:
if actions_match := re.search(
r"https://github\.com/juniorguru/eggtray/actions/runs/(\d+)",
comment_body,
):
return actions_match.group(0)
return None


def generate_error_hash(error: Exception) -> str:
"""Generate a unique hash for an error for tracking purposes."""
error_string = (
f"{datetime.now(UTC).isoformat()}-{type(error).__name__}-{str(error)}"
)
return hashlib.sha256(error_string.encode()).hexdigest()[:8]


@routes.get("/")
async def index(request: Request) -> Response:
logger.info(f"Received {request!r}")
return json_response(
{
"status": "OK",
"launch_at": LAUNCH_AT.isoformat(),
"uptime_sec": (datetime.now(UTC) - LAUNCH_AT).total_seconds(),
}
)


@routes.post("/checks/{github_username}")
async def create_check(request: Request) -> Response:
github_username = request.match_info["github_username"]
logger.info(f"Creating check for GitHub username: {github_username}")

github_client = request.app["github_client"]
eggtray_url = request.app["eggtray_url"]
debug = request.app["debug"]

try:
eggtray_owner, eggtray_repo = parse_eggtray_url(eggtray_url)

title = f"Zpětná vazba na profil @{github_username}"
body = f"Z webu junior.guru přišel požadavek na zpětnou vazbu k profilu @{github_username}."

logger.debug(f"Creating issue in {eggtray_owner}/{eggtray_repo}")
issue = github_client.rest.issues.create(
owner=eggtray_owner,
repo=eggtray_repo,
title=title,
body=body,
labels=["check"],
)

issue_data = issue.parsed_data
logger.info(f"Created issue #{issue_data.number}: {issue_data.html_url}")

return json_response(
{
"url": issue_data.html_url,
"number": issue_data.number,
}
)

except Exception as e:
error_hash = generate_error_hash(e)
logger.error(f"Error creating check [{error_hash}]: {e}")
if debug:
raise
return json_response(
{"error": "Internal server error", "debug_id": error_hash},
status=500,
)


@routes.get("/checks/{issue_number}")
async def get_check_status(request: Request) -> Response:
try:
issue_number = int(request.match_info["issue_number"])
except ValueError:
return json_response(
{"error": "Invalid issue number"},
status=400,
)

logger.info(f"Getting check status for issue #{issue_number}")

github_client = request.app["github_client"]
eggtray_url = request.app["eggtray_url"]
debug = request.app["debug"]

try:
eggtray_owner, eggtray_repo = parse_eggtray_url(eggtray_url)

logger.info(
f"Fetching issue #{issue_number} from {eggtray_owner}/{eggtray_repo}"
)
try:
issue = github_client.rest.issues.get(
owner=eggtray_owner,
repo=eggtray_repo,
issue_number=issue_number,
)
except RequestFailed as e:
if e.response.status_code == 404:
logger.info(f"Issue #{issue_number} not found")
return json_response(
{"error": "Issue not found"},
status=404,
)
raise

issue_data = issue.parsed_data
logger.info(
f"Getting comments of issue #{issue_number} ({issue_data.state}) to check for summary"
)

comments = github_client.rest.issues.list_comments(
owner=eggtray_owner,
repo=eggtray_repo,
issue_number=issue_number,
)

if comment_body := find_summary_comment(comments.parsed_data):
logger.info(f"Check #{issue_number} is complete, parsing summary data")

response_data = {"status": CheckStatus.COMPLETE}

if summary_data := parse_summary_json(comment_body):
response_data["data"] = summary_data

if actions_url := extract_actions_url(comment_body):
response_data["actions_url"] = actions_url

return json_response(response_data, status=200)
else:
logger.info(
f"Check #{issue_number} is still in progress (no summary found)"
)
return json_response(
{"status": CheckStatus.PENDING},
status=202,
)

except Exception as e:
error_hash = generate_error_hash(e)
logger.error(f"Error getting check status [{error_hash}]: {e}")
if debug:
raise
return json_response(
{"error": "Internal server error", "debug_id": error_hash},
status=500,
)


web.add_routes(routes)
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API endpoints lack CORS (Cross-Origin Resource Sharing) configuration. According to the README, these endpoints will be called from the junior.guru static website. Without CORS headers, browsers will block these cross-origin API requests. Consider adding aiohttp-cors middleware or manually setting appropriate CORS headers (Access-Control-Allow-Origin, etc.) to enable the static website to call these endpoints.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +82
@routes.post("/checks/{github_username}")
async def create_check(request: Request) -> Response:
github_username = request.match_info["github_username"]
logger.info(f"Creating check for GitHub username: {github_username}")
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The github_username parameter lacks input validation. Malicious or malformed usernames (e.g., containing special characters, being excessively long, or empty) could be passed directly to the GitHub API or used in issue titles/bodies, potentially causing issues or abuse. Consider validating that the username matches GitHub's username format (alphanumeric plus hyphens, 1-39 characters, cannot start/end with hyphen).

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +56
def extract_actions_url(comment_body: str) -> str | None:
if actions_match := re.search(
r"https://github\.com/juniorguru/eggtray/actions/runs/(\d+)",
comment_body,
):
return actions_match.group(0)
return None
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extract_actions_url function is hardcoded to only match URLs from the juniorguru/eggtray repository, but eggtray_url is configurable. If the eggtray_url is changed to a different repository, the actions URL extraction will fail silently. Consider making the regex pattern dynamic based on the configured eggtray_url, or document this limitation clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +63
error_string = (
f"{datetime.now(UTC).isoformat()}-{type(error).__name__}-{str(error)}"
)
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generate_error_hash function includes the current timestamp in the error hash. This means the same error occurring at different times will have different hashes, making it harder to track recurring issues. Consider removing the timestamp from the hash calculation if the goal is to identify similar errors, or keep only the date portion (without time) for daily grouping.

Suggested change
error_string = (
f"{datetime.now(UTC).isoformat()}-{type(error).__name__}-{str(error)}"
)
error_string = f"{type(error).__name__}-{str(error)}"

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +93
def test_parse_summary_json_invalid_json():
comment_body = "```json\n{invalid json}\n```"

with pytest.raises(Exception):
parse_summary_json(comment_body)
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects any Exception to be raised when parsing invalid JSON, but parse_summary_json will specifically raise json.JSONDecodeError (a subclass of ValueError). The test should be more specific about which exception type is expected, e.g., pytest.raises(json.JSONDecodeError), to properly document and verify the actual behavior.

Copilot uses AI. Check for mistakes.
@honzajavorek honzajavorek assigned Copilot and unassigned Copilot Feb 6, 2026
@honzajavorek
Copy link
Copy Markdown
Member

@copilot Address comments from both my and copilot review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement minimal GitHub profile reviews API

3 participants