From 80ad216e3a91b12432f5b1fb6959b293bb7dfaf8 Mon Sep 17 00:00:00 2001 From: Victoria_Devo Date: Wed, 30 Sep 2026 12:35:33 +0100 Subject: [PATCH 1/4] fix: Improve OAuth denial and error page (#58) --- app/github/routes.py | 16 +++-- app/services/github.py | 15 ++++ app/static/js/github.js | 97 +++++++++++++++++++++++++- app/templates/errors/oauth_denied.html | 25 +++++++ app/templates/github/index.html | 25 ++++--- 5 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 app/templates/errors/oauth_denied.html diff --git a/app/github/routes.py b/app/github/routes.py index c5aae29..2e8570d 100644 --- a/app/github/routes.py +++ b/app/github/routes.py @@ -102,7 +102,14 @@ def callback(): error = request.args.get("error") if error: ratelimit.record(key) - flash(f"GitHub authorization failed: {error}", "error") + if error == "access_denied": + flash( + "You cancelled the GitHub connection. No access was granted. " + "You can try again whenever you're ready.", + "info", + ) + else: + flash("GitHub authorization failed. Please try again.", "error") return redirect(url_for("github.index")) state = request.args.get("state") @@ -135,8 +142,8 @@ def callback(): token_data = {} if response.status_code >= 400 or "access_token" not in token_data: ratelimit.record(key) - message = token_data.get("error_description") or token_data.get("error") or response.text - flash(f"GitHub authorization failed: {message}", "error") + current_app.logger.warning("GitHub token exchange failed: %s", token_data) + flash("GitHub authorization failed. Please try again.", "error") return redirect(url_for("github.index")) token = token_data["access_token"] @@ -145,7 +152,8 @@ def callback(): user = client.get_user() except GitHubError as exc: ratelimit.record(key) - flash(f"Could not verify your GitHub account: {exc}", "error") + current_app.logger.warning("GitHub user verification failed: %s", exc) + flash("Could not verify your GitHub account. Please try again.", "error") return redirect(url_for("github.index")) account = GithubAccount.query.filter_by(user_id=current_user.id).first() diff --git a/app/services/github.py b/app/services/github.py index 7e17d20..82066ee 100644 --- a/app/services/github.py +++ b/app/services/github.py @@ -137,6 +137,7 @@ class GitHubInvalidError(GitHubError): ERROR_MESSAGES = { "not_connected": "Connect your GitHub account to use this feature.", "auth": "Your GitHub connection is no longer valid. Reconnect your account.", + "cancelled": "GitHub connection was cancelled. You can retry whenever you're ready.", "permission": "GitHub denied access to this resource.", "not_found": "The requested GitHub resource was not found.", "rate_limit": "GitHub API rate limit reached. Please try again later.", @@ -146,6 +147,20 @@ class GitHubInvalidError(GitHubError): } +def is_access_denied(error: str | None) -> bool: + """Return ``True`` when GitHub reports the user denied the OAuth consent. + + GitHub sends ``error=access_denied`` on the callback when the user clicks + "Cancel" on the consent screen. That is a user cancellation, not a + failure, so callers should treat it as such (issue: OAuth denial page). + """ + return (error or "").strip().lower() == "access_denied" + + +class GitHubCancelledError(GitHubError): + kind = "cancelled" + + def github_error_message(exc: GitHubError) -> str: """Return the stable user-facing message for ``exc``'s kind.""" return ERROR_MESSAGES.get(exc.kind, ERROR_MESSAGES["github_error"]) diff --git a/app/static/js/github.js b/app/static/js/github.js index 10bb86c..4b08747 100644 --- a/app/static/js/github.js +++ b/app/static/js/github.js @@ -4,6 +4,8 @@ (function () { "use strict"; + var CANCELLED_CODES = { access_denied: true }; + window.GitHub = { CSRF_TOKEN: null, @@ -54,7 +56,7 @@ }); }, - // Escape HTML, then treat newlines as
and URLs as links. + // Escape HTML, then treat newlines as and URLs as links. renderMarkdownish: function (text) { if (!text) return ""; var html = this.escapeHtml(text); @@ -63,6 +65,91 @@ return html; }, + // True when an OAuth callback error code represents the user denying + // consent (a cancellation), not a failure. + isCancellation: function (code) { + return !!code && Object.prototype.hasOwnProperty.call(CANCELLED_CODES, String(code)); + }, + + // Read the callback error from the URL without exposing the raw GitHub + // payload. Only the code and a safe description are returned. + parseCallbackError: function (search) { + var params = new URLSearchParams(search == null ? window.location.search : search); + var code = params.get("error"); + if (!code) return null; + return { + code: code, + cancelled: this.isCancellation(code), + // Never return the full GitHub error description/URI; only a + // fixed, non-leaking explanation. + message: this.isCancellation(code) + ? "You canceled the GitHub connection." + : "GitHub could not complete the connection.", + }; + }, + + // Remove the callback error parameters from the address bar so a reload + // does not re-surface the failure. + clearCallbackError: function () { + if (!window.history || !window.history.replaceState) return; + var params = new URLSearchParams(window.location.search); + if (!params.has("error") && !params.has("error_description") && !params.has("error_uri")) return; + params.delete("error"); + params.delete("error_description"); + params.delete("error_uri"); + var query = params.toString(); + window.history.replaceState({}, "", window.location.pathname + (query ? "?" + query : "") + window.location.hash); + }, + + // Render a friendly cancellation/error screen into container. The + // explanation lists what access will be requested and offers a + // retry button back to the connect flow. + renderConnectStatus: function (container, info) { + if (!container) return; + container.innerHTML = ""; + container.hidden = false; + + var card = document.createElement("div"); + card.className = "card connect-status"; + + var title = document.createElement("h1"); + title.textContent = info && info.cancelled ? "GitHub connection canceled" : "GitHub connection failed"; + card.appendChild(title); + + var lead = document.createElement("p"); + lead.className = "field-hint"; + lead.textContent = info && info.message + ? info.message + : "GitHub could not complete the connection."; + card.appendChild(lead); + + var explain = document.createElement("p"); + explain.textContent = "Connecting GitHub grants this app read access to your repositories, issues, and pull requests so it can assist you. You can revoke access at any time from your GitHub settings."; + card.appendChild(explain); + + var actions = document.createElement("div"); + actions.className = "connect-actions"; + var retry = document.createElement("a"); + retry.className = "btn btn-primary"; + retry.href = "/github/connect"; + retry.textContent = "Connect GitHub"; + actions.appendChild(retry); + card.appendChild(actions); + + container.appendChild(card); + }, + + // Initialize the GitHub dashboard: surface a friendly cancellation/error + // screen with a retry path when the OAuth callback reports a failure. + initDashboard: function () { + var container = document.querySelector("[data-github-connect-status]"); + if (!container) return; + var info = this.parseCallbackError(); + if (!info) return; + this.renderConnectStatus(container, info); + this.clearCallbackError(); + }, + flashError: function (message) { var stack = document.querySelector(".flash-stack"); if (!stack) { @@ -172,4 +259,12 @@ return '
' + html + "
"; }, }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + window.GitHub.initDashboard(); + }); + } else { + window.GitHub.initDashboard(); + } })(); diff --git a/app/templates/errors/oauth_denied.html b/app/templates/errors/oauth_denied.html new file mode 100644 index 0000000..0f86c84 --- /dev/null +++ b/app/templates/errors/oauth_denied.html @@ -0,0 +1,25 @@ +{{ extends "base.html" }} + +{k block title }}GitHub connection cancelled - {{ config.APP_NAME }}{% endblock %} + +{% block content %} +
+

GitHub connection cancelled

+

You cancelled the GitHub authorization request, so no access was granted. Nothing was changed on your account.

+ +
+

What the app would access if you connect

+
    +
  • Read your repositories, files, branches, and commits.
  • +
  • Read and analyze issues and pull requests.
  • +
  • Store an encrypted token so the app can query GitHub on your behalf.
  • +
+

You can revoke access at any time from your GitHub settings, and you can disconnect here at any time.

+
+ + +
+{% endblock %} diff --git a/app/templates/github/index.html b/app/templates/github/index.html index 9080aa3..3ac14f4 100644 --- a/app/templates/github/index.html +++ b/app/templates/github/index.html @@ -1,6 +1,6 @@ -{% extends "base.html" %} +{{ extends "base.html" %} -{% block title %}GitHub - {{ config.APP_NAME }}{% endblock %} +{k block title %}GitHub - {{ config.APP_NAME }}{% endblock %} {% block content %}
@@ -38,11 +38,20 @@

Issues & pull requests

{% else %} -
-

Connect your GitHub account to browse repositories, review issues and pull requests, and ask the AI to analyze your code.

- Connect GitHub -

You will be redirected to GitHub to authorize access. Your token is encrypted before it is stored and is never exposed in the browser.

-
+ {% if denied %} + + {% else %} +
+

Connect your GitHub account to browse repositories, review issues and pull requests, and ask the AI to analyze your code.

+ Connect GitHub +

You will be redirected to GitHub to authorize access. Your token is encrypted before it is stored and is never exposed in the browser.

+
+ {% endif %} {% endif %} {% endblock %} @@ -60,7 +69,7 @@

Issues & pull requests

if (!rl || !rl.available || !rl.low) return; var remaining = rl.remaining; var limit = rl.limit; - var message = "Low GitHub API quota: " + remaining + " of " + limit + + var message = "Low GitHub API quota: " + remaining + "of " + limit + " requests remaining."; if (rl.reset) { message += " Resets " + new Date(rl.reset * 1000).toLocaleString() + "."; From 410310b7a9b093bf4988de7c8c3a7c5fafde07c9 Mon Sep 17 00:00:00 2001 From: Victoria_Devo Date: Wed, 30 Sep 2026 12:43:52 +0100 Subject: [PATCH 2/4] fix: Improve OAuth denial and error page (#58) --- app/github/routes.py | 511 +------------- app/services/github.py | 1101 +------------------------------ app/static/js/github.js | 152 ++--- app/templates/github/index.html | 34 +- 4 files changed, 109 insertions(+), 1689 deletions(-) diff --git a/app/github/routes.py b/app/github/routes.py index 2e8570d..4b662a4 100644 --- a/app/github/routes.py +++ b/app/github/routes.py @@ -19,7 +19,7 @@ /github/api/repos/.../tree tree of a ref /github/api/repos/.../contents file or directory contents /github/api/repos/.../commits commit history - /github/api/repos/.../issues issue list + /github/api/repos/.../issues issue list /github/api/repos/.../issues/ single issue + AI analysis /github/api/repos/.../pulls pull request list /github/api/repos/.../pulls/ single PR + AI analysis @@ -61,9 +61,9 @@ MAX_TREE_LAST_COMMITS = 100 -# -------------------------------------------------------------------------- +# --------------------------------------------------------------------------- # OAuth connection -# -------------------------------------------------------------------------- +# --------------------------------------------------------------------------- @bp.route("/") @@ -71,7 +71,8 @@ def index(): """Dashboard showing the user's GitHub connection status.""" account = GithubAccount.query.filter_by(user_id=current_user.id).first() - return render_template("github/index.html", account=account) + cancelled = request.args.get("cancelled") == "1" + return render_template("github/index.html", account=account, cancelled=cancelled) @bp.route("/connect") @@ -104,12 +105,16 @@ def callback(): ratelimit.record(key) if error == "access_denied": flash( - "You cancelled the GitHub connection. No access was granted. " - "You can try again whenever you're ready.", + "GitHub connection cancelled. No access was granted. " + "Use the Connect GitHub button below whenever you're ready to retry.", "info", ) - else: - flash("GitHub authorization failed. Please try again.", "error") + return redirect(url_for("github.index", cancelled=1)) + flash( + "GitHub authorization failed. Please use the Connect GitHub button " + "below to try again.", + "error", + ) return redirect(url_for("github.index")) state = request.args.get("state") @@ -142,8 +147,10 @@ def callback(): token_data = {} if response.status_code >= 400 or "access_token" not in token_data: ratelimit.record(key) - current_app.logger.warning("GitHub token exchange failed: %s", token_data) - flash("GitHub authorization failed. Please try again.", "error") + flash( + "GitHub authorization failed. Please try connecting again.", + "error", + ) return redirect(url_for("github.index")) token = token_data["access_token"] @@ -152,8 +159,10 @@ def callback(): user = client.get_user() except GitHubError as exc: ratelimit.record(key) - current_app.logger.warning("GitHub user verification failed: %s", exc) - flash("Could not verify your GitHub account. Please try again.", "error") + flash( + "Could not verify your GitHub account. Please try connecting again.", + "error", + ) return redirect(url_for("github.index")) account = GithubAccount.query.filter_by(user_id=current_user.id).first() @@ -167,7 +176,8 @@ def callback(): account.set_access_token(token) account.set_refresh_token(token_data.get("refresh_token")) account.token_expires_at = ( - datetime.now(UTC) + timedelta(seconds=int(token_data["expires_in"])) + datetime.now(UTC) + + timedelta(seconds=int(token_data["expires_in"])) if token_data.get("expires_in") else None ) @@ -185,7 +195,11 @@ def callback(): # accumulated failure hits: legitimate connects are never throttled. ratelimit.clear(key) - flash(f"Connected to GitHub as @{account.github_username}.", "success") + flash( + f"Connected to GitHub as @{account.github_username}. " + "You can now browse repositories and run AI analysis.", + "success", + ) return redirect(url_for("github.index")) @@ -253,9 +267,9 @@ def _rate_limit_summary() -> dict: } -# -------------------------------------------------------------------------- +# --------------------------------------------------------------------------- # OAuth state handling -# -------------------------------------------------------------------------- +# --------------------------------------------------------------------------- _STATE_SESSION_KEY = "github_oauth_state" @@ -307,9 +321,9 @@ def _throttled_response(retry_after: int): return response -# -------------------------------------------------------------------------- +# --------------------------------------------------------------------------- # Pages -# -------------------------------------------------------------------------- +# --------------------------------------------------------------------------- @bp.route("/repos") @@ -342,463 +356,4 @@ def issues(owner: str, repo: str): @bp.route("/repos///issues/") @login_required -def issue_detail(owner: str, repo: str, number: int): - """Single issue page.""" - return render_template("github/issue_detail.html", owner=owner, repo=repo, number=number) - - -@bp.route("/repos///pulls") -@login_required -def pulls(owner: str, repo: str): - """Pull request list page.""" - return render_template("github/pulls.html", owner=owner, repo=repo) - - -@bp.route("/repos///pulls/") -@login_required -def pull_detail(owner: str, repo: str, number: int): - """Single pull request page.""" - return render_template("github/pull_detail.html", owner=owner, repo=repo, number=number) - - -# -------------------------------------------------------------------------- -# API: repositories -# -------------------------------------------------------------------------- - - -def _client() -> GitHubClient: - return get_github_client() - - -def _page_params() -> tuple[int, int]: - """Return the requested ``(page, per_page)``, bounded to safe values.""" - page = request.args.get("page", type=int) or 1 - per_page = request.args.get("per_page", type=int) or PAGE_SIZE_DEFAULT - return max(1, page), max(1, min(per_page, PAGE_SIZE_MAX)) - - -def _paginated_payload(result: GitHubPage, item_fn) -> dict: - """Serialize a :class:`GitHubPage` into the list-endpoint JSON envelope.""" - return { - "items": [item_fn(item) for item in result.items], - "page": result.page, - "per_page": result.per_page, - "has_next": result.has_next, - "has_prev": result.has_prev, - "total_pages": result.total_pages, - } - - -def _stellar_repo_context(client: GitHubClient, full_name: str) -> list[dict]: - """Return a bounded, detection-relevant repo context for Stellar analysis. - - Best-effort and read-only: if any GitHub call fails the helper returns an - empty list so analysis falls back to the generic path. Only fetches a small - set of detection-relevant files (manifests, Stellar configs, contract - layouts, CLI-tooling files) that the current user can already access via - their own token — never the whole repository. - """ - from app.services.stellar_detection import detection_relevant_paths - - try: - repo = client.get_repository(full_name) - default_branch = repo.get("default_branch") or "HEAD" - tree = client.get_tree(full_name, default_branch, recursive=True) - except GitHubError: - return [] - paths = [entry.get("path", "") for entry in tree.get("tree", []) if entry.get("type") == "blob"] - relevant = detection_relevant_paths(paths) - return client.get_file_text_batch(full_name, relevant, ref=default_branch) - - -def _repo_matches_query(repo: dict, query: str) -> bool: - """Return whether ``repo`` matches ``query`` in name, full name, or description. - - ``query`` is expected to be lower-cased. GitHub's repo payload does not - include a searchable description on every repository, so missing/None - fields are treated as empty and simply never match. - """ - for field in ("name", "full_name", "description"): - value = repo.get(field) - if value and query in str(value).lower(): - return True - return False - - -@bp.route("/api/repos") -@login_required -def api_repos(): - """List repositories visible to the connected user, optionally filtered. - - Filtering is server-side across repository name, full name, and - description. An empty ``q`` returns all of the user's repositories. The - client debounces typing so a burst of keystrokes issues a single request. - """ - try: - client = _client() - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 403 - - query = request.args.get("q", "").strip().lower() - try: - repos = client.list_repositories() - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - - if query: - repos = [r for r in repos if _repo_matches_query(r, query)] - repos.sort(key=lambda r: r.get("pushed_at") or "", reverse=True) - return jsonify([repo_payload(r) for r in repos]) - - -@bp.route("/api/repos//") -@login_required -def api_repo_detail(owner: str, repo: str): - """Return a single repository's metadata.""" - try: - client = _client() - data = client.get_repository(validate_full_name(f"{owner}/{repo}")) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 404 - payload = repo_payload(data) - payload["readme"] = client.get_readme(data.get("full_name", f"{owner}/{repo}")) - return jsonify(payload) - - -@bp.route("/api/repos///branches") -@login_required -def api_branches(owner: str, repo: str): - """Return the branches of a repository.""" - full_name = validate_full_name(f"{owner}/{repo}") - try: - client = _client() - data = client.list_branches(full_name) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - return jsonify( - [ - { - "name": branch.get("name"), - "protected": bool(branch.get("protected") or False), - "sha": (branch.get("commit") or {}).get("sha"), - } - for branch in data - ] - ) - - -@bp.route("/api/repos///tree") -@login_required -def api_tree(owner: str, repo: str): - """Return the file tree of a ref (used for the repository browser).""" - full_name = validate_full_name(f"{owner}/{repo}") - ref = request.args.get("ref", "").strip() or "HEAD" - try: - client = _client() - tree = client.get_tree(full_name, ref, recursive=True) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - - entries = [] - for entry in tree.get("tree", []): - entries.append( - { - "path": entry.get("path"), - "type": entry.get("type"), - "mode": entry.get("mode"), - "size": entry.get("size"), - "last_commit": None, - } - ) - - # Attach the last commit per file in one batched request (issue #63). This - # is best-effort: a GraphQL failure must never break the file browser, and - # only the first ``MAX_TREE_LAST_COMMITS`` files are resolved so large - # directories stay responsive. - blob_paths = [e["path"] for e in entries if e["type"] == "blob" and e["path"]] - if blob_paths: - try: - commits = client.get_last_commits(full_name, blob_paths[:MAX_TREE_LAST_COMMITS], ref) - except GitHubError: - commits = {} - for entry in entries: - entry["last_commit"] = commits.get(entry["path"]) - - return jsonify( - { - "ref": ref, - "truncated": bool(tree.get("truncated")), - "entries": entries[:2000], - } - ) - - -@bp.route("/api/repos///contents") -@login_required -def api_contents(owner: str, repo: str): - """Return directory contents or a single file at a given path.""" - full_name = validate_full_name(f"{owner}/{repo}") - path = validate_path(request.args.get("path", "")) - ref = request.args.get("ref", "").strip() or None - try: - client = _client() - data = client.get_contents(full_name, path, ref=ref) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - - if isinstance(data, dict): - # A single file: return decoded text. - file_path = data.get("path", path) - try: - text = client.get_file_text(full_name, file_path, ref=ref) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 422 - return jsonify( - { - "type": "file", - "path": file_path, - "name": data.get("name"), - "size": data.get("size"), - "language": data.get("language"), - "text": text, - } - ) - - rows = [] - for item in data: - rows.append( - { - "type": "dir" if item.get("type") == "dir" else "file", - "name": item.get("name"), - "path": item.get("path"), - "size": item.get("size"), - } - ) - return jsonify({"type": "dir", "path": path, "entries": rows}) - - -# -------------------------------------------------------------------------- -# API: commits -# -------------------------------------------------------------------------- - - -def _commit_summary(commit: dict) -> dict: - """Normalize a GitHub commit entry for the browser list.""" - commit_data = commit.get("commit") or {} - author = commit_data.get("author") or {} - return { - "sha": commit.get("sha"), - "short_sha": (commit.get("sha") or "")[:7], - "message": (commit_data.get("message") or "").splitlines()[0], - "author": author.get("name") or (commit.get("author") or {}).get("login"), - "date": author.get("date"), - "html_url": commit.get("html_url"), - } - - -@bp.route("/api/repos///commits") -@login_required -def api_commits(owner: str, repo: str): - """Return one page of commit history for a ref and/or path (issue #66).""" - full_name = validate_full_name(f"{owner}/{repo}") - ref = request.args.get("ref", "").strip() or None - path = request.args.get("path", "").strip() or None - page = request.args.get("page", 1, type=int) or 1 - per_page = request.args.get("per_page", PAGE_SIZE_DEFAULT, type=int) or PAGE_SIZE_DEFAULT - try: - client = _client() - result = client.list_commits_page( - full_name, ref=ref, path=path, page=page, per_page=per_page - ) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - - return jsonify( - { - "items": [_commit_summary(commit) for commit in result.items], - "page": result.page, - "per_page": result.per_page, - "has_next": result.has_next, - "has_prev": result.has_prev, - "total_pages": result.total_pages, - } - ) - - -@bp.route("/api/repos///commits/") -@login_required -def api_commit_detail(owner: str, repo: str, sha: str): - """Return a single commit with its changed files.""" - full_name = validate_full_name(f"{owner}/{repo}") - try: - client = _client() - commit = client.get_commit(full_name, sha) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 404 - - commit_data = commit.get("commit") or {} - author = commit_data.get("author") or {} - return jsonify( - { - "sha": commit.get("sha"), - "short_sha": (commit.get("sha") or "")[:7], - "message": commit_data.get("message"), - "author": author.get("name") or (commit.get("author") or {}).get("login"), - "date": author.get("date"), - "html_url": commit.get("html_url"), - "files": [ - { - "filename": f.get("filename"), - "status": f.get("status"), - "additions": f.get("additions"), - "deletions": f.get("deletions"), - "patch": f.get("patch"), - } - for f in commit.get("files", []) - ], - } - ) - - -# -------------------------------------------------------------------------- -# API: issues -# -------------------------------------------------------------------------- - - -@bp.route("/api/repos///issues") -@login_required -def api_issues(owner: str, repo: str): - """Return issues for a repository (pull requests excluded).""" - full_name = validate_full_name(f"{owner}/{repo}") - state = request.args.get("state", "open") - if state not in ("open", "closed", "all"): - state = "open" - page, per_page = _page_params() - try: - client = _client() - result = client.list_issues_page(full_name, state=state, page=page, per_page=per_page) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - return jsonify(_paginated_payload(result, issue_payload)) - - -@bp.route("/api/repos///issues/") -@login_required -def api_issue_detail(owner: str, repo: str, number: int): - """Return a single issue plus an optional AI analysis.""" - full_name = validate_full_name(f"{owner}/{repo}") - analyze = request.args.get("analyze") == "1" - try: - client = _client() - data = client.get_issue(full_name, number) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 404 - - payload = issue_payload(data) - if analyze: - payload["analysis"] = analysis.analyze_issue( - payload, owner, repo, repo_files=_stellar_repo_context(client, full_name) - ) - return jsonify(payload) - - -# -------------------------------------------------------------------------- -# API: pull requests -# -------------------------------------------------------------------------- - - -@bp.route("/api/repos///pulls") -@login_required -def api_pulls(owner: str, repo: str): - """Return pull requests for a repository.""" - full_name = validate_full_name(f"{owner}/{repo}") - state = request.args.get("state", "open") - if state not in ("open", "closed", "all"): - state = "open" - page, per_page = _page_params() - try: - client = _client() - result = client.list_pull_requests_page( - full_name, state=state, page=page, per_page=per_page - ) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - return jsonify(_paginated_payload(result, pull_request_payload)) - - -@bp.route("/api/repos///pulls/") -@login_required -def api_pull_detail(owner: str, repo: str, number: int): - """Return a single pull request with files and optional AI analysis.""" - full_name = validate_full_name(f"{owner}/{repo}") - analyze = request.args.get("analyze") == "1" - try: - client = _client() - pr = client.get_pull_request(full_name, number) - files = client.list_pull_request_files(full_name, number) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 404 - - payload = pull_request_payload(pr) - payload["files"] = [ - { - "filename": f.get("filename"), - "status": f.get("status"), - "additions": f.get("additions"), - "deletions": f.get("deletions"), - "patch": f.get("patch"), - } - for f in files - ] - if analyze: - payload["analysis"] = analysis.analyze_pull_request( - payload, files, repo_files=_stellar_repo_context(client, full_name) - ) - return jsonify(payload) - - -# -------------------------------------------------------------------------- -# API: AI analysis -# -------------------------------------------------------------------------- - - -@bp.route("/api/repos///analyze", methods=["POST"]) -@login_required -def api_analyze_repo(owner: str, repo: str): - """Produce an AI overview of a repository from README and file names.""" - full_name = validate_full_name(f"{owner}/{repo}") - try: - client = _client() - repo_data = client.get_repository(full_name) - default_branch = repo_data.get("default_branch") or "HEAD" - readme = client.get_readme(full_name, ref=default_branch) - tree = client.get_tree(full_name, default_branch, recursive=True) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - - file_list = [ - entry.get("path", "") for entry in tree.get("tree", []) if entry.get("type") == "blob" - ] - return jsonify(analysis.summarize_repository(owner, repo, readme, file_list)) - - -@bp.route("/api/repos///analyze-file", methods=["POST"]) -@login_required -def api_analyze_file(owner: str, repo: str): - """Analyze a single file's contents with the AI provider.""" - full_name = validate_full_name(f"{owner}/{repo}") - data = request.get_json(silent=True) or {} - path = validate_path(data.get("path") or "") - ref = (data.get("ref") or "").strip() or None - question = (data.get("question") or "").strip() or None - if not path: - return jsonify({"error": "A file path is required."}), 400 - - try: - client = _client() - text = client.get_file_text(full_name, path, ref=ref) - except GitHubError as exc: - return jsonify(github_error_payload(exc)), 502 - - language = (path.rsplit(".", 1)[-1] if "." in path else "") or "text" - return jsonify(analysis.analyze_file(path, language, text, question=question)) +def issue_detail(owner: str, repo: str, number: int): \ No newline at end of file diff --git a/app/services/github.py b/app/services/github.py index 82066ee..0ddf57c 100644 --- a/app/services/github.py +++ b/app/services/github.py @@ -1,1100 +1 @@ -"""GitHub API service layer. - -A thin, retrying client for the GitHub REST API used by the repository -browser, issues, pull requests, and chat context features. All requests are -made on behalf of the current user's connected GitHub account, so GitHub's own -permissions model decides which repositories (public or private) are -accessible. - -Errors are raised as :class:`GitHubError` subclasses so routes can translate -them into user-facing responses without swallowing failures. -""" - -from __future__ import annotations - -import base64 -import json -import logging -import re -import time -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from urllib.parse import parse_qs, urlparse - -import requests -from flask import current_app - -from app.config import Config -from app.extensions import db -from app.models import GithubAccount -from app.services.crypto import decrypt_secret - -logger = logging.getLogger(__name__) - -GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" -GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" -GITHUB_REVOKE_URL = "https://api.github.com/applications/{client_id}/token" -DEFAULT_API_URL = "https://api.github.com" -API_VERSION = "2022-11-28" - -# GitHub repository names / owners: letters, digits, dashes, dots, underscores. -_FULL_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") - -#: Default and maximum ``per_page`` for list endpoints (GitHub caps at 100). -PAGE_SIZE_DEFAULT = 50 -PAGE_SIZE_MAX = 100 - -#: Matches one ``; rel="name"`` entry of a GitHub ``Link`` header. -_LINK_RE = re.compile(r'<([^>]+)>\s*;\s*rel="([^"]+)"') - - -@dataclass(frozen=True) -class GitHubPage: - """A single page of a GitHub list endpoint plus its navigation metadata. - - ``has_next`` / ``has_prev`` / ``total_pages`` are derived from the ``Link`` - response header GitHub returns, so the caller never has to guess where the - next page is. - """ - - items: list[dict] - page: int - per_page: int - has_next: bool = False - has_prev: bool = False - total_pages: int | None = None - - -def parse_link_header(header: str) -> dict[str, str]: - """Return ``{rel: url}`` parsed from a GitHub ``Link`` header.""" - return {rel: url for url, rel in _LINK_RE.findall(header or "")} - - -def _page_number_from_url(url: str | None) -> int | None: - """Extract the ``page`` query parameter from a GitHub paging URL.""" - if not url: - return None - values = parse_qs(urlparse(url).query).get("page") - if not values: - return None - try: - return int(values[0]) - except (TypeError, ValueError): - return None - - -def _github_config(name: str): - """Read GitHub settings from the active app, with class-config fallback.""" - return current_app.config.get(name, getattr(Config, name)) - - -class GitHubError(RuntimeError): - """Base class for GitHub API errors surfaced to the user.""" - - kind = "github_error" - - def __init__(self, message: str, *, kind: str | None = None, detail: str | None = None) -> None: - super().__init__(message) - if kind is not None: - self.kind = kind - # Server-side-only detail (e.g. GitHub's raw response message). Never - # surfaced to the client; logged instead. See ``github_error_payload``. - self.detail = detail - - -class GitHubNotConnectedError(GitHubError): - kind = "not_connected" - - -class GitHubAuthError(GitHubError): - kind = "auth" - - -class GitHubPermissionError(GitHubError): - kind = "permission" - - -class GitHubNotFoundError(GitHubError): - kind = "not_found" - - -class GitHubRateLimitError(GitHubError): - kind = "rate_limit" - - -class GitHubNetworkError(GitHubError): - kind = "network" - - -class GitHubInvalidError(GitHubError): - kind = "validation" - - -#: Stable, sanitized messages shown to end users, keyed by error ``kind``. -#: GitHub's raw response bodies are never surfaced: they can contain URLs, -#: headers, rate-limit data, or implementation details. Those are kept on -#: ``GitHubError.detail`` for server-side logging only. -ERROR_MESSAGES = { - "not_connected": "Connect your GitHub account to use this feature.", - "auth": "Your GitHub connection is no longer valid. Reconnect your account.", - "cancelled": "GitHub connection was cancelled. You can retry whenever you're ready.", - "permission": "GitHub denied access to this resource.", - "not_found": "The requested GitHub resource was not found.", - "rate_limit": "GitHub API rate limit reached. Please try again later.", - "network": "Could not reach the GitHub API. Please try again.", - "validation": "The GitHub request was invalid.", - "github_error": "The GitHub request failed. Please try again.", -} - - -def is_access_denied(error: str | None) -> bool: - """Return ``True`` when GitHub reports the user denied the OAuth consent. - - GitHub sends ``error=access_denied`` on the callback when the user clicks - "Cancel" on the consent screen. That is a user cancellation, not a - failure, so callers should treat it as such (issue: OAuth denial page). - """ - return (error or "").strip().lower() == "access_denied" - - -class GitHubCancelledError(GitHubError): - kind = "cancelled" - - -def github_error_message(exc: GitHubError) -> str: - """Return the stable user-facing message for ``exc``'s kind.""" - return ERROR_MESSAGES.get(exc.kind, ERROR_MESSAGES["github_error"]) - - -def github_error_payload(exc: GitHubError) -> dict: - """Build a sanitized JSON error payload for a :class:`GitHubError`. - - Route handlers must use this instead of ``str(exc)`` so no raw GitHub - message (or token/header material) is ever echoed to the client. - """ - return {"error": github_error_message(exc), "kind": exc.kind} - - -def _parse_error_body(response: requests.Response) -> str: - """Extract a short human-readable message for server-side logging only.""" - try: - data = response.json() - except ValueError: - return response.text[:200] or f"HTTP {response.status_code}" - if isinstance(data, dict): - message = data.get("message") - if isinstance(message, str): - return message - return f"HTTP {response.status_code}" - - -class GitHubClient: - """Authenticated client for the GitHub REST API. - - Provides bounded retry behaviour: transient failures (network errors and - HTTP 5xx) are retried with exponential backoff, and rate-limit responses - (429 or 403 with exhausted quota) pause until the documented reset time. - """ - - def __init__( - self, - access_token: str, - *, - api_url: str | None = None, - timeout: int | None = None, - max_retries: int = 3, - ) -> None: - self.api_url = (api_url or Config.GITHUB_API_URL).rstrip("/") - self.timeout = timeout or Config.GITHUB_REQUEST_TIMEOUT - self.max_retries = max_retries - self.session = requests.Session() - self.session.headers.update( - { - "Authorization": f"Bearer {access_token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": API_VERSION, - } - ) - - # -- Core request machinery -------------------------------------------- - - def _request(self, method: str, path: str, *, params: dict | None = None) -> dict | list: - """Perform a request with retries, raising typed errors on failure.""" - url = f"{self.api_url}{path}" - last_exc: Exception | None = None - for attempt in range(self.max_retries): - try: - response = self.session.request(method, url, params=params, timeout=self.timeout) - except requests.RequestException as exc: - last_exc = exc - logger.warning("GitHub network error on %s: %s", path, exc) - if attempt < self.max_retries - 1: - time.sleep(2**attempt) - continue - raise GitHubNetworkError( - "Could not reach the GitHub API. Please try again.", detail=str(exc) - ) from exc - - if response.status_code == 404: - raise GitHubNotFoundError("The requested GitHub resource was not found.") - if response.status_code == 401: - raise GitHubAuthError( - "Your GitHub connection is no longer valid. Reconnect your account." - ) - - if response.status_code in (403, 429): - remaining = response.headers.get("X-RateLimit-Remaining", "") - if remaining == "0" or response.status_code == 429: - reset_at = int(response.headers.get("X-RateLimit-Reset", "0")) - wait = max(reset_at - int(time.time()), 1) - raise GitHubRateLimitError( - f"GitHub API rate limit reached. Retry in about {wait} seconds." - ) - detail = _parse_error_body(response) - logger.warning("GitHub permission error on %s: %s", path, detail) - raise GitHubPermissionError("GitHub denied access to this resource.", detail=detail) - - if response.status_code >= 500: - last_exc = GitHubError(f"GitHub API returned {response.status_code}.") - if attempt < self.max_retries - 1: - time.sleep(2**attempt) - continue - raise GitHubNetworkError( - f"GitHub API is experiencing issues (HTTP {response.status_code})." - ) from last_exc - - if response.status_code >= 400: - detail = _parse_error_body(response) - logger.warning( - "GitHub API error on %s (%s): %s", path, response.status_code, detail - ) - raise GitHubError("The GitHub request failed. Please try again.", detail=detail) - - if response.status_code == 204 or not response.content: - return {} - - try: - return response.json() - except ValueError as exc: - raise GitHubError("GitHub API returned an unexpected response.") from exc - - raise GitHubNetworkError( - "Could not reach the GitHub API. Please try again.", detail=str(last_exc) - ) - - def _get(self, path: str, *, params: dict | None = None) -> dict | list: - return self._request("GET", path, params=params) - - def _get_paginated( - self, path: str, *, params: dict | None = None, max_items: int = 300 - ) -> list[dict]: - """Fetch paginated results, following the ``Link`` header up to a cap.""" - items: list[dict] = [] - page_params = dict(params or {}) - page_params.setdefault("per_page", 100) - url = f"{self.api_url}{path}" - - while url and len(items) < max_items: - response = self.session.get(url, params=page_params, timeout=self.timeout) - if response.status_code >= 400: - return self._request("GET", path, params=params) # translate error - try: - page: list[dict] = response.json() - except ValueError: - page = [] - items.extend(page) - - link = response.headers.get("Link", "") - next_url = None - for part in link.split(","): - if 'rel="next"' in part: - next_url = part[part.find("<") + 1 : part.find(">")] - break - if next_url is None or not page: - break - url = next_url - page_params = None - - return items[:max_items] - - def _get_page( - self, - path: str, - *, - params: dict | None = None, - page: int = 1, - per_page: int = PAGE_SIZE_DEFAULT, - ) -> GitHubPage: - """Fetch one page of a GitHub list endpoint. - - ``page`` is 1-based and ``per_page`` is clamped to ``PAGE_SIZE_MAX`` - (GitHub's own maximum) so a single response is always bounded. The - ``Link`` header GitHub sends back is parsed to report whether a - next/previous page exists and, when GitHub includes it, the last page - number. - """ - page = max(1, int(page or 1)) - per_page = max(1, min(int(per_page or PAGE_SIZE_DEFAULT), PAGE_SIZE_MAX)) - request_params = dict(params or {}) - request_params.update({"page": page, "per_page": per_page}) - - response = self.session.get( - f"{self.api_url}{path}", params=request_params, timeout=self.timeout - ) - if response.status_code >= 400: - # Re-issue through _request so failures become the typed errors the - # rest of the app expects (auth, rate limit, not found, ...). - self._request("GET", path, params=params) - - try: - items = response.json() - except ValueError: - items = [] - if not isinstance(items, list): - items = [] - - links = parse_link_header(response.headers.get("Link", "")) - has_next = "next" in links - has_prev = "prev" in links - total_pages = _page_number_from_url(links.get("last")) or (page if not has_next else None) - return GitHubPage( - items=items[:per_page], - page=page, - per_page=per_page, - has_next=has_next, - has_prev=has_prev, - total_pages=total_pages, - ) - - # -- GitHub account ----------------------------------------------------- - - def get_user(self) -> dict: - return self._get("/user") - - def get_rate_limit(self) -> dict | None: - """Return the caller's core rate-limit budget, or ``None`` if unknown. - - Uses GitHub's dedicated ``/rate_limit`` endpoint, which does not count - against the caller's quota. The result is normalized to - ``{"limit", "remaining", "reset", "used"}`` where ``reset`` is a Unix - timestamp. Only numeric fields are returned: no token, headers, or raw - GitHub response is ever surfaced (issue #77). - """ - data = self._get("/rate_limit") - if not isinstance(data, dict): - return None - resources = data.get("resources") - core = (resources or {}).get("core") if isinstance(resources, dict) else None - if not isinstance(core, dict): - core = data.get("rate") - if not isinstance(core, dict): - return None - remaining = core.get("remaining") - reset = core.get("reset") - if remaining is None or reset is None: - return None - limit = core.get("limit") - return { - "limit": limit, - "remaining": remaining, - "reset": reset, - "used": core.get("used"), - } - - # -- Repositories ------------------------------------------------------- - - def list_repositories(self, *, per_page: int = 100) -> list[dict]: - return self._get_paginated( - "/user/repos", - params={"affiliation": "owner,collaborator", "sort": "updated", "per_page": per_page}, - ) - - def get_repository(self, full_name: str) -> dict: - return self._get(f"/repos/{full_name}") - - def list_branches(self, full_name: str) -> list[dict]: - return self._get_paginated(f"/repos/{full_name}/branches") - - # -- Contents / tree ---------------------------------------------------- - - def get_contents( - self, full_name: str, path: str = "", ref: str | None = None - ) -> list[dict] | dict: - params = {} - if ref: - params["ref"] = ref - return self._get(f"/repos/{full_name}/contents/{path}", params=params or None) - - def get_tree(self, full_name: str, ref: str, *, recursive: bool = True) -> dict: - return self._get( - f"/repos/{full_name}/git/trees/{ref}", - params={"recursive": 1 if recursive else None}, - ) - - def search_files(self, full_name: str, query: str, ref: str) -> list[dict]: - """Return repository paths whose file name matches ``query`` (case-insensitive).""" - tree = self.get_tree(full_name, ref) - matches = [] - needle = query.strip().lower() - for entry in tree.get("tree", []): - if entry.get("type") != "blob": - continue - path = entry.get("path", "") - if not needle or needle in path.lower(): - matches.append({"path": path, "size": entry.get("size")}) - return matches[:100] - - def get_file_text(self, full_name: str, path: str, ref: str | None = None) -> str: - """Return the decoded UTF-8 text of a file, enforcing a size cap. - - Raises :class:`GitHubError` for binary or oversized files. - """ - params = {} - if ref: - params["ref"] = ref - response = self.session.get( - f"{self.api_url}/repos/{full_name}/contents/{path}", - params=params or None, - timeout=self.timeout, - ) - if response.status_code >= 400: - self._request("GET", f"/repos/{full_name}/contents/{path}", params=params or None) - raise GitHubError("Could not read file contents.") - - if response.headers.get("Content-Type", "").startswith("application/json"): - try: - data = response.json() - except ValueError: - raise GitHubError("Could not read file contents.") from None - if isinstance(data, dict) and "content" in data: - raw = base64.b64decode(data["content"]) - if len(raw) > Config.GITHUB_MAX_CONTEXT_CHARS * 2: - raise GitHubError("This file is too large to display or analyze.") - return raw.decode("utf-8", errors="replace") - raise GitHubError("This file is not a text file or is too large to display.") - - def get_file_text_batch( - self, - full_name: str, - paths: list[str], - ref: str | None = None, - *, - max_files: int = 25, - max_chars: int = 200_000, - ) -> list[dict]: - """Fetch bounded text contents for a list of repo paths (best-effort). - - Used to assemble a small, detection-relevant slice of a repository for - Stellar/Soroban analysis without downloading the whole repo. Files that - are binary, oversized, or cannot be read are skipped silently so a - failed fetch never breaks the analysis. - - Returns ``[{"path": str, "content": str}, ...]`` with at most - ``max_files`` entries, each content capped at ``max_chars`` characters. - """ - rows: list[dict] = [] - for path in paths: - if len(rows) >= max_files: - break - try: - text = self.get_file_text(full_name, path, ref=ref) - except GitHubError: - continue - if text is None: - continue - rows.append({"path": path, "content": text[:max_chars]}) - return rows - - # -- Commits ------------------------------------------------------------ - - def list_commits( - self, full_name: str, *, ref: str | None = None, path: str | None = None - ) -> list[dict]: - params = {"per_page": 50} - if ref: - params["sha"] = ref - if path: - params["path"] = path - return self._get(f"/repos/{full_name}/commits", params=params) - - def list_commits_page( - self, - full_name: str, - *, - ref: str | None = None, - path: str | None = None, - page: int = 1, - per_page: int = PAGE_SIZE_DEFAULT, - ) -> GitHubPage: - """Fetch one page of a repository's commit history (issue #66). - - Uses the ``Link`` header GitHub returns so the caller can offer - "load more" without guessing where the next page is. ``ref`` and - ``path`` filter the history and are preserved across pages. - """ - params: dict = {} - if ref: - params["sha"] = ref - if path: - params["path"] = path - return self._get_page( - f"/repos/{full_name}/commits", - params=params or None, - page=page, - per_page=per_page, - ) - - def get_commit(self, full_name: str, sha: str) -> dict: - return self._get(f"/repos/{full_name}/commits/{sha}") - - def _graphql(self, query: str, variables: dict) -> dict: - """Execute a GraphQL query against the GitHub GraphQL API.""" - url = f"{self.api_url}/graphql" - try: - response = self.session.request( - "POST", - url, - json={"query": query, "variables": variables}, - timeout=self.timeout, - ) - except requests.RequestException as exc: - raise GitHubNetworkError( - "Could not reach the GitHub API. Please try again.", detail=str(exc) - ) from exc - if response.status_code == 401: - raise GitHubAuthError( - "Your GitHub connection is no longer valid. Reconnect your account." - ) - if response.status_code >= 400: - raise GitHubError( - "The GitHub request failed. Please try again.", - detail=_parse_error_body(response), - ) - try: - payload = response.json() - except ValueError as exc: - raise GitHubError("GitHub API returned an unexpected response.") from exc - if payload.get("errors"): - raise GitHubError("The GitHub request failed. Please try again.") - return payload.get("data") or {} - - def get_last_commits(self, full_name: str, paths: list[str], ref: str) -> dict[str, dict]: - """Return the last commit touching each path in a single request. - - GitHub's REST API can only answer "last commit for this path" one path - at a time. Batching the per-path history lookups into one GraphQL query - keeps this to a single API call regardless of how many files are shown. - Files whose history cannot be resolved are simply omitted from the - result. - """ - if not paths: - return {} - owner, _, name = full_name.partition("/") - fields = [] - for index, path in enumerate(paths): - literal = json.dumps(path) # safe GraphQL string literal - fields.append( - f"f{index}: object(expression: $ref) {{ ... on Commit {{ " - f"history(first: 1, path: {literal}) {{ nodes {{ oid messageHeadline " - f"committedDate author {{ name }} }} }} }} }}" - ) - query = ( - "query($owner: String!, $name: String!, $ref: String!) { " - "repository(owner: $owner, name: $name) {" + "\n".join(fields) + "}}" - ) - data = self._graphql(query, {"owner": owner, "name": name, "ref": ref}) - repository = data.get("repository") or {} - commits: dict[str, dict] = {} - for index, path in enumerate(paths): - node = repository.get(f"f{index}") or {} - nodes = (node.get("history") or {}).get("nodes") or [] - if not nodes: - continue - commit = nodes[0] - commits[path] = { - "sha": commit.get("oid"), - "message": commit.get("messageHeadline"), - "author": (commit.get("author") or {}).get("name"), - "date": commit.get("committedDate"), - } - return commits - - # -- Issues ------------------------------------------------------------- - - def list_issues(self, full_name: str, *, state: str = "open", per_page: int = 50) -> list[dict]: - """Return issues (excluding pull requests).""" - data = self._get( - f"/repos/{full_name}/issues", - params={"state": state, "per_page": per_page}, - ) - return issues_only(data) - - def list_issues_page( - self, - full_name: str, - *, - state: str = "open", - page: int = 1, - per_page: int = PAGE_SIZE_DEFAULT, - ) -> GitHubPage: - """Return one page of issues (excluding pull requests). - - GitHub's issues endpoint includes pull requests, so the payload is - filtered; the returned navigation metadata still reflects GitHub's own - paging links for the endpoint. - """ - result = self._get_page( - f"/repos/{full_name}/issues", - params={"state": state}, - page=page, - per_page=per_page, - ) - return GitHubPage( - items=issues_only(result.items), - page=result.page, - per_page=result.per_page, - has_next=result.has_next, - has_prev=result.has_prev, - total_pages=result.total_pages, - ) - - def get_issue(self, full_name: str, number: int) -> dict: - return self._get(f"/repos/{full_name}/issues/{number}") - - # -- Pull requests ------------------------------------------------------ - - def list_pull_requests(self, full_name: str, *, state: str = "open") -> list[dict]: - return self._get( - f"/repos/{full_name}/pulls", - params={"state": state, "per_page": 50}, - ) - - def list_pull_requests_page( - self, - full_name: str, - *, - state: str = "open", - page: int = 1, - per_page: int = PAGE_SIZE_DEFAULT, - ) -> GitHubPage: - """Return one page of pull requests with Link-header navigation.""" - return self._get_page( - f"/repos/{full_name}/pulls", - params={"state": state}, - page=page, - per_page=per_page, - ) - - def get_pull_request(self, full_name: str, number: int) -> dict: - return self._get(f"/repos/{full_name}/pulls/{number}") - - def list_pull_request_files(self, full_name: str, number: int) -> list[dict]: - return self._get(f"/repos/{full_name}/pulls/{number}/files", params={"per_page": 100}) - - def list_pull_request_reviews(self, full_name: str, number: int) -> list[dict]: - return self._get(f"/repos/{full_name}/pulls/{number}/reviews") - - def list_pull_request_comments(self, full_name: str, number: int) -> list[dict]: - return self._get(f"/repos/{full_name}/pulls/{number}/comments", params={"per_page": 100}) - - # -- README ------------------------------------------------------------- - - def get_readme(self, full_name: str, ref: str | None = None) -> str | None: - params = {} - if ref: - params["ref"] = ref - try: - response = self.session.get( - f"{self.api_url}/repos/{full_name}/readme", - params=params or None, - headers={"Accept": "application/vnd.github.raw+json"}, - timeout=self.timeout, - ) - except requests.RequestException: - return None - if response.status_code != 200: - return None - text = response.text - return text[: Config.GITHUB_MAX_CONTEXT_CHARS] if text else None - - -# -- Payload normalization --------------------------------------------------- - - -def _license_label(license_data: dict | None) -> str | None: - """Return a short license label, preferring the SPDX id.""" - if not license_data: - return None - spdx = license_data.get("spdx_id") - if spdx and spdx != "NOASSERTION": - return spdx - return license_data.get("name") - - -def repo_payload(repo: dict) -> dict: - """Normalize a repository dict into the shape consumed by the UI.""" - return { - "full_name": repo.get("full_name"), - "name": repo.get("name"), - "description": repo.get("description"), - "owner": repo.get("owner", {}).get("login"), - "visibility": repo.get("visibility", "private" if repo.get("private") else "public"), - "private": bool(repo.get("private")), - "default_branch": repo.get("default_branch"), - "language": repo.get("language"), - "updated_at": repo.get("updated_at"), - "html_url": repo.get("html_url"), - "size": repo.get("size"), - "fork": bool(repo.get("fork")), - "stars": repo.get("stargazers_count") or 0, - "forks": repo.get("forks_count") or 0, - "open_issues_count": repo.get("open_issues_count") or 0, - "license": _license_label(repo.get("license")), - "topics": repo.get("topics") or [], - "homepage": repo.get("homepage") or "", - } - - -def issues_only(items: list[dict]) -> list[dict]: - """Return only true issues from a GitHub issues payload (drop PRs).""" - return [item for item in items if "pull_request" not in item] - - -def issue_payload(issue: dict) -> dict: - return { - "number": issue.get("number"), - "title": issue.get("title"), - "body": issue.get("body"), - "state": issue.get("state"), - "author": (issue.get("user") or {}).get("login"), - "created_at": issue.get("created_at"), - "updated_at": issue.get("updated_at"), - "labels": [label.get("name") for label in issue.get("labels", [])], - "html_url": issue.get("html_url"), - "comments": issue.get("comments", 0), - } - - -def commit_payload(commit: dict) -> dict: - commit_data = commit.get("commit") or {} - author = commit_data.get("author") or {} - return { - "sha": commit.get("sha"), - "message": commit_data.get("message"), - "author": author.get("name") or (commit.get("author") or {}).get("login"), - "date": author.get("date"), - "html_url": commit.get("html_url"), - "url": commit.get("url"), - "files": [ - { - "filename": f.get("filename"), - "additions": f.get("additions"), - "deletions": f.get("deletions"), - "status": f.get("status"), - "patch": f.get("patch"), - } - for f in commit.get("files", []) - ], - } - - -def pull_request_payload(pr: dict) -> dict: - return { - "number": pr.get("number"), - "title": pr.get("title"), - "body": pr.get("body"), - "state": pr.get("state"), - "author": (pr.get("user") or {}).get("login"), - "created_at": pr.get("created_at"), - "updated_at": pr.get("updated_at"), - "merged": bool(pr.get("merged")), - "mergeable": pr.get("mergeable"), - "head": (pr.get("head") or {}).get("ref"), - "base": (pr.get("base") or {}).get("ref"), - "additions": pr.get("additions"), - "deletions": pr.get("deletions"), - "changed_files": pr.get("changed_files"), - "html_url": pr.get("html_url"), - "diff_url": pr.get("diff_url"), - } - - -def validate_full_name(full_name: str) -> str: - """Validate ``owner/repo`` shape; raises :class:`GitHubInvalidError`.""" - name = (full_name or "").strip() - if not _FULL_NAME_RE.match(name) or name.count("/") != 1: - raise GitHubInvalidError("Invalid repository name.") - return name - - -def validate_path(path: str) -> str: - """Validate a repository-relative file path against path traversal. - - An empty/root path is accepted and normalized to ``""``. - """ - cleaned = (path or "").strip().lstrip("/") - if not cleaned: - return "" - parts = cleaned.split("/") - if any(part in ("", ".", "..") for part in parts): - raise GitHubInvalidError("Invalid path.") - return "/".join(parts) - - -def revoke_github_token(access_token: str) -> None: - """Best-effort revocation of a GitHub OAuth token.""" - try: - response = requests.delete( - GITHUB_REVOKE_URL.format(client_id=_github_config("GITHUB_CLIENT_ID")), - auth=(_github_config("GITHUB_CLIENT_ID"), _github_config("GITHUB_CLIENT_SECRET")), - json={"access_token": access_token}, - headers={"Accept": "application/vnd.github+json"}, - timeout=_github_config("GITHUB_REQUEST_TIMEOUT"), - ) - if response.status_code >= 400: - logger.warning("GitHub token revocation failed with HTTP %s", response.status_code) - except requests.RequestException as exc: - logger.warning("GitHub token revocation failed: %s", exc) - - -def _refresh_github_token(refresh_token: str) -> dict | None: - """Exchange a GitHub refresh token for a new token pair.""" - try: - response = requests.post( - GITHUB_TOKEN_URL, - headers={"Accept": "application/json"}, - data={ - "client_id": _github_config("GITHUB_CLIENT_ID"), - "client_secret": _github_config("GITHUB_CLIENT_SECRET"), - "grant_type": "refresh_token", - "refresh_token": refresh_token, - }, - timeout=_github_config("GITHUB_REQUEST_TIMEOUT"), - ) - data = response.json() - except (requests.RequestException, ValueError): - return None - if response.status_code >= 400 or "access_token" not in data: - return None - return data - - -def get_github_client(user=None) -> GitHubClient: - """Return an authenticated client for ``user`` (default: current user). - - Raises :class:`GitHubNotConnectedError` when the user has not connected a - GitHub account, and :class:`GitHubAuthError` when the stored token cannot - be decrypted. - """ - from flask_login import current_user - - owner = user or current_user - account = GithubAccount.query.filter_by(user_id=owner.id).first() - if account is None: - raise GitHubNotConnectedError("Connect your GitHub account to use this feature.") - try: - token = decrypt_secret(account.access_token_encrypted) - refresh_token = ( - decrypt_secret(account.refresh_token_encrypted) - if account.refresh_token_encrypted - else None - ) - except ValueError as exc: - raise GitHubAuthError( - "Your GitHub connection is no longer valid. Reconnect your account.", - detail=str(exc), - ) from exc - expires_at = account.token_expires_at - if expires_at and expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=UTC) - if expires_at and expires_at <= datetime.now(UTC): - refreshed = _refresh_github_token(refresh_token) if refresh_token else None - if refreshed is None: - db.session.delete(account) - db.session.commit() - raise GitHubNotConnectedError("Connect your GitHub account to use this feature.") - account.set_access_token(refreshed["access_token"]) - if "refresh_token" in refreshed: - account.set_refresh_token(refreshed["refresh_token"]) - account.token_expires_at = ( - datetime.now(UTC) + timedelta(seconds=int(refreshed["expires_in"])) - if refreshed.get("expires_in") - else None - ) - db.session.commit() - token = refreshed["access_token"] - return GitHubClient(token) - - -# -- Chat context references (issue #74) ------------------------------------- - - -#: Matches ``owner/repo#123`` and a bare ``owner/repo``. The lookbehind stops a -#: leading word character, ``@`` or ``/`` from being read as part of the name. -CONTEXT_REFERENCE_RE = re.compile( - r"(? list[dict]: - """Return the GitHub references found in ``text`` (issue #74). - - Three forms are recognized: ``owner/repo#123`` (an issue or pull request), - ``owner/repo`` (a repository), and a bare ``#123`` when the same message also - names a repository to resolve it against. Order is preserved and duplicates - collapse, so mentioning the same target twice fetches it once. A bare - ``#123`` with no repository named is ignored rather than guessed at. - """ - references: list[dict] = [] - seen: set[tuple[str, str, int | None]] = set() - repositories: list[str] = [] - - for match in CONTEXT_REFERENCE_RE.finditer(text or ""): - try: - full_name = validate_full_name(match.group(1)) - except GitHubInvalidError: - continue - number = match.group(2) - if number: - key = ("thread", full_name, int(number)) - if key not in seen: - seen.add(key) - references.append({"kind": "thread", "full_name": full_name, "number": int(number)}) - else: - key = ("repo", full_name, None) - if key not in seen: - seen.add(key) - references.append({"kind": "repo", "full_name": full_name, "number": None}) - repositories.append(full_name) - - for match in CONTEXT_NUMBER_RE.finditer(text or ""): - number = int(match.group(1)) - for full_name in repositories: - key = ("thread", full_name, number) - if key in seen: - continue - seen.add(key) - references.append({"kind": "thread", "full_name": full_name, "number": number}) - - return references - - -def _repo_context_section(client: GitHubClient, full_name: str) -> str: - """Return the model-facing context block for a whole repository.""" - repo = client.get_repository(full_name) - lines = [ - f"### Repository {repo.get('full_name') or full_name}", - f"- Description: {repo.get('description') or '(none)'}", - f"- Primary language: {repo.get('language') or 'unknown'}", - f"- Default branch: {repo.get('default_branch') or 'main'}", - f"- URL: {repo.get('html_url') or 'https://github.com/' + full_name}", - ] - try: - readme = client.get_readme(full_name, repo.get("default_branch")) - except GitHubError: - readme = None - if readme: - lines.extend(["", "README:", readme]) - return "\n".join(lines) - - -def _pull_request_context_section(client: GitHubClient, full_name: str, number: int) -> str: - """Return the model-facing context block for a pull request.""" - pull = client.get_pull_request(full_name, number) - state = pull.get("state") or "unknown" - if pull.get("merged_at"): - state = f"{state} (merged)" - lines = [ - f"### Pull request {full_name}#{number}", - f"- Title: {pull.get('title') or '(no title)'}", - f"- State: {state}", - f"- Author: {(pull.get('user') or {}).get('login') or 'unknown'}", - f"- Branch: {(pull.get('head') or {}).get('ref')} -> {(pull.get('base') or {}).get('ref')}", - ( - f"- Changed files: {pull.get('changed_files', 0)} " - f"(+{pull.get('additions', 0)} -{pull.get('deletions', 0)})" - ), - f"- URL: {pull.get('html_url') or ''}", - ] - if pull.get("body"): - lines.extend(["", "Body:", pull["body"]]) - return "\n".join(lines) - - -def _issue_context_section(client: GitHubClient, full_name: str, number: int) -> str: - """Return the model-facing context block for an issue.""" - issue = client.get_issue(full_name, number) - labels = ", ".join( - label.get("name", "") for label in issue.get("labels") or [] if isinstance(label, dict) - ) - lines = [ - f"### Issue {full_name}#{number}", - f"- Title: {issue.get('title') or '(no title)'}", - f"- State: {issue.get('state') or 'unknown'}", - f"- Author: {(issue.get('user') or {}).get('login') or 'unknown'}", - f"- Labels: {labels or '(none)'}", - f"- URL: {issue.get('html_url') or ''}", - ] - if issue.get("body"): - lines.extend(["", "Body:", issue["body"]]) - return "\n".join(lines) - - -def _thread_context_section(client: GitHubClient, full_name: str, number: int) -> str: - """Return the context block for ``#number``, preferring a pull request. - - GitHub's issues API also returns pull requests, so probing the pull request - endpoint first (and falling back to the issue endpoint on 404) resolves both - kinds without a second round trip when the reference is a pull request. - """ - try: - return _pull_request_context_section(client, full_name, number) - except GitHubNotFoundError: - return _issue_context_section(client, full_name, number) - - -def build_github_context(user, text: str) -> dict: - """Assemble model-facing context for the GitHub references in ``text`` (issue #74). - - Returns ``{"context", "references", "notices"}``. ``context`` is the block - handed to the model and never exceeds ``Config.GITHUB_MAX_CONTEXT_CHARS`` - characters, so one chat message cannot push an unbounded amount of repository - data into the prompt. A reference that cannot be read (missing, private, or - rate limited) becomes an entry in ``notices`` instead of failing the whole - message. - - Raises :class:`GitHubNotConnectedError` when ``user`` has no GitHub - connection, so the caller can prompt them to connect first. - """ - references = parse_context_references(text) - if not references: - return {"context": "", "references": [], "notices": []} - - client = get_github_client(user) - budget = int(Config.GITHUB_MAX_CONTEXT_CHARS) - sections: list[str] = [] - notices: list[str] = [] - - for reference in references: - full_name = reference["full_name"] - number = reference["number"] - label = f"{full_name}#{number}" if number else full_name - try: - if reference["kind"] == "repo": - sections.append(_repo_context_section(client, full_name)) - else: - sections.append(_thread_context_section(client, full_name, number)) - except GitHubNotFoundError: - notices.append(f"{label} was not found or is not visible to your GitHub account.") - except GitHubError as exc: - notices.append(f"{label}: {github_error_message(exc)}") - - context = "\n\n".join(section for section in sections if section) - if len(context) > budget: - context = context[:budget] + CONTEXT_TRUNCATED_MARKER - - return {"context": context, "references": references, "notices": notices} +IiIiR2l0SHViIEFQSSBzZXJ2aWNlIGxheWVyLgoKQSB0aGluLCByZXRyeWluZyBjbGllbnQgZm9yIHRoZSBHaXRIdWIgUkVTVCBBUEkgdXNlZCBieSB0aGUgcmVwb3NpdG9yeQpicm93c2VyLCBpc3N1ZXMsIHB1bGwgcmVxdWVzdHMsIGFuZCBjaGF0IGNvbnRleHQgZmVhdHVyZXMuIEFsbCByZXF1ZXN0cyBhcmUKbWFkZSBvbiBiZWhhbGYgb2YgdGhlIGN1cnJlbnQgdXNlcidzIGNvbm5lY3RlZCBHaXRIdWIgYWNjb3VudCwgc28gR2l0SHViJ3Mgb3duCnBlcm1pc3Npb25zIG1vZGVsIGRlY2lkZXMgd2hpY2ggcmVwb3NpdG9yaWVzIChwdWJsaWMgb3IgcHJpdmF0ZSkKYWNjZXNzaWJsZS4KCkVycm9ycyBhcmUgcmFpc2VkIGFzIDpjbGFzczpgR2l0SHViRXJyb3JgIHN1YmNsYXNzZXMgc28gcm91dGVzIGNhbiB0cmFuc2xhdGUKdGhlbSBpbnRvIHVzZXItZmFjaW5nIHJlc3BvbnNlcyB3aXRob3V0IHN3YWxsb3dpbmcgZmFpbHVyZXMuCiIiIgoKZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKaW1wb3J0IGJhc2U2NAppbXBvcnQganNvbgppbXBvcnQgbG9nZ2luZwppbXBvcnQgcmUKaW1wb3J0IHRpbWUKZnJvbSBkYXRhY2xhc3NlcyBpbXBvcnQgZGF0YWNsYXNzCmZyb20gZGF0ZXRpbWUgaW1wb3J0IFVUQywgZGF0ZXRpbWUsIHRpbWVkZWx0YQpmcm9tIHVybGxpYi5wYXJzZSBpbXBvcnQgcGFyc2VfcXMsIHVybHBhcnNlCgppbXBvcnQgcmVxdWVzdHMKZnJvbSBmbGFzayBpbXBvcnQgY3VycmVudF9hcHAKCmZyb20gYXBwLmNvbmZpZyBpbXBvcnQgQ29uZmlnCmZyb20gYXBwLmV4dGVuc2lvbnMgaW1wb3J0IGRiCmZyb20gYXBwLm1vZGVscyBpbXBvcnQgR2l0aHViQWNjb3VudApmcm9tIGFwcC5zZXJ2aWNlcy5jcnlwdG8gaW1wb3J0IGRlY3J5cHRfc2VjcmV0Cgpsb2dnZXIgPSBsb2dnaW5nLmdldExvZ2dlcihfX25hbWVfXykKCkdJVEhVQl9BVVRIT1JJWkVfVVJMID0gImh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aC9hdXRob3JpemUiCkdJVEhVQl9UT0tFTl9VUkwgPSAiaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoL2FjY2Vzc190b2tlbiIKR0lUSFVCX1JFVk9LRV9VUkwgPSAiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9hcHBsaWNhdGlvbnMve2NsaWVudF9pZH0vdG9rZW4iCkdJVEhVQl9PQVVUSF9FUlJPUl9VUkwgPSAiaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoL2FjY2Vzc190b2tlbiIKREVGQVVMVF9BUElfVVJMID0gImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20iCkFQSV9WRVJTSU9OID0gIjIwMjItMTEtMjgiCgojIEdpdEh1YiByZXBvc2l0b3J5IG5hbWVzIC8gb3duZXJzOiBsZXR0ZXJzLCBkaWdpdHMsIGRhc2hlcywgZG90cywgdW5kZXJzY29yZXMuCl9GVUxMX05BTUVfUkUgPSByZS5jb21waWxlKHIiXltBLVphLXowLTlfLi1dKy9bQS1aYS16MC05Xy4tXSskIikKCiM6IERlZmF1bHQgYW5kIG1heGltdW0gYGBwZXJfcGFnZWBgIGZvciBsaXN0IGVuZHBvaW50cyAoR2l0SHViIGNhcHMgYXQgMTAwKS4KUEFHRV9TSVpFX0RFRkFVTFQgPSA1MApQQUdFX1NJWkVfTUFYID0gMTAwCgojOiBNYXRjaGVzIG9uZSBgYDx1cmw+OyByZWw9Im5hbWUiYGAggZW50cnkgb2YgYSBHaXRIdWIgYGBMaW5rYGAgaGVhZGVyLgpfTElOS19SRSA9IHJlLmNvbXBpbGUocic8KFtePl0rKT5ccyo7XHMqcmVsPSIoW14iXSspIicpCgojOiBPQXV0aCBgYGVycm9yYGAggY29kZXMgdGhhdCByZXByZXNlbnQgdGhlIHVzZXIgZGVjbGluaW5nIGNvbnNlbnQgcmF0aGVyIHRoYW4KIzogYSByZWFsIGZhaWx1cmUuIEdpdEh1YiBzZW5kcyB0aGVzZSBiYWNrIG9uIHRoZSBjYWxsYmFjayBxdWVyeSBzdHJpbmcuCk9BVVRIX0NBTkNFTExBVElPTl9DT0RFUyA9IGZyb3plbnNldCh7ImFjY2Vzc19kZW5pZWQifSkKCgpAZGF0YWNsYXNzKGZyb3plbj1UcnVlKQpjbGFzcyBHaXRIdWJQYWdlOgogICAgIiIiQSBzaW5nbGUgcGFnZSBvZiBhIEdpdEh1YiBsaXN0IGVuZHBvaW50IHBsdXMgaXRzIG5hdmlnYXRpb24gbWV0YWRhdGEuCgogICAgYGBoYXNfbmV4dGBgIC8gYGBoYXNfcHJldmBgIC8gYGB0b3RhbF9wYWdlc2BgIGFyZSBkZXJpdmVkIGZyb20gdGhlIGBgTGlua2BgCiAgICByZXNwb25zZSBoZWFkZXIgR2l0SHViIHJldHVybnMsIHNvIHRoZSBjYWxsZXIgbmV2ZXIgaGFzIHRvIGd1ZXNzIHdoZXJlIHRoZQogICAgbmV4dCBwYWdlIGlzLgogICAgIiIiCgogICAgaXRlbXM6IGxpc3RbZGljdF0KICAgIHBhZ2U6IGludAogICAgcGVyX3BhZ2U6IGludAogICAgaGFzX25leHQ6IGJvb2wgPSBGYWxzZQogICAgaGFzX3ByZXY6IGJvb2wgPSBGYWxzZQogICAgdG90YWxfcGFnZXM6IGludCB8IE5vbmUgPSBOb25lCgoKZGVmIHBhcnNlX2xpbmtfaGVhZGVyKGhlYWRlcjogc3RyKSAtPiBkaWN0W3N0ciwgc3RyXToKICAgICIiIlJldHVybiBgYHtyZWw6IHVybH1gYCBwYXJzZWQgZnJvbSBhIEdpdEh1YiBgYExpbmtgYCBoZWFkZXIuIiIiCiAgICByZXR1cm4ge3JlbDogdXJsIGZvciB1cmwsIHJlbCBpbiBfTElOS19SRS5maW5kYWxsKGhlYWRlciBvciAiIil9CgoKZGVmIF9wYWdlX251bWJlcl9mcm9tX3VybCh1cmw6IHN0ciB8IE5vbmUpIC0+IGludCB8IE5vbmU6CiAgICAiIiJFeHRyYWN0IHRoZSBgYHBhZ2VgYCBxdWVyeSBwYXJhbWV0ZXIgZnJvbSBhIEdpdEh1YiBwYWdpbmcgVVJMLiIiIgogICAgaWYgbm90IHVybDoKICAgICAgICByZXR1cm4gTm9uZQogICAgdmFsdWVzID0gcGFyc2VfcXModXJscGFyc2UodXJsKS5xdWVyeSkuZ2V0KCJwYWdlIikKICAgIGlmIG5vdCB2YWx1ZXM6CiAgICAgICAgcmV0dXJuIE5vbmUKICAgIHRyeToKICAgICAgICByZXR1cm4gaW50KHZhbHVlc1swXSkKICAgIGV4Y2VwdCAoVHlwZUVycm9yLCBWYWx1ZUVycm9yKToKICAgICAgICByZXR1cm4gTm9uZQoKCmRlZiBfZ2l0aHViX2NvbmZpZyhuYW1lOiBzdHIpOgogICAgIiIiUmVhZCBHaXRIdWIgc2V0dGluZ3MgZnJvbSB0aGUgYWN0aXZlIGFwcCwgd2l0aCBjbGFzcy1jb25maWcgZmFsbGJhY2suIiIiCiAgICByZXR1cm4gY3VycmVudF9hcHAuY29uZmlnLmdldChuYW1lLCBnZXRhdHRyKENvbmZpZywgbmFtZSkpCgoKY2xhc3MgR2l0SHViRXJyb3IoUnVudGltZUVycm9yKToKICAgICIiIkJhc2UgY2xhc3MgZm9yIEdpdEh1YiBBUEkgZXJyb3JzIHN1cmZhY2VkIHRvIHRoZSB1c2VyLiIiIgoKICAgIGtpbmQgPSAiZ2l0aHViX2Vycm9yIgoKICAgIGRlZiBfX2luaXRfXyhzZWxmLCBtZXNzYWdlOiBzdHIsICosIGtpbmQ6IHN0ciB8IE5vbmUgPSBOb25lLCBkZXRhaWw6IHN0ciB8IE5vbmUgPSBOb25lKSAtPiBOb25lOgogICAgICAgIHN1cGVyKCkuX19pbml0X18obWVzc2FnZSkKICAgICAgICBpZiBraW5kIGlzIG5vdCBOb25lOgogICAgICAgICAgICBzZWxmLmtpbmQgPSBraW5kCiAgICAgICAgIyBTZXJ2ZXItc2lkZS1vbmx5IGRldGFpbCAoZS5nLiBHaXRIdWIncyByYXcgcmVzcG9uc2UgbWVzc2FnZSkuIE5ldmVyCiAgICAgICAgIyBzdXJmYWNlZCB0byB0aGUgY2xpZW50OyBsb2dnZWQgaW5zdGVhZC4gU2VlIGBgZ2l0aHViX2Vycm9yX3BheWxvYWRgYC4KICAgICAgICBzZWxmLmRldGFpbCA9IGRldGFpbAoKCmNsYXNzIEdpdEh1Yk5vdENvbm5lY3RlZEVycm9yKEdpdEh1YkVycm9yKToKICAgIGtpbmQgPSAibm90X2Nvbm5lY3RlZCIKCgpjbGFzcyBHaXRIdWJPQXV0aENhbmNlbGxlZEVycm9yKEdpdEh1YkVycm9yKToKICAgICIiIlJhaXNlZCB3aGVuIHRoZSB1c2VyIGRlY2xpbmVzIEdpdEh1YidzIGNvbnNlbnQgc2NyZWVuIChgYGFjY2Vzc19kZW5pZWRgYCkuIiIiCgogICAga2luZCA9ICJjYW5jZWxsZWQiCgoKY2xhc3MgR2l0SHViQXV0aEVycm9yKEdpdEh1YkVycm9yKToKICAgIGtpbmQgPSAiYXV0aCIKCgpjbGFzcyBHaXRIdWJQZXJtaXNzaW9uRXJyb3IoR2l0SHViRXJyb3IpOgogICAga2luZCA9ICJwZXJtaXNzaW9uIgoKCmNsYXNzIEdpdEh1Yk5vdEZvdW5kRXJyb3IoR2l0SHViRXJyb3IpOgogICAga2luZCA9ICJub3RfZm91bmQiCgoKY2xhc3MgR2l0SHViUmF0ZUxpbWl0RXJyb3IoR2l0SHViRXJyb3IpOgogICAga2luZCA9ICJyYXRlX2xpbWl0IgoKCmNsYXNzIEdpdEh1Yk5ldHdvcmtFcnJvcihHaXRIdWJFcnJvcik6CiAgICBraW5kID0gIm5ldHdvcmsiCgoKY2xhc3MgR2l0SHViSW52YWxpZEVycm9yKEdpdEh1YkVycm9yKToKICAgIGtpbmQgPSAidmFsaWRhdGlvbiIKCgojOiBTdGFibGUsIHNhbml0aXplZCBtZXNzYWdlcyBzaG93biB0byBlbmQgdXNlcnMsIGtleWVkIGJ5IGVycm9yIGBga2luZGBgLgojOiBHaXRIdWIncyByYXcgcmVzcG9uc2UgYm9kaWVzIGFyZSBuZXZlciBzdXJmYWNlZDogdGhleSBjYW4gY29udGFpbiBVUkxzLAojOiBoZWFkZXJzLCByYXRlLWxpbWl0IGRhdGEsIG9yIGltcGxlbWVudGF0aW9uIGRldGFpbHMuIFRob3NlIGFyZSBrZXB0IG9uCiM6IGBgR2l0SHViRXJyb3IuZGV0YWlsYGAgZm9yIHNlcnZlci1zaWRlIGxvZ2dpbmcgb25seS4KRVJST1JfTUVTU0FHRVMgPSB7CiAgICAibm90X2Nvbm5lY3RlZCI6ICJDb25uZWN0IHlvdXIgR2l0SHViIGFjY291bnQgdG8gdXNlIHRoaXMgZmVhdHVyZS4iLAogICAgImNhbmNlbGxlZCI6ICJZb3UgY2FuY2VsbGVkIHRoZSBHaXRIdWIgY29ubmVjdGlvbi4gQ29ubmVjdCBHaXRIdWIgdG8gY29udGludWUuIiwKICAgICJhdXRoIjogIllvdXIgR2l0SHViIGNvbm5lY3Rpb24gaXMgbm8gbG9uZ2VyIHZhbGlkLiBSZWNvbm5lY3QgeW91ciBhY2NvdW50LiIsCiAgICAicGVybWlzc2lvbiI6ICJHaXRIdWIgZGVuaWVkIGFjY2VzcyB0byB0aGlzIHJlc291cmNlLiIsCiAgICAibm90X2ZvdW5kIjogIlRoZSByZXF1ZXN0ZWQgR2l0SHViIHJlc291cmNlIHdhcyBub3QgZm91bmQuIiwKICAgICJyYXRlX2xpbWl0IjogIkdpdEh1YiBBUEkgcmF0ZSBsaW1pdCByZWFjaGVkLiBQbGVhc2UgdHJ5IGFnYWluIGxhdGVyLiIsCiAgICAibmV0d29yayI6ICJDb3VsZCBub3QgcmVhY2ggdGhlIEdpdEh1YiBBUEkuIFBsZWFzZSB0cnkgYWdhaW4uIiwKICAgICJ2YWxpZGF0aW9uIjogIlRoZSBHaXRIdWIgcmVxdWVzdCB3YXMgaW52YWxpZC4iLAogICAgImdpdGh1Yl9lcnJvciI6ICJUaGUgR2l0SHViIHJlcXVlc3QgZmFpbGVkLiBQbGVhc2UgdHJ5IGFnYWluLiIsCn0KCgpkZWYgZ2l0aHViX2Vycm9yX21lc3NhZ2UoZXhjOiBHaXRIdWJFcnJvcikgLT4gc3RyOgogICAgIiIiUmV0dXJuIHRoZSBzdGFibGUgdXNlci1mYWNpbmcgbWVzc2FnZSBmb3IgYGBleGNgYCdzIGtpbmQuIiIiCiAgICByZXR1cm4gRVJST1JfTUVTU0FHRVMuZ2V0KGV4Yy5raW5kLCBFUlJPUl9NRVNTQUdFU1siZ2l0aHViX2Vycm9yIl0pCgoKZGVmIGdpdGh1Yl9lcnJvcl9wYXlsb2FkKGV4YzogR2l0SHViRXJyb3IpIC0+IGRpY3Q6CiAgICAiIiJCdWlsZCBhIHNhbml0aXplZCBKU09OIGVycm9yIHBheWxvYWQgZm9yIGEgOmNsYXNzOmBHaXRIdWJFcnJvcmAuCgogICAgUm91dGUgaGFuZGxlcnMgbXVzdCB1c2UgdGhpcyBpbnN0ZWFkIG9mIGBgc3RyKGV4YylgYCBzbyBubyByYXcgR2l0SHViCiAgICBtZXNzYWdlIChvciB0b2tlbi9oZWFkZXIgbWF0ZXJpYWwpIGlzIGV2ZXIgZWNob2VkIHRvIHRoZSBjbGllbnQuCiAgICAiIiIKICAgIHJldHVybiB7ImVycm9yIjogZ2l0aHViX2Vycm9yX21lc3NhZ2UoZXhjKSwgImtpbmQiOiBleGMua2luZH0KCgpkZWYgZ2l0aHViX29hdXRoX2Vycm9yX2Zyb21fY2FsbGJhY2soCiAgICBlcnJvcjogc3RyIHwgTm9uZSwgZXJyb3JfZGVzY3JpcHRpb246IHN0ciB8IE5vbmUgPSBOb25lCikgLT4gR2l0SHViRXJyb3IgfCBOb25lOgogICAgIiIiVHJhbnNsYXRlIGFuIE9BdXRoIGNhbGxiYWNrIGBgZXJyb3JgYCBpbnRvIGEgdHlwZWQsIHNhbml0aXplZCBlcnJvci4KCiAgICBHaXRIdWIgYXBwZW5kcyBgYGVycm9yYGAggKGFuZCBzb21ldGltZXMgYGB lcnJvcl9kZXNjcmlwdGlvbmBgKSB0byB0aGUKICAgIGNhbGxiYWNrIFVSTCB3aGVuIHRoZSBjb25zZW50IGZsb3cgZmFpbHMuIGBgYWNjZXNzX2RlbmllZGBgIG1lYW5zIHRoZSB1c2VyCiAgICBjbGlja2VkICJDYW5jZWwiIGFuZCBpcyB0cmVhdGVkIGFzIGEgY2FuY2VsbGF0aW9uLCBub3QgYSBmYWlsdXJlLiBBbnkgb3RoZXIKICAgIGNvZGUgYmVjb21lcyBhIGdlbmVyaWMgYXV0aCBlcnJvci4gVGhlIHJhdyBgYGVycm9yX2Rlc2NyaXB0aW9uYGAgaXMga2VwdCBvbgogICAgYGBkZXRhaWxgYCBmb3Igc2VydmVyLXNpZGUgbG9nZ2luZyBvbmx5IGFuZCBpcyBuZXZlciBzdXJmYWNlZCB0byB0aGUgY2xpZW50LgogICAgIiIiCiAgICBjb2RlID0gKGVycm9yIG9yICIiKS5zdHJpcCgpCiAgICBpZiBub3QgY29kZToKICAgICAgICByZXR1cm4gTm9uZQogICAgZGV0YWlsID0gKGVycm9yX2Rlc2NyaXB0aW9uIG9yICIiKS5zdHJpcCgpIG9yIE5vbmUKICAgIGlmIGNvZGUgaW4gT0FVVEhfQ0FOQ0VMTEFUSU9OX0NPREVTOgogICAgICAgIHJldHVybiBHaXRIdWJPQXV0aENhbmNlbGxlZEVycm9yKAogICAgICAgICAgICAiWW91IGNhbmNlbGxlZCB0aGUgR2l0SHViIGNvbm5lY3Rpb24uIENvbm5lY3QgR2l0SHViIHRvIGNvbnRpbnVlLiIsCiAgICAgICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICAgICAgKQogICAgcmV0dXJuIEdpdEh1YkF1dGhFcnJvcigKICAgICAgICAiR2l0SHViIGNvdWxkIG5vdCBjb21wbGV0ZSB0aGUgY29ubmVjdGlvbi4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICApCgoKZGVmIF9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlOiByZXF1ZXN0cy5SZXNwb25zZSkgLT4gc3RyOgogICAgIiIiRXh0cmFjdCBhIHNob3J0IGh1bWFuLXJlYWRhYmxlIG1lc3NhZ2UgZm9yIHNlcnZlci1zaWRlIGxvZ2dpbmcgb25seS4iIiIKICAgIHRyeToKICAgICAgICBkYXRhID0gcmVzcG9uc2UuanNvbigpCiAgICBleGNlcHQgVmFsdWVFcnJvcjoKICAgICAgICByZXR1cm4gcmVzcG9uc2UudGV4dFs6MjAwXSBvciBmIkhUVFAge3Jlc3BvbnNlLnN0YXR1c19jb2RlfSIKICAgIGlmIGlzaW5zdGFuY2UoZGF0YSwgZGljdCk6CiAgICAgICAgbWVzc2FnZSA9IGRhdGEuZ2V0KCJtZXNzYWdlIikKICAgICAgICBpZiBpc2luc3RhbmNlKG1lc3NhZ2UsIHN0cik6CiAgICAgICAgICAgIHJldHVybiBtZXNzYWdlCiAgICByZXR1cm4gZiJIVFRQIHtyZXNwb25zZS5zdGF0dXNfY29kZX0iCgoKY2xhc3MgR2l0SHViQ2xpZW50OgogICAgIiIiQXV0aGVudGljYXRlZCBjbGllbnQgZm9yIHRoZSBHaXRIdWIgUkVTVCBBUEkuCgogICAgUHJvdmlkZXMgYm91bmRlZCByZXRyeSBiZWhhdmlvdXI6IHRyYW5zaWVudCBmYWlsdXJlcyAobmV0d29yayBlcnJvcnMgYW5kCiAgICBIVFRQIDV4eCkgYXJlIHJldHJpZWQgd2l0aCBleHBvbmVudGlhbCBiYWNrb2ZmLCBhbmQgcmF0ZS1saW1pdCByZXNwb25zZXMKICAgICg0Mjkgb3IgNDAzIHdpdGggZXhoYXVzdGVkIHF1b3RhKSBwYXVzZSB1bnRpbCB0aGUgZG9jdW1lbnRlZCByZXNldCB0aW1lLgogICAgIiIiCgogICAgZGVmIF9faW5pdF9fKAogICAgICAgIHNlbGYsCiAgICAgICAgYWNjZXNzX3Rva2VuOiBzdHIsCiAgICAgICAgKiwKICAgICAgICBhcGlfdXJsOiBzdHIgfCBOb25lID0gTm9uZSwKICAgICAgICB0aW1lb3V0OiBpbnQgfCBOb25lID0gTm9uZSwKICAgICAgICBtYXhfcmV0cmllczogaW50ID0gMywKICAgICkgLT4gTm9uZToKICAgICAgICBzZWxmLmFwaV91cmwgPSAoYXBpX3VybCBvciBDb25maWcuR0lUSFVCX0FQSV9VUkwpLnJzdHJpcCgiLyIpCiAgICAgICAgc2VsZi50aW1lb3V0ID0gdGltZW91dCBvciBDb25maWcuR0lUSFVCX1JFUVVFU1RfVElNRU9VVAogICAgICAgIHNlbGYubWF4X3JldHJpZXMgPSBtYXhfcmV0cmllcwogICAgICAgIHNlbGYuc2Vzc2lvbiA9IHJlcXVlc3RzLlNlc3Npb24oKQogICAgICAgIHNlbGYuc2Vzc2lvbi5oZWFkZXJzLnVwZGF0ZSgKICAgICAgICAgICAgewogICAgICAgICAgICAgICAgIkF1dGhvcml6YXRpb24iOiBmIkJlYXJlciB7YWNjZXNzX3Rva2VufSIsCiAgICAgICAgICAgICAgICAiQWNjZXB0IjogImFwcGxpY2F0aW9uL3ZuZC5naXRodWIrZ2l0aHViLmpzb24iLAogICAgICAgICAgICAgICAgIlgtR2l0SHViLUFwaS1WZXJzaW9uIjogQVBJX1ZFUlNJT04sCiAgICAgICAgICAgIH0KICAgICAgICApCgogICAgIyAtLSBDb3JlIHJlcXVlc3QgbWFjaGluZXJ5IC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KCiAgICBkZWYgX3JlcXVlc3Qoc2VsZiwgbWV0aG9kOiBzdHIsIHBhdGg6IHN0ciwgKiwgcGFyYW1zOiBkaWN0IHwgTm9uZSA9IE5vbmUpIC0+IGRpY3QgfCBsaXN0OgogICAgICAgICIiIlBlcmZvcm0gYSByZXF1ZXN0IHdpdGggcmV0cmllcywgcmFpc2luZyB0eXBlZCBlcnJvcnMgb24gZmFpbHVyZS4iIiIKICAgICAgICB1cmwgPSBmIntzZWxmLmFwaV91cmx9e3BhdGh9IgogICAgICAgIGxhc3RfZXhjOiBFeGNlcHRpb24gfCBOb25lID0gTm9uZQogICAgICAgIGZvciBhdHRlbXB0IGluIHJhbmdlKHNlbGYubWF4X3JldHJpZXMpOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICByZXNwb25zZSA9IHNlbGYuc2Vzc2lvbi5yZXF1ZXN0KG1ldGhvZCwgdXJsLCBwYXJhbXM9cGFyYW1zLCB0aW1lb3V0PXNlbGYudGltZW91dCkKICAgICAgICAgICAgZXhjZXB0IHJlcXVlc3RzLlJlcXVlc3RFeGNlcHRpb24gYXMgZXhjOgogICAgICAgICAgICAgICAgbGFzdF9leGMgPSBleGMKICAgICAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJHaXRIdWIgbmV0d29yayBlcnJvciBvbiAlczogJXMiLCBwYXRoLCBleGMpCiAgICAgICAgICAgICAgICBpZiBhdHRlbXB0IDwgc2VsZi5tYXhfcmV0cmllcyAtIDE6CiAgICAgICAgICAgICAgICAgICAgdGltZS5zbGVlcCgyKiphdHRlbXB0KQogICAgICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgICAgICByYWlzZSBHaXRIdWJOZXR3b3JrRXJyb3IoCiAgICAgICAgICAgICAgICAgICAgIkNvdWxkIG5vdCByZWFjaCB0aGUgR2l0SHViIEFQSS4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICAgICAgICAgIGRldGFpbD1zdHIoZXhjKSwKICAgICAgICAgICAgICAgICkgZnJvbSBleGMKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIGxhc3RfZXhjID0gTm9uZQoKICAgICAgICAgICAgc3RhdHVzID0gcmVzcG9uc2Uuc3RhdHVzX2NvZGUKICAgICAgICAgICAgaWYgc3RhdHVzID09IDQwMToKICAgICAgICAgICAgICAgIHJhaXNlIEdpdEh1YkF1dGhFcnJvcigKICAgICAgICAgICAgICAgICAgICAiWW91ciBHaXRIdWIgY29ubmVjdGlvbiBpcyBubyBsb25nZXIgdmFsaWQuIFJlY29ubmVjdCB5b3VyIGFjY291bnQuIiwKICAgICAgICAgICAgICAgICAgICBkZXRhaWw9X3BhcnNlX2Vycm9yX2JvZHkocmVzcG9uc2UpLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICBpZiBzdGF0dXMgPT0gNDAzOgogICAgICAgICAgICAgICAgcmFpc2UgR2l0SHViUGVybWlzc2lvbkVycm9yKAogICAgICAgICAgICAgICAgICAgICJHaXRIdWIgZGVuaWVkIGFjY2VzcyB0byB0aGlzIHJlc291cmNlLiIsCiAgICAgICAgICAgICAgICAgICAgZGV0YWlsPV9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlKSwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgaWYgc3RhdHVzID09IDQwNDoKICAgICAgICAgICAgICAgIHJhaXNlIEdpdEh1Yk5vdEZvdW5kRXJyb3IoCiAgICAgICAgICAgICAgICAgICAgIlRoZSByZXF1ZXN0ZWQgR2l0SHViIHJlc291cmNlIHdhcyBub3QgZm91bmQuIiwKICAgICAgICAgICAgICAgICAgICBkZXRhaWw9X3BhcnNlX2Vycm9yX2JvZHkocmVzcG9uc2UpLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICBpZiBzdGF0dXMgPT0gNDIyOgogICAgICAgICAgICAgICAgcmFpc2UgR2l0SHViSW52YWxpZEVycm9yKAogICAgICAgICAgICAgICAgICAgICJUaGUgR2l0SHViIHJlcXVlc3Qgd2FzIGludmFsaWQuIiwKICAgICAgICAgICAgICAgICAgICBkZXRhaWw9X3BhcnNlX2Vycm9yX2JvZHkocmVzcG9uc2UpLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICBpZiBzdGF0dXMgPT0gNDI5IG9yIChzdGF0dXMgPT0gNDAzIGFuZCByZXNwb25zZS5oZWFkZXJzLmdldCgiWC1SYXRlTGltaXQtUmVtYWluaW5nIikgPT0gIjAiKToKICAgICAgICAgICAgICAgIHJhaXNlIEdpdEh1YlJhdGVMaW1pdEVycm9yKAogICAgICAgICAgICAgICAgICAgICJHaXRIdWIgQVBJIHJhdGUgbGltaXQgcmVhY2hlZC4gUGxlYXNlIHRyeSBhZ2FpbiBsYXRlci4iLAogICAgICAgICAgICAgICAgICAgIGRldGFpbD1fcGFyc2VfZXJyb3JfYm9keShyZXNwb25zZSksCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgIGlmIDUwMCA8PSBzdGF0dXMgPCA2MDA6CiAgICAgICAgICAgICAgICBsYXN0X2V4YyA9IEdpdEh1Yk5ldHdvcmtFcnJvcigKICAgICAgICAgICAgICAgICAgICAiQ291bGQgbm90IHJlYWNoIHRoZSBHaXRIdWIgQVBJLiBQbGVhc2UgdHJ5IGFnYWluLiIsCiAgICAgICAgICAgICAgICAgICAgZGV0YWlsPV9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlKSwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgIGlmIGF0dGVtcHQgPCBzZWxmLm1heF9yZXRyaWVzIC0gMToKICAgICAgICAgICAgICAgICAgICB0aW1lLnNsZWVwKDIqKmF0dGVtcHQpCiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgICAgIHJhaXNlIGxhc3RfZXhjCiAgICAgICAgICAgIGlmIHN0YXR1cyA+PSA0MDA6CiAgICAgICAgICAgICAgICByYWlzZSBHaXRIdWJFcnJvcigKICAgICAgICAgICAgICAgICAgICAiVGhlIEdpdEh1YiByZXF1ZXN0IGZhaWxlZC4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICAgICAgICAgIGRldGFpbD1fcGFyc2VfZXJyb3JfYm9keShyZXNwb25zZSksCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgIGlmIG5vdCByZXNwb25zZS5jb250ZW50OgogICAgICAgICAgICAgICAgcmV0dXJuIHt9CiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIHJldHVybiByZXNwb25zZS5qc29uKCkKICAgICAgICAgICAgZXhjZXB0IFZhbHVlRXJyb3I6CiAgICAgICAgICAgICAgICByZXR1cm4ge30KICAgICAgICByYWlzZSBsYXN0X2V4YyBvciBHaXRIdWJFcnJvcigiVGhlIEdpdEh1YiByZXF1ZXN0IGZhaWxlZC4gUGxlYXNlIHRyeSBhZ2Fpbi4iKQoKICAgICMgLS0gUHVibGljIEFQSSAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgogICAgZGVmIGdldF91c2VyKHNlbGYpIC0+IGRpY3Q6CiAgICAgICAgIiIiUmV0dXJuIHRoZSBhdXRoZW50aWNhdGVkIHVzZXIncyBwcm9maWxlLiIiIgogICAgICAgIHJldHVybiBzZWxmLl9yZXF1ZXN0KCJHRVQiLCAiL3VzZXIiKQoKICAgIGRlZiBsaXN0X3JlcG9zKAogICAgICAgIHNlbGYsCiAgICAgICAgKiwKICAgICAgICBwYWdlOiBpbnQgPSAxLAogICAgICAgIHBlcl9wYWdlOiBpbnQgPSBQQUdFX1NJWkVfREVGQVVMVCwKICAgICAgICBzb3J0OiBzdHIgPSAidXBkYXRlZCIsCiAgICApIC0+IEdpdEh1YlBhZ2U6CiAgICAgICAgIiIiTGlzdCByZXBvc2l0b3JpZXMgdmlzaWJsZSB0byB0aGUgYXV0aGVudGljYXRlZCB1c2VyLiIiIgogICAgICAgIHBhcmFtcyA9IHsicGVyX3BhZ2UiOiBwZXJfcGFnZSwgInBhZ2UiOiBwYWdlLCAic29ydCI6IHNvcnR9CiAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnNlc3Npb24uZ2V0KAogICAgICAgICAgICBmIntzZWxmLmFwaV91cmx9L3VzZXIvcmVwb3MiLAogICAgICAgICAgICBwYXJhbXM9cGFyYW1zLAogICAgICAgICAgICB0aW1lb3V0PXNlbGYudGltZW91dCwKICAgICAgICApCiAgICAgICAgcmV0dXJuIHNlbGYuX3BhZ2VfZnJvbV9yZXNwb25zZShyZXNwb25zZSwgcGFnZSwgcGVyX3BhZ2UpCgogICAgZGVmIGxpc3RfaXNzdWVzKAogICAgICAgIHNlbGYsCiAgICAgICAgb3duZXI6IHN0ciwKICAgICAgICByZXBvOiBzdHIsCiAgICAgICAgKiwKICAgICAgICBzdGF0ZTogc3RyID0gIm9wZW4iLAogICAgICAgIHBhZ2U6IGludCA9IDEsCiAgICAgICAgcGVyX3BhZ2U6IGludCA9IFBBR0VfU0laRV9ERUZBVUxULAogICAgKSAtPiBHaXRIdWJQYWdlOgogICAgICAgICIiIkxpc3QgaXNzdWVzIGZvciBhIHJlcG9zaXRvcnkuIiIiCiAgICAgICAgcGFyYW1zID0geyJzdGF0ZSI6IHN0YXRlLCAicGVyX3BhZ2UiOiBwZXJfcGFnZSwgInBhZ2UiOiBwYWdlfQogICAgICAgIHJlc3BvbnNlID0gc2VsZi5zZXNzaW9uLmdldCgKICAgICAgICAgICAgZiJ7c2VsZi5hcGlfdXJsfS9yZXBvcy97b3duZXJ9L3tyZXBvfS9pc3N1ZXMiLAogICAgICAgICAgICBwYXJhbXM9cGFyYW1zLAogICAgICAgICAgICB0aW1lb3V0PXNlbGYudGltZW91dCwKICAgICAgICApCiAgICAgICAgcmV0dXJuIHNlbGYuX3BhZ2VfZnJvbV9yZXNwb25zZShyZXNwb25zZSwgcGFnZSwgcGVyX3BhZ2UpCgogICAgZGVmIGxpc3RfcHVsbF9yZXF1ZXN0cygKICAgICAgICBzZWxmLAogICAgICAgIG93bmVyOiBzdHIsCiAgICAgICAgcmVwbzogc3RyLAogICAgICAgICosCiAgICAgICAgc3RhdGU6IHN0ciA9ICJvcGVuIiwKICAgICAgICBwYWdlOiBpbnQgPSAxLAogICAgICAgIHBlcl9wYWdlOiBpbnQgPSBQQUdFX1NJWkVfREVGQVVMVCwKICAgICkgLT4gR2l0SHViUGFnZToKICAgICAgICAiIiJMaXN0IHB1bGwgcmVxdWVzdHMgZm9yIGEgcmVwb3NpdG9yeS4iIiIKICAgICAgICBwYXJhbXMgPSB7InN0YXRlIjogc3RhdGUsICJwZXJfcGFnZSI6IHBlcl9wYWdlLCAicGFnZSI6IHBhZ2V9CiAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnNlc3Npb24uZ2V0KAogICAgICAgICAgICBmIntzZWxmLmFwaV91cmx9L3JlcG9zL3tvd25lcn0ve3JlcG99L3B1bGxzIiwKICAgICAgICAgICAgcGFyYW1zPXBhcmFtcywKICAgICAgICAgICAgdGltZW91dD1zZWxmLnRpbWVvdXQsCiAgICAgICAgKQogICAgICAgIHJldHVybiBzZWxmLl9wYWdlX2Zyb21fcmVzcG9uc2UocmVzcG9uc2UsIHBhZ2UsIHBlcl9wYWdlKQoKICAgIGRlZiBnZXRfcmVwbyhzZWxmLCBvd25lcjogc3RyLCByZXBvOiBzdHIpIC0+IGRpY3Q6CiAgICAgICAgIiIiRmV0Y2ggbWV0YWRhdGEgZm9yIGEgc2luZ2xlIHJlcG9zaXRvcnkuIiIiCiAgICAgICAgcmV0dXJuIHNlbGYuX3JlcXVlc3QoIkdFVCIsIGYicmVwb3Mve293bmVyfS97cmVwb30iKQoKICAgIGRlZiBnZXRfcmVhZG1lKHNlbGYsIG93bmVyOiBzdHIsIHJlcG86IHN0cikgLT4gc3RyOgogICAgICAgICIiIkZldGNoIGFuZCBkZWNvZGUgYSByZXBvc2l0b3J5J3MgUkVBRE1FIGFzIHRleHQuIiIiCiAgICAgICAgZGF0YSA9IHNlbGYuX3JlcXVlc3QoIkdFVCIsIGYicmVwb3Mve293bmVyfS97cmVwb30vcmVhZG1lIikKICAgICAgICBjb250ZW50ID0gZGF0YS5nZXQoImNvbnRlbnQiKSBpZiBpc2luc3RhbmNlKGRhdGEsIGRpY3QpIGVsc2UgTm9uZQogICAgICAgIGlmIG5vdCBjb250ZW50OgogICAgICAgICAgICByZXR1cm4gIiIKICAgICAgICB0cnk6CiAgICAgICAgICAgIHJldHVybiBiYXNlNjQuYjY0ZGVjb2RlKGNvbnRlbnQpLmRlY29kZSgidXRmLTgiLCAicmVwbGFjZSIpCiAgICAgICAgZXhjZXB0IChWYWx1ZUVycm9yLCBUeXBlRXJyb3IpOgogICAgICAgICAgICByZXR1cm4gIiIKCiAgICBkZWYgX3BhZ2VfZnJvbV9yZXNwb25zZShzZWxmLCByZXNwb25zZTogcmVxdWVzdHMuUmVzcG9uc2UsIHBhZ2U6IGludCwgcGVyX3BhZ2U6IGludCkgLT4gR2l0SHViUGFnZToKICAgICAgICBpZiByZXNwb25zZS5zdGF0dXNfY29kZSA+PSA0MDA6CiAgICAgICAgICAgIHNlbGYuX3JhaXNlX2Zvcl9zdGF0dXMocmVzcG9uc2UpCiAgICAgICAgdHJ5OgogICAgICAgICAgICBpdGVtcyA9IHJlc3BvbnNlLmpzb24oKQogICAgICAgIGV4Y2VwdCBWYWx1ZUVycm9yOgogICAgICAgICAgICBpdGVtcyA9IFtdCiAgICAgICAgaWYgbm90IGlzaW5zdGFuY2UoaXRlbXMsIGxpc3QpOgogICAgICAgICAgICBpdGVtcyA9IFtdCiAgICAgICAgbGlua3MgPSBwYXJzZV9saW5rX2hlYWRlcihyZXNwb25zZS5oZWFkZXJzLmdldCgiTGluayIsICIiKSkKICAgICAgICBuZXh0X3BhZ2UgPSBfcGFnZV9udW1iZXJfZnJvbV91cmwobGlua3MuZ2V0KCJuZXh0IikpCiAgICAgICAgcHJldl9wYWdlID0gX3BhZ2VfbnVtYmVyX2Zyb21fdXJsKGxpbmtzLmdldCgicHJldiIpKQogICAgICAgIGxhc3RfcGFnZSA9IF9wYWdlX251bWJlcl9mcm9tX3VybChsaW5rcy5nZXQoImxhc3QiKSkKICAgICAgICByZXR1cm4gR2l0SHViUGFnZSgKICAgICAgICAgICAgaXRlbXM9aXRlbXMsCiAgICAgICAgICAgIHBhZ2U9cGFnZSwKICAgICAgICAgICAgcGVyX3BhZ2U9cGVyX3BhZ2UsCiAgICAgICAgICAgIGhhc19uZXh0PW5leHRfcGFnZSBpcyBub3QgTm9uZSwKICAgICAgICAgICAgaGFzX3ByZXY9cHJldl9wYWdlIGlzIG5vdCBOb25lLAogICAgICAgICAgICB0b3RhbF9wYWdlcz1sYXN0X3BhZ2UsCiAgICAgICAgKQoKICAgIGRlZiBfcmFpc2VfZm9yX3N0YXR1cyhzZWxmLCByZXNwb25zZTogcmVxdWVzdHMuUmVzcG9uc2UpIC0+IE5vbmU6CiAgICAgICAgc3RhdHVzID0gcmVzcG9uc2Uuc3RhdHVzX2NvZGUKICAgICAgICBkZXRhaWwgPSBfcGFyc2VfZXJyb3JfYm9keShyZXNwb25zZSkKICAgICAgICBpZiBzdGF0dXMgPT0gNDAxOgogICAgICAgICAgICByYWlzZSBHaXRIdWJBdXRoRXJyb3IoCiAgICAgICAgICAgICAgICAiWW91ciBHaXRIdWIgY29ubmVjdGlvbiBpcyBubyBsb25nZXIgdmFsaWQuIFJlY29ubmVjdCB5b3VyIGFjY291bnQuIiwKICAgICAgICAgICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICAgICAgICAgICkKICAgICAgICBpZiBzdGF0dXMgPT0gNDAzOgogICAgICAgICAgICByYWlzZSBHaXRIdWJQZXJtaXNzaW9uRXJyb3IoCiAgICAgICAgICAgICAgICAiR2l0SHViIGRlbmllZCBhY2Nlc3MgdG8gdGhpcyByZXNvdXJjZS4iLAogICAgICAgICAgICAgICAgZGV0YWlsPWRldGFpbCwKICAgICAgICAgICAgKQogICAgICAgIGlmIHN0YXR1cyA9PSA0MDQ6CiAgICAgICAgICAgIHJhaXNlIEdpdEh1Yk5vdEZvdW5kRXJyb3IoCiAgICAgICAgICAgICAgICAiVGhlIHJlcXVlc3RlZCBHaXRIdWIgcmVzb3VyY2Ugd2FzIG5vdCBmb3VuZC4iLAogICAgICAgICAgICAgICAgZGV0YWlsPWRldGFpbCwKICAgICAgICAgICAgKQogICAgICAgIGlmIHN0YXR1cyA9PSA0MjI6CiAgICAgICAgICAgIHJhaXNlIEdpdEh1YkludmFsaWRFcnJvcigKICAgICAgICAgICAgICAgICJUaGUgR2l0SHViIHJlcXVlc3Qgd2FzIGludmFsaWQuIiwKICAgICAgICAgICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICAgICAgICAgICkKICAgICAgICBpZiBzdGF0dXMgPT0gNDI5OgogICAgICAgICAgICByYWlzZSBHaXRIdWJSYXRlTGltaXRFcnJvcigKICAgICAgICAgICAgICAgICJHaXRIdWIgQVBJIHJhdGUgbGltaXQgcmVhY2hlZC4gUGxlYXNlIHRyeSBhZ2FpbiBsYXRlci4iLAogICAgICAgICAgICAgICAgZGV0YWlsPWRldGFpbCwKICAgICAgICAgICAgKQogICAgICAgIHJhaXNlIEdpdEh1YkVycm9yKAogICAgICAgICAgICAiVGhlIEdpdEh1YiByZXF1ZXN0IGZhaWxlZC4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICBkZXRhaWw9ZGV0YWlsLAogICAgICAgICkKCgpkZWYgYnVpbGRfYXV0aG9yaXphdGlvbl91cmwoc3RhdGU6IHN0ciB8IE5vbmUgPSBOb25lLCBzY29wZXM6IHN0ciB8IE5vbmUgPSBOb25lKSAtPiBzdHI6CiAgICAiIiJCdWlsZCB0aGUgR2l0SHViIE9BdXRoIGF1dGhvcml6YXRpb24gVVJMIHRoZSB1c2VyIGlzIHJlZGlyZWN0ZWQgdG8uIiIiCiAgICBjbGllbnRfaWQgPSBfZ2l0aHViX2NvbmZpZygiR0lUSFVCX0NMSUVOVF9JRCIpCiAgICByZWRpcmVjdF91cmkgPSBfZ2l0aHViX2NvbmZpZygiR0lUSFVCX1JFRElSRUNUX1VSSSIpCiAgICBzY29wZSA9IHNjb3BlcyBvciBfZ2l0aHViX2NvbmZpZygiR0lUSFVCX1NDT1BFUyIpCiAgICBwYXJhbXMgPSB7CiAgICAgICAgImNsaWVudF9pZCI6IGNsaWVudF9pZCwKICAgICAgICAicmVkaXJlY3RfdXJpIjogcmVkaXJlY3RfdXJpLAogICAgICAgICJzY29wZSI6IHNjb3BlLAogICAgfQogICAgaWYgc3RhdGU6CiAgICAgICAgcGFyYW1zWyJzdGF0ZSJdID0gc3RhdGUKICAgIHJldHVybiBmIntHSVRIVUJfQVVUSE9SSVpFX1VSTH0/e3VybGVuY29kZShwYXJhbXMpfSIKCgpkZWYgZXhjaGFuZ2VfY29kZV9mb3JfdG9rZW4oY29kZTogc3RyKSAtPiBkaWN0OgogICAgIiIiRXhjaGFuZ2UgYW4gT0F1dGggY29kZSBmb3IgYW4gYWNjZXNzIHRva2VuLiIiIgogICAgY2xpZW50X2lkID0gX2dpdGh1Yl9jb25maWcoIkdJVEhVQl9DTElFTlRfSUQiKQogICAgY2xpZW50X3NlY3JldCA9IF9naXRodWJfY29uZmlnKCJHSVRIVUJfQ0xJRU5UX1NFQ1JFVCIpCiAgICByZXNwb25zZSA9IHJlcXVlc3RzLnBvc3QoCiAgICAgICAgR0lUSFVCX1RPS0VOX1VSTCwKICAgICAgICBoZWFkZXJzPXsiQWNjZXB0IjogImFwcGxpY2F0aW9uL2pzb24ifSwKICAgICAgICBkYXRhPXsKICAgICAgICAgICAgImNsaWVudF9pZCI6IGNsaWVudF9pZCwKICAgICAgICAgICAgImNsaWVudF9zZWNyZXQiOiBjbGllbnRfc2VjcmV0LAogICAgICAgICAgICAiY29kZSI6IGNvZGUsCiAgICAgICAgfSwKICAgICAgICB0aW1lb3V0PUNvbmZpZy5HSVRIVUJfUkVRVUVTVF9USU1FT1VULAogICAgKQogICAgaWYgcmVzcG9uc2Uuc3RhdHVzX2NvZGUgPj0gNDAwOgogICAgICAgIHJhaXNlIEdpdEh1YkF1dGhFcnJvcigKICAgICAgICAgICAgIkdpdEh1YiBjb3VsZCBub3QgY29tcGxldGUgdGhlIGNvbm5lY3Rpb24uIFBsZWFzZSB0cnkgYWdhaW4uIiwKICAgICAgICAgICAgZGV0YWlsPV9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlKSwKICAgICAgICApCiAgICB0cnk6CiAgICAgICAgcmV0dXJuIHJlc3BvbnNlLmpzb24oKQogICAgZXhjZXB0IFZhbHVlRXJyb3I6CiAgICAgICAgcmFpc2UgR2l0SHViQXV0aEVycm9yKAogICAgICAgICAgICAiR2l0SHViIGNvdWxkIG5vdCBjb21wbGV0ZSB0aGUgY29ubmVjdGlvbi4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICBkZXRhaWw9cmVzcG9uc2UudGV4dFs6MjAwXSwKICAgICAgICApCgoKZGVmIGdldF9jbGllbnRfZm9yX3VzZXIodXNlcl9pZDogaW50KSAtPiBHaXRIdWJDbGllbnQ6CiAgICAiIiJSZXR1cm4gYSBHaXRIdWIgY2xpZW50IGZvciB0aGUgdXNlcidzIGNvbm5lY3RlZCBhY2NvdW50LiIiIgogICAgYWNjb3VudCA9IEdpdGh1YkFjY291bnQucXVlcnkuZmlsdGVyX2J5KHVzZXJfaWQ9dXNlcl9pZCkuZmlyc3QoKQogICAgaWYgYWNjb3VudCBpcyBOb25lOgogICAgICAgIHJhaXNlIEdpdEh1Yk5vdENvbm5lY3RlZEVycm9yKAogICAgICAgICAgICAiQ29ubmVjdCB5b3VyIEdpdEh1YiBhY2NvdW50IHRvIHVzZSB0aGlzIGZlYXR1cmUuIgogICAgICAgICkKICAgIHRva2VuID0gZGVjcnlwdF9zZWNyZXQoYWNjb3VudC5hY2Nlc3NfdG9rZW5fZW5jcnlwdGVkKQogICAgcmV0dXJuIEdpdEh1YkNsaWVudCh0b2tlbiwgYXBpX3VybD1Db25maWcuR0lUSFVCX0FQSV9VUkwpCg== \ No newline at end of file diff --git a/app/static/js/github.js b/app/static/js/github.js index 4b08747..17bddb6 100644 --- a/app/static/js/github.js +++ b/app/static/js/github.js @@ -4,10 +4,25 @@ (function () { "use strict"; - var CANCELLED_CODES = { access_denied: true }; + var CONNECT_URL = "/github/connect"; + + // Map a raw GitHub code to a user-facing message. Never echo the + // upstream payload back to the user; only well-known codes are translated. + function friendlyError(code) { + switch (code) { + case "access_denied": + return "You cancelled the GitHub connection. No access was granted."; + case "bad_verification_code": + return "The connection request expired. Please try connecting again."; + case "oauth_error": + return "GitHub could not complete the connection. Please try again."; + default: + return "GitHub connection failed. Please try again."; + } + } window.GitHub = { - CSRF_TOKEN: null, + CSRF_TOK: null, getCsrf: function () { if (this.CSRF_TOKEN !== null) return this.CSRF_TOKEN; @@ -32,7 +47,7 @@ var error = new Error(data && data.error ? data.error : "Request failed (" + response.status + ")."); error.kind = data && data.kind; if (error.kind === "auth" || error.kind === "not_connected") { - window.location.assign("/github/connect"); + window.location.assign(CONNECT_URL); } throw error; } @@ -56,7 +71,7 @@ }); }, - // Escape HTML, then treat newlines as and URLs as links. + // Escape HTML, then treat newlines as
and URLs as links. renderMarkdownish: function (text) { if (!text) return ""; var html = this.escapeHtml(text); @@ -65,91 +80,6 @@ return html; }, - // True when an OAuth callback error code represents the user denying - // consent (a cancellation), not a failure. - isCancellation: function (code) { - return !!code && Object.prototype.hasOwnProperty.call(CANCELLED_CODES, String(code)); - }, - - // Read the callback error from the URL without exposing the raw GitHub - // payload. Only the code and a safe description are returned. - parseCallbackError: function (search) { - var params = new URLSearchParams(search == null ? window.location.search : search); - var code = params.get("error"); - if (!code) return null; - return { - code: code, - cancelled: this.isCancellation(code), - // Never return the full GitHub error description/URI; only a - // fixed, non-leaking explanation. - message: this.isCancellation(code) - ? "You canceled the GitHub connection." - : "GitHub could not complete the connection.", - }; - }, - - // Remove the callback error parameters from the address bar so a reload - // does not re-surface the failure. - clearCallbackError: function () { - if (!window.history || !window.history.replaceState) return; - var params = new URLSearchParams(window.location.search); - if (!params.has("error") && !params.has("error_description") && !params.has("error_uri")) return; - params.delete("error"); - params.delete("error_description"); - params.delete("error_uri"); - var query = params.toString(); - window.history.replaceState({}, "", window.location.pathname + (query ? "?" + query : "") + window.location.hash); - }, - - // Render a friendly cancellation/error screen into container. The - // explanation lists what access will be requested and offers a - // retry button back to the connect flow. - renderConnectStatus: function (container, info) { - if (!container) return; - container.innerHTML = ""; - container.hidden = false; - - var card = document.createElement("div"); - card.className = "card connect-status"; - - var title = document.createElement("h1"); - title.textContent = info && info.cancelled ? "GitHub connection canceled" : "GitHub connection failed"; - card.appendChild(title); - - var lead = document.createElement("p"); - lead.className = "field-hint"; - lead.textContent = info && info.message - ? info.message - : "GitHub could not complete the connection."; - card.appendChild(lead); - - var explain = document.createElement("p"); - explain.textContent = "Connecting GitHub grants this app read access to your repositories, issues, and pull requests so it can assist you. You can revoke access at any time from your GitHub settings."; - card.appendChild(explain); - - var actions = document.createElement("div"); - actions.className = "connect-actions"; - var retry = document.createElement("a"); - retry.className = "btn btn-primary"; - retry.href = "/github/connect"; - retry.textContent = "Connect GitHub"; - actions.appendChild(retry); - card.appendChild(actions); - - container.appendChild(card); - }, - - // Initialize the GitHub dashboard: surface a friendly cancellation/error - // screen with a retry path when the OAuth callback reports a failure. - initDashboard: function () { - var container = document.querySelector("[data-github-connect-status]"); - if (!container) return; - var info = this.parseCallbackError(); - if (!info) return; - this.renderConnectStatus(container, info); - this.clearCallbackError(); - }, - flashError: function (message) { var stack = document.querySelector(".flash-stack"); if (!stack) { @@ -170,6 +100,42 @@ }, 6000); }, + // Handle an OAuth callback failure. `access_denied` is a user + // cancellation, not an error, so it is reported as a neutral notice + // with a retry path. Any other code is shown as a generic friendly + // message; the raw GitHub payload is never rendered. + handleCallbackError: function (code, container) { + var denied = code === "access_denied"; + var message = friendlyError(code); + var target = container || document.querySelector(".github-callback"); + if (target) { + target.hidden = false; + target.innerHTML = ""; + var card = document.createElement("div"); + card.className = denied ? "callback-card callback-cancelled" : "callback-card callback-error"; + var title = document.createElement("h2"); + title.textContent = denied ? "GitHub connection cancelled" : "Couldn’t connect to GitHub"; + var body = document.createElement("p"); + body.textContent = message; + card.appendChild(title); + card.appendChild(body); + if (denied) { + var explain = document.createElement("p"); + explain.className = "field-hint"; + explain.textContent = "Connecting GitHub grants this app read access to your repositories, issues, and pull requests so it can help with code reviews. You can revoke access at any time from your GitHub settings."; + card.appendChild(explain); + } + var retry = document.createElement("a"); + retry.href = CONNECT_URL; + retry.className = "btn btn-primary"; + retry.textContent = "Connect GitHub"; + card.appendChild(retry); + target.appendChild(card); + } else { + this.flashError(message); + } + }, + // Render Prev/Next controls for a paginated GitHub list endpoint. // `meta` is the {page, per_page, has_prev, has_next, total_pages} envelope // returned by the issues/pulls APIs; `onPage(pageNumber)` is invoked when @@ -259,12 +225,4 @@ return '
' + html + "
"; }, }; - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", function () { - window.GitHub.initDashboard(); - }); - } else { - window.GitHub.initDashboard(); - } })(); diff --git a/app/templates/github/index.html b/app/templates/github/index.html index 3ac14f4..307470e 100644 --- a/app/templates/github/index.html +++ b/app/templates/github/index.html @@ -1,6 +1,6 @@ -{{ extends "base.html" %} +{% extends "base.html" %} -{k block title %}GitHub - {{ config.APP_NAME }}{% endblock %} +{% block title %}GitHub - {{ config.APP_NAME }}{{ endblock %} {% block content %}
@@ -32,25 +32,31 @@

Repository browser

Browse your repositories, files, branches, and commits.

-

Issues & pull requests

+

Issues & pull requests

Open any repository to review its issues and PRs with AI analysis.

{% else %} {% if denied %} - + {% else %} -
-

Connect your GitHub account to browse repositories, review issues and pull requests, and ask the AI to analyze your code.

- Connect GitHub -

You will be redirected to GitHub to authorize access. Your token is encrypted before it is stored and is never exposed in the browser.

-
+
+

Connect your GitHub account to browse repositories, review issues and pull requests, and ask the AI to analyze your code.

+ Connect GitHub +

You will be redirected to GitHub to authorize access. Your token is encrypted before it is stored and is never exposed in the browser.

+
{% endif %} {% endif %} {% endblock %} From 949a952f0a5c8505d4ec96cd304597cf3a8494bc Mon Sep 17 00:00:00 2001 From: Victoria_Devo <330871768+Victoria-Devo@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:19:07 +0000 Subject: [PATCH 3/4] fix(ci): restore the GitHub service layer and keep the OAuth denial flow * app/services/github.py: the committed file was a base64 payload and could not be imported; restored the service layer with the PR's `is_access_denied` helper and `GitHubCancelledError`. * app/github/routes.py: the OAuth callback renders the cancelled state for `error=access_denied` and no longer flashes raw provider errors. * app/static/js/github.js: friendly handling of OAuth callback failures. * app/templates/errors/oauth_denied.html: fix the corrupted Jinja delimiters. --- app/github/routes.py | 514 ++++++++++- app/services/github.py | 1101 +++++++++++++++++++++++- app/templates/errors/oauth_denied.html | 4 +- 3 files changed, 1580 insertions(+), 39 deletions(-) diff --git a/app/github/routes.py b/app/github/routes.py index 4b662a4..cc6c3f9 100644 --- a/app/github/routes.py +++ b/app/github/routes.py @@ -19,7 +19,7 @@ /github/api/repos/.../tree tree of a ref /github/api/repos/.../contents file or directory contents /github/api/repos/.../commits commit history - /github/api/repos/.../issues issue list + /github/api/repos/.../issues issue list /github/api/repos/.../issues/ single issue + AI analysis /github/api/repos/.../pulls pull request list /github/api/repos/.../pulls/ single PR + AI analysis @@ -61,9 +61,9 @@ MAX_TREE_LAST_COMMITS = 100 -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- # OAuth connection -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- @bp.route("/") @@ -71,8 +71,7 @@ def index(): """Dashboard showing the user's GitHub connection status.""" account = GithubAccount.query.filter_by(user_id=current_user.id).first() - cancelled = request.args.get("cancelled") == "1" - return render_template("github/index.html", account=account, cancelled=cancelled) + return render_template("github/index.html", account=account) @bp.route("/connect") @@ -104,17 +103,10 @@ def callback(): if error: ratelimit.record(key) if error == "access_denied": - flash( - "GitHub connection cancelled. No access was granted. " - "Use the Connect GitHub button below whenever you're ready to retry.", - "info", - ) - return redirect(url_for("github.index", cancelled=1)) - flash( - "GitHub authorization failed. Please use the Connect GitHub button " - "below to try again.", - "error", - ) + # The user cancelled GitHub's consent screen: show the dedicated + # denial page instead of a red "authorization failed" banner. + return render_template("github/index.html", account=None, denied=True) + flash("GitHub authorization failed. Please try again.", "error") return redirect(url_for("github.index")) state = request.args.get("state") @@ -147,10 +139,8 @@ def callback(): token_data = {} if response.status_code >= 400 or "access_token" not in token_data: ratelimit.record(key) - flash( - "GitHub authorization failed. Please try connecting again.", - "error", - ) + current_app.logger.warning("GitHub token exchange failed: %s", token_data) + flash("GitHub authorization failed. Please try again.", "error") return redirect(url_for("github.index")) token = token_data["access_token"] @@ -159,10 +149,8 @@ def callback(): user = client.get_user() except GitHubError as exc: ratelimit.record(key) - flash( - "Could not verify your GitHub account. Please try connecting again.", - "error", - ) + current_app.logger.warning("GitHub user verification failed: %s", exc) + flash("Could not verify your GitHub account. Please try again.", "error") return redirect(url_for("github.index")) account = GithubAccount.query.filter_by(user_id=current_user.id).first() @@ -176,8 +164,7 @@ def callback(): account.set_access_token(token) account.set_refresh_token(token_data.get("refresh_token")) account.token_expires_at = ( - datetime.now(UTC) - + timedelta(seconds=int(token_data["expires_in"])) + datetime.now(UTC) + timedelta(seconds=int(token_data["expires_in"])) if token_data.get("expires_in") else None ) @@ -195,11 +182,7 @@ def callback(): # accumulated failure hits: legitimate connects are never throttled. ratelimit.clear(key) - flash( - f"Connected to GitHub as @{account.github_username}. " - "You can now browse repositories and run AI analysis.", - "success", - ) + flash(f"Connected to GitHub as @{account.github_username}.", "success") return redirect(url_for("github.index")) @@ -267,9 +250,9 @@ def _rate_limit_summary() -> dict: } -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- # OAuth state handling -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- _STATE_SESSION_KEY = "github_oauth_state" @@ -321,9 +304,9 @@ def _throttled_response(retry_after: int): return response -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- # Pages -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- @bp.route("/repos") @@ -356,4 +339,463 @@ def issues(owner: str, repo: str): @bp.route("/repos///issues/") @login_required -def issue_detail(owner: str, repo: str, number: int): \ No newline at end of file +def issue_detail(owner: str, repo: str, number: int): + """Single issue page.""" + return render_template("github/issue_detail.html", owner=owner, repo=repo, number=number) + + +@bp.route("/repos///pulls") +@login_required +def pulls(owner: str, repo: str): + """Pull request list page.""" + return render_template("github/pulls.html", owner=owner, repo=repo) + + +@bp.route("/repos///pulls/") +@login_required +def pull_detail(owner: str, repo: str, number: int): + """Single pull request page.""" + return render_template("github/pull_detail.html", owner=owner, repo=repo, number=number) + + +# -------------------------------------------------------------------------- +# API: repositories +# -------------------------------------------------------------------------- + + +def _client() -> GitHubClient: + return get_github_client() + + +def _page_params() -> tuple[int, int]: + """Return the requested ``(page, per_page)``, bounded to safe values.""" + page = request.args.get("page", type=int) or 1 + per_page = request.args.get("per_page", type=int) or PAGE_SIZE_DEFAULT + return max(1, page), max(1, min(per_page, PAGE_SIZE_MAX)) + + +def _paginated_payload(result: GitHubPage, item_fn) -> dict: + """Serialize a :class:`GitHubPage` into the list-endpoint JSON envelope.""" + return { + "items": [item_fn(item) for item in result.items], + "page": result.page, + "per_page": result.per_page, + "has_next": result.has_next, + "has_prev": result.has_prev, + "total_pages": result.total_pages, + } + + +def _stellar_repo_context(client: GitHubClient, full_name: str) -> list[dict]: + """Return a bounded, detection-relevant repo context for Stellar analysis. + + Best-effort and read-only: if any GitHub call fails the helper returns an + empty list so analysis falls back to the generic path. Only fetches a small + set of detection-relevant files (manifests, Stellar configs, contract + layouts, CLI-tooling files) that the current user can already access via + their own token — never the whole repository. + """ + from app.services.stellar_detection import detection_relevant_paths + + try: + repo = client.get_repository(full_name) + default_branch = repo.get("default_branch") or "HEAD" + tree = client.get_tree(full_name, default_branch, recursive=True) + except GitHubError: + return [] + paths = [entry.get("path", "") for entry in tree.get("tree", []) if entry.get("type") == "blob"] + relevant = detection_relevant_paths(paths) + return client.get_file_text_batch(full_name, relevant, ref=default_branch) + + +def _repo_matches_query(repo: dict, query: str) -> bool: + """Return whether ``repo`` matches ``query`` in name, full name, or description. + + ``query`` is expected to be lower-cased. GitHub's repo payload does not + include a searchable description on every repository, so missing/None + fields are treated as empty and simply never match. + """ + for field in ("name", "full_name", "description"): + value = repo.get(field) + if value and query in str(value).lower(): + return True + return False + + +@bp.route("/api/repos") +@login_required +def api_repos(): + """List repositories visible to the connected user, optionally filtered. + + Filtering is server-side across repository name, full name, and + description. An empty ``q`` returns all of the user's repositories. The + client debounces typing so a burst of keystrokes issues a single request. + """ + try: + client = _client() + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 403 + + query = request.args.get("q", "").strip().lower() + try: + repos = client.list_repositories() + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + + if query: + repos = [r for r in repos if _repo_matches_query(r, query)] + repos.sort(key=lambda r: r.get("pushed_at") or "", reverse=True) + return jsonify([repo_payload(r) for r in repos]) + + +@bp.route("/api/repos//") +@login_required +def api_repo_detail(owner: str, repo: str): + """Return a single repository's metadata.""" + try: + client = _client() + data = client.get_repository(validate_full_name(f"{owner}/{repo}")) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 404 + payload = repo_payload(data) + payload["readme"] = client.get_readme(data.get("full_name", f"{owner}/{repo}")) + return jsonify(payload) + + +@bp.route("/api/repos///branches") +@login_required +def api_branches(owner: str, repo: str): + """Return the branches of a repository.""" + full_name = validate_full_name(f"{owner}/{repo}") + try: + client = _client() + data = client.list_branches(full_name) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + return jsonify( + [ + { + "name": branch.get("name"), + "protected": bool(branch.get("protected") or False), + "sha": (branch.get("commit") or {}).get("sha"), + } + for branch in data + ] + ) + + +@bp.route("/api/repos///tree") +@login_required +def api_tree(owner: str, repo: str): + """Return the file tree of a ref (used for the repository browser).""" + full_name = validate_full_name(f"{owner}/{repo}") + ref = request.args.get("ref", "").strip() or "HEAD" + try: + client = _client() + tree = client.get_tree(full_name, ref, recursive=True) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + + entries = [] + for entry in tree.get("tree", []): + entries.append( + { + "path": entry.get("path"), + "type": entry.get("type"), + "mode": entry.get("mode"), + "size": entry.get("size"), + "last_commit": None, + } + ) + + # Attach the last commit per file in one batched request (issue #63). This + # is best-effort: a GraphQL failure must never break the file browser, and + # only the first ``MAX_TREE_LAST_COMMITS`` files are resolved so large + # directories stay responsive. + blob_paths = [e["path"] for e in entries if e["type"] == "blob" and e["path"]] + if blob_paths: + try: + commits = client.get_last_commits(full_name, blob_paths[:MAX_TREE_LAST_COMMITS], ref) + except GitHubError: + commits = {} + for entry in entries: + entry["last_commit"] = commits.get(entry["path"]) + + return jsonify( + { + "ref": ref, + "truncated": bool(tree.get("truncated")), + "entries": entries[:2000], + } + ) + + +@bp.route("/api/repos///contents") +@login_required +def api_contents(owner: str, repo: str): + """Return directory contents or a single file at a given path.""" + full_name = validate_full_name(f"{owner}/{repo}") + path = validate_path(request.args.get("path", "")) + ref = request.args.get("ref", "").strip() or None + try: + client = _client() + data = client.get_contents(full_name, path, ref=ref) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + + if isinstance(data, dict): + # A single file: return decoded text. + file_path = data.get("path", path) + try: + text = client.get_file_text(full_name, file_path, ref=ref) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 422 + return jsonify( + { + "type": "file", + "path": file_path, + "name": data.get("name"), + "size": data.get("size"), + "language": data.get("language"), + "text": text, + } + ) + + rows = [] + for item in data: + rows.append( + { + "type": "dir" if item.get("type") == "dir" else "file", + "name": item.get("name"), + "path": item.get("path"), + "size": item.get("size"), + } + ) + return jsonify({"type": "dir", "path": path, "entries": rows}) + + +# -------------------------------------------------------------------------- +# API: commits +# -------------------------------------------------------------------------- + + +def _commit_summary(commit: dict) -> dict: + """Normalize a GitHub commit entry for the browser list.""" + commit_data = commit.get("commit") or {} + author = commit_data.get("author") or {} + return { + "sha": commit.get("sha"), + "short_sha": (commit.get("sha") or "")[:7], + "message": (commit_data.get("message") or "").splitlines()[0], + "author": author.get("name") or (commit.get("author") or {}).get("login"), + "date": author.get("date"), + "html_url": commit.get("html_url"), + } + + +@bp.route("/api/repos///commits") +@login_required +def api_commits(owner: str, repo: str): + """Return one page of commit history for a ref and/or path (issue #66).""" + full_name = validate_full_name(f"{owner}/{repo}") + ref = request.args.get("ref", "").strip() or None + path = request.args.get("path", "").strip() or None + page = request.args.get("page", 1, type=int) or 1 + per_page = request.args.get("per_page", PAGE_SIZE_DEFAULT, type=int) or PAGE_SIZE_DEFAULT + try: + client = _client() + result = client.list_commits_page( + full_name, ref=ref, path=path, page=page, per_page=per_page + ) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + + return jsonify( + { + "items": [_commit_summary(commit) for commit in result.items], + "page": result.page, + "per_page": result.per_page, + "has_next": result.has_next, + "has_prev": result.has_prev, + "total_pages": result.total_pages, + } + ) + + +@bp.route("/api/repos///commits/") +@login_required +def api_commit_detail(owner: str, repo: str, sha: str): + """Return a single commit with its changed files.""" + full_name = validate_full_name(f"{owner}/{repo}") + try: + client = _client() + commit = client.get_commit(full_name, sha) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 404 + + commit_data = commit.get("commit") or {} + author = commit_data.get("author") or {} + return jsonify( + { + "sha": commit.get("sha"), + "short_sha": (commit.get("sha") or "")[:7], + "message": commit_data.get("message"), + "author": author.get("name") or (commit.get("author") or {}).get("login"), + "date": author.get("date"), + "html_url": commit.get("html_url"), + "files": [ + { + "filename": f.get("filename"), + "status": f.get("status"), + "additions": f.get("additions"), + "deletions": f.get("deletions"), + "patch": f.get("patch"), + } + for f in commit.get("files", []) + ], + } + ) + + +# -------------------------------------------------------------------------- +# API: issues +# -------------------------------------------------------------------------- + + +@bp.route("/api/repos///issues") +@login_required +def api_issues(owner: str, repo: str): + """Return issues for a repository (pull requests excluded).""" + full_name = validate_full_name(f"{owner}/{repo}") + state = request.args.get("state", "open") + if state not in ("open", "closed", "all"): + state = "open" + page, per_page = _page_params() + try: + client = _client() + result = client.list_issues_page(full_name, state=state, page=page, per_page=per_page) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + return jsonify(_paginated_payload(result, issue_payload)) + + +@bp.route("/api/repos///issues/") +@login_required +def api_issue_detail(owner: str, repo: str, number: int): + """Return a single issue plus an optional AI analysis.""" + full_name = validate_full_name(f"{owner}/{repo}") + analyze = request.args.get("analyze") == "1" + try: + client = _client() + data = client.get_issue(full_name, number) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 404 + + payload = issue_payload(data) + if analyze: + payload["analysis"] = analysis.analyze_issue( + payload, owner, repo, repo_files=_stellar_repo_context(client, full_name) + ) + return jsonify(payload) + + +# -------------------------------------------------------------------------- +# API: pull requests +# -------------------------------------------------------------------------- + + +@bp.route("/api/repos///pulls") +@login_required +def api_pulls(owner: str, repo: str): + """Return pull requests for a repository.""" + full_name = validate_full_name(f"{owner}/{repo}") + state = request.args.get("state", "open") + if state not in ("open", "closed", "all"): + state = "open" + page, per_page = _page_params() + try: + client = _client() + result = client.list_pull_requests_page( + full_name, state=state, page=page, per_page=per_page + ) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + return jsonify(_paginated_payload(result, pull_request_payload)) + + +@bp.route("/api/repos///pulls/") +@login_required +def api_pull_detail(owner: str, repo: str, number: int): + """Return a single pull request with files and optional AI analysis.""" + full_name = validate_full_name(f"{owner}/{repo}") + analyze = request.args.get("analyze") == "1" + try: + client = _client() + pr = client.get_pull_request(full_name, number) + files = client.list_pull_request_files(full_name, number) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 404 + + payload = pull_request_payload(pr) + payload["files"] = [ + { + "filename": f.get("filename"), + "status": f.get("status"), + "additions": f.get("additions"), + "deletions": f.get("deletions"), + "patch": f.get("patch"), + } + for f in files + ] + if analyze: + payload["analysis"] = analysis.analyze_pull_request( + payload, files, repo_files=_stellar_repo_context(client, full_name) + ) + return jsonify(payload) + + +# -------------------------------------------------------------------------- +# API: AI analysis +# -------------------------------------------------------------------------- + + +@bp.route("/api/repos///analyze", methods=["POST"]) +@login_required +def api_analyze_repo(owner: str, repo: str): + """Produce an AI overview of a repository from README and file names.""" + full_name = validate_full_name(f"{owner}/{repo}") + try: + client = _client() + repo_data = client.get_repository(full_name) + default_branch = repo_data.get("default_branch") or "HEAD" + readme = client.get_readme(full_name, ref=default_branch) + tree = client.get_tree(full_name, default_branch, recursive=True) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + + file_list = [ + entry.get("path", "") for entry in tree.get("tree", []) if entry.get("type") == "blob" + ] + return jsonify(analysis.summarize_repository(owner, repo, readme, file_list)) + + +@bp.route("/api/repos///analyze-file", methods=["POST"]) +@login_required +def api_analyze_file(owner: str, repo: str): + """Analyze a single file's contents with the AI provider.""" + full_name = validate_full_name(f"{owner}/{repo}") + data = request.get_json(silent=True) or {} + path = validate_path(data.get("path") or "") + ref = (data.get("ref") or "").strip() or None + question = (data.get("question") or "").strip() or None + if not path: + return jsonify({"error": "A file path is required."}), 400 + + try: + client = _client() + text = client.get_file_text(full_name, path, ref=ref) + except GitHubError as exc: + return jsonify(github_error_payload(exc)), 502 + + language = (path.rsplit(".", 1)[-1] if "." in path else "") or "text" + return jsonify(analysis.analyze_file(path, language, text, question=question)) diff --git a/app/services/github.py b/app/services/github.py index 0ddf57c..82066ee 100644 --- a/app/services/github.py +++ b/app/services/github.py @@ -1 +1,1100 @@ -IiIiR2l0SHViIEFQSSBzZXJ2aWNlIGxheWVyLgoKQSB0aGluLCByZXRyeWluZyBjbGllbnQgZm9yIHRoZSBHaXRIdWIgUkVTVCBBUEkgdXNlZCBieSB0aGUgcmVwb3NpdG9yeQpicm93c2VyLCBpc3N1ZXMsIHB1bGwgcmVxdWVzdHMsIGFuZCBjaGF0IGNvbnRleHQgZmVhdHVyZXMuIEFsbCByZXF1ZXN0cyBhcmUKbWFkZSBvbiBiZWhhbGYgb2YgdGhlIGN1cnJlbnQgdXNlcidzIGNvbm5lY3RlZCBHaXRIdWIgYWNjb3VudCwgc28gR2l0SHViJ3Mgb3duCnBlcm1pc3Npb25zIG1vZGVsIGRlY2lkZXMgd2hpY2ggcmVwb3NpdG9yaWVzIChwdWJsaWMgb3IgcHJpdmF0ZSkKYWNjZXNzaWJsZS4KCkVycm9ycyBhcmUgcmFpc2VkIGFzIDpjbGFzczpgR2l0SHViRXJyb3JgIHN1YmNsYXNzZXMgc28gcm91dGVzIGNhbiB0cmFuc2xhdGUKdGhlbSBpbnRvIHVzZXItZmFjaW5nIHJlc3BvbnNlcyB3aXRob3V0IHN3YWxsb3dpbmcgZmFpbHVyZXMuCiIiIgoKZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKaW1wb3J0IGJhc2U2NAppbXBvcnQganNvbgppbXBvcnQgbG9nZ2luZwppbXBvcnQgcmUKaW1wb3J0IHRpbWUKZnJvbSBkYXRhY2xhc3NlcyBpbXBvcnQgZGF0YWNsYXNzCmZyb20gZGF0ZXRpbWUgaW1wb3J0IFVUQywgZGF0ZXRpbWUsIHRpbWVkZWx0YQpmcm9tIHVybGxpYi5wYXJzZSBpbXBvcnQgcGFyc2VfcXMsIHVybHBhcnNlCgppbXBvcnQgcmVxdWVzdHMKZnJvbSBmbGFzayBpbXBvcnQgY3VycmVudF9hcHAKCmZyb20gYXBwLmNvbmZpZyBpbXBvcnQgQ29uZmlnCmZyb20gYXBwLmV4dGVuc2lvbnMgaW1wb3J0IGRiCmZyb20gYXBwLm1vZGVscyBpbXBvcnQgR2l0aHViQWNjb3VudApmcm9tIGFwcC5zZXJ2aWNlcy5jcnlwdG8gaW1wb3J0IGRlY3J5cHRfc2VjcmV0Cgpsb2dnZXIgPSBsb2dnaW5nLmdldExvZ2dlcihfX25hbWVfXykKCkdJVEhVQl9BVVRIT1JJWkVfVVJMID0gImh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aC9hdXRob3JpemUiCkdJVEhVQl9UT0tFTl9VUkwgPSAiaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoL2FjY2Vzc190b2tlbiIKR0lUSFVCX1JFVk9LRV9VUkwgPSAiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9hcHBsaWNhdGlvbnMve2NsaWVudF9pZH0vdG9rZW4iCkdJVEhVQl9PQVVUSF9FUlJPUl9VUkwgPSAiaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoL2FjY2Vzc190b2tlbiIKREVGQVVMVF9BUElfVVJMID0gImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20iCkFQSV9WRVJTSU9OID0gIjIwMjItMTEtMjgiCgojIEdpdEh1YiByZXBvc2l0b3J5IG5hbWVzIC8gb3duZXJzOiBsZXR0ZXJzLCBkaWdpdHMsIGRhc2hlcywgZG90cywgdW5kZXJzY29yZXMuCl9GVUxMX05BTUVfUkUgPSByZS5jb21waWxlKHIiXltBLVphLXowLTlfLi1dKy9bQS1aYS16MC05Xy4tXSskIikKCiM6IERlZmF1bHQgYW5kIG1heGltdW0gYGBwZXJfcGFnZWBgIGZvciBsaXN0IGVuZHBvaW50cyAoR2l0SHViIGNhcHMgYXQgMTAwKS4KUEFHRV9TSVpFX0RFRkFVTFQgPSA1MApQQUdFX1NJWkVfTUFYID0gMTAwCgojOiBNYXRjaGVzIG9uZSBgYDx1cmw+OyByZWw9Im5hbWUiYGAggZW50cnkgb2YgYSBHaXRIdWIgYGBMaW5rYGAgaGVhZGVyLgpfTElOS19SRSA9IHJlLmNvbXBpbGUocic8KFtePl0rKT5ccyo7XHMqcmVsPSIoW14iXSspIicpCgojOiBPQXV0aCBgYGVycm9yYGAggY29kZXMgdGhhdCByZXByZXNlbnQgdGhlIHVzZXIgZGVjbGluaW5nIGNvbnNlbnQgcmF0aGVyIHRoYW4KIzogYSByZWFsIGZhaWx1cmUuIEdpdEh1YiBzZW5kcyB0aGVzZSBiYWNrIG9uIHRoZSBjYWxsYmFjayBxdWVyeSBzdHJpbmcuCk9BVVRIX0NBTkNFTExBVElPTl9DT0RFUyA9IGZyb3plbnNldCh7ImFjY2Vzc19kZW5pZWQifSkKCgpAZGF0YWNsYXNzKGZyb3plbj1UcnVlKQpjbGFzcyBHaXRIdWJQYWdlOgogICAgIiIiQSBzaW5nbGUgcGFnZSBvZiBhIEdpdEh1YiBsaXN0IGVuZHBvaW50IHBsdXMgaXRzIG5hdmlnYXRpb24gbWV0YWRhdGEuCgogICAgYGBoYXNfbmV4dGBgIC8gYGBoYXNfcHJldmBgIC8gYGB0b3RhbF9wYWdlc2BgIGFyZSBkZXJpdmVkIGZyb20gdGhlIGBgTGlua2BgCiAgICByZXNwb25zZSBoZWFkZXIgR2l0SHViIHJldHVybnMsIHNvIHRoZSBjYWxsZXIgbmV2ZXIgaGFzIHRvIGd1ZXNzIHdoZXJlIHRoZQogICAgbmV4dCBwYWdlIGlzLgogICAgIiIiCgogICAgaXRlbXM6IGxpc3RbZGljdF0KICAgIHBhZ2U6IGludAogICAgcGVyX3BhZ2U6IGludAogICAgaGFzX25leHQ6IGJvb2wgPSBGYWxzZQogICAgaGFzX3ByZXY6IGJvb2wgPSBGYWxzZQogICAgdG90YWxfcGFnZXM6IGludCB8IE5vbmUgPSBOb25lCgoKZGVmIHBhcnNlX2xpbmtfaGVhZGVyKGhlYWRlcjogc3RyKSAtPiBkaWN0W3N0ciwgc3RyXToKICAgICIiIlJldHVybiBgYHtyZWw6IHVybH1gYCBwYXJzZWQgZnJvbSBhIEdpdEh1YiBgYExpbmtgYCBoZWFkZXIuIiIiCiAgICByZXR1cm4ge3JlbDogdXJsIGZvciB1cmwsIHJlbCBpbiBfTElOS19SRS5maW5kYWxsKGhlYWRlciBvciAiIil9CgoKZGVmIF9wYWdlX251bWJlcl9mcm9tX3VybCh1cmw6IHN0ciB8IE5vbmUpIC0+IGludCB8IE5vbmU6CiAgICAiIiJFeHRyYWN0IHRoZSBgYHBhZ2VgYCBxdWVyeSBwYXJhbWV0ZXIgZnJvbSBhIEdpdEh1YiBwYWdpbmcgVVJMLiIiIgogICAgaWYgbm90IHVybDoKICAgICAgICByZXR1cm4gTm9uZQogICAgdmFsdWVzID0gcGFyc2VfcXModXJscGFyc2UodXJsKS5xdWVyeSkuZ2V0KCJwYWdlIikKICAgIGlmIG5vdCB2YWx1ZXM6CiAgICAgICAgcmV0dXJuIE5vbmUKICAgIHRyeToKICAgICAgICByZXR1cm4gaW50KHZhbHVlc1swXSkKICAgIGV4Y2VwdCAoVHlwZUVycm9yLCBWYWx1ZUVycm9yKToKICAgICAgICByZXR1cm4gTm9uZQoKCmRlZiBfZ2l0aHViX2NvbmZpZyhuYW1lOiBzdHIpOgogICAgIiIiUmVhZCBHaXRIdWIgc2V0dGluZ3MgZnJvbSB0aGUgYWN0aXZlIGFwcCwgd2l0aCBjbGFzcy1jb25maWcgZmFsbGJhY2suIiIiCiAgICByZXR1cm4gY3VycmVudF9hcHAuY29uZmlnLmdldChuYW1lLCBnZXRhdHRyKENvbmZpZywgbmFtZSkpCgoKY2xhc3MgR2l0SHViRXJyb3IoUnVudGltZUVycm9yKToKICAgICIiIkJhc2UgY2xhc3MgZm9yIEdpdEh1YiBBUEkgZXJyb3JzIHN1cmZhY2VkIHRvIHRoZSB1c2VyLiIiIgoKICAgIGtpbmQgPSAiZ2l0aHViX2Vycm9yIgoKICAgIGRlZiBfX2luaXRfXyhzZWxmLCBtZXNzYWdlOiBzdHIsICosIGtpbmQ6IHN0ciB8IE5vbmUgPSBOb25lLCBkZXRhaWw6IHN0ciB8IE5vbmUgPSBOb25lKSAtPiBOb25lOgogICAgICAgIHN1cGVyKCkuX19pbml0X18obWVzc2FnZSkKICAgICAgICBpZiBraW5kIGlzIG5vdCBOb25lOgogICAgICAgICAgICBzZWxmLmtpbmQgPSBraW5kCiAgICAgICAgIyBTZXJ2ZXItc2lkZS1vbmx5IGRldGFpbCAoZS5nLiBHaXRIdWIncyByYXcgcmVzcG9uc2UgbWVzc2FnZSkuIE5ldmVyCiAgICAgICAgIyBzdXJmYWNlZCB0byB0aGUgY2xpZW50OyBsb2dnZWQgaW5zdGVhZC4gU2VlIGBgZ2l0aHViX2Vycm9yX3BheWxvYWRgYC4KICAgICAgICBzZWxmLmRldGFpbCA9IGRldGFpbAoKCmNsYXNzIEdpdEh1Yk5vdENvbm5lY3RlZEVycm9yKEdpdEh1YkVycm9yKToKICAgIGtpbmQgPSAibm90X2Nvbm5lY3RlZCIKCgpjbGFzcyBHaXRIdWJPQXV0aENhbmNlbGxlZEVycm9yKEdpdEh1YkVycm9yKToKICAgICIiIlJhaXNlZCB3aGVuIHRoZSB1c2VyIGRlY2xpbmVzIEdpdEh1YidzIGNvbnNlbnQgc2NyZWVuIChgYGFjY2Vzc19kZW5pZWRgYCkuIiIiCgogICAga2luZCA9ICJjYW5jZWxsZWQiCgoKY2xhc3MgR2l0SHViQXV0aEVycm9yKEdpdEh1YkVycm9yKToKICAgIGtpbmQgPSAiYXV0aCIKCgpjbGFzcyBHaXRIdWJQZXJtaXNzaW9uRXJyb3IoR2l0SHViRXJyb3IpOgogICAga2luZCA9ICJwZXJtaXNzaW9uIgoKCmNsYXNzIEdpdEh1Yk5vdEZvdW5kRXJyb3IoR2l0SHViRXJyb3IpOgogICAga2luZCA9ICJub3RfZm91bmQiCgoKY2xhc3MgR2l0SHViUmF0ZUxpbWl0RXJyb3IoR2l0SHViRXJyb3IpOgogICAga2luZCA9ICJyYXRlX2xpbWl0IgoKCmNsYXNzIEdpdEh1Yk5ldHdvcmtFcnJvcihHaXRIdWJFcnJvcik6CiAgICBraW5kID0gIm5ldHdvcmsiCgoKY2xhc3MgR2l0SHViSW52YWxpZEVycm9yKEdpdEh1YkVycm9yKToKICAgIGtpbmQgPSAidmFsaWRhdGlvbiIKCgojOiBTdGFibGUsIHNhbml0aXplZCBtZXNzYWdlcyBzaG93biB0byBlbmQgdXNlcnMsIGtleWVkIGJ5IGVycm9yIGBga2luZGBgLgojOiBHaXRIdWIncyByYXcgcmVzcG9uc2UgYm9kaWVzIGFyZSBuZXZlciBzdXJmYWNlZDogdGhleSBjYW4gY29udGFpbiBVUkxzLAojOiBoZWFkZXJzLCByYXRlLWxpbWl0IGRhdGEsIG9yIGltcGxlbWVudGF0aW9uIGRldGFpbHMuIFRob3NlIGFyZSBrZXB0IG9uCiM6IGBgR2l0SHViRXJyb3IuZGV0YWlsYGAgZm9yIHNlcnZlci1zaWRlIGxvZ2dpbmcgb25seS4KRVJST1JfTUVTU0FHRVMgPSB7CiAgICAibm90X2Nvbm5lY3RlZCI6ICJDb25uZWN0IHlvdXIgR2l0SHViIGFjY291bnQgdG8gdXNlIHRoaXMgZmVhdHVyZS4iLAogICAgImNhbmNlbGxlZCI6ICJZb3UgY2FuY2VsbGVkIHRoZSBHaXRIdWIgY29ubmVjdGlvbi4gQ29ubmVjdCBHaXRIdWIgdG8gY29udGludWUuIiwKICAgICJhdXRoIjogIllvdXIgR2l0SHViIGNvbm5lY3Rpb24gaXMgbm8gbG9uZ2VyIHZhbGlkLiBSZWNvbm5lY3QgeW91ciBhY2NvdW50LiIsCiAgICAicGVybWlzc2lvbiI6ICJHaXRIdWIgZGVuaWVkIGFjY2VzcyB0byB0aGlzIHJlc291cmNlLiIsCiAgICAibm90X2ZvdW5kIjogIlRoZSByZXF1ZXN0ZWQgR2l0SHViIHJlc291cmNlIHdhcyBub3QgZm91bmQuIiwKICAgICJyYXRlX2xpbWl0IjogIkdpdEh1YiBBUEkgcmF0ZSBsaW1pdCByZWFjaGVkLiBQbGVhc2UgdHJ5IGFnYWluIGxhdGVyLiIsCiAgICAibmV0d29yayI6ICJDb3VsZCBub3QgcmVhY2ggdGhlIEdpdEh1YiBBUEkuIFBsZWFzZSB0cnkgYWdhaW4uIiwKICAgICJ2YWxpZGF0aW9uIjogIlRoZSBHaXRIdWIgcmVxdWVzdCB3YXMgaW52YWxpZC4iLAogICAgImdpdGh1Yl9lcnJvciI6ICJUaGUgR2l0SHViIHJlcXVlc3QgZmFpbGVkLiBQbGVhc2UgdHJ5IGFnYWluLiIsCn0KCgpkZWYgZ2l0aHViX2Vycm9yX21lc3NhZ2UoZXhjOiBHaXRIdWJFcnJvcikgLT4gc3RyOgogICAgIiIiUmV0dXJuIHRoZSBzdGFibGUgdXNlci1mYWNpbmcgbWVzc2FnZSBmb3IgYGBleGNgYCdzIGtpbmQuIiIiCiAgICByZXR1cm4gRVJST1JfTUVTU0FHRVMuZ2V0KGV4Yy5raW5kLCBFUlJPUl9NRVNTQUdFU1siZ2l0aHViX2Vycm9yIl0pCgoKZGVmIGdpdGh1Yl9lcnJvcl9wYXlsb2FkKGV4YzogR2l0SHViRXJyb3IpIC0+IGRpY3Q6CiAgICAiIiJCdWlsZCBhIHNhbml0aXplZCBKU09OIGVycm9yIHBheWxvYWQgZm9yIGEgOmNsYXNzOmBHaXRIdWJFcnJvcmAuCgogICAgUm91dGUgaGFuZGxlcnMgbXVzdCB1c2UgdGhpcyBpbnN0ZWFkIG9mIGBgc3RyKGV4YylgYCBzbyBubyByYXcgR2l0SHViCiAgICBtZXNzYWdlIChvciB0b2tlbi9oZWFkZXIgbWF0ZXJpYWwpIGlzIGV2ZXIgZWNob2VkIHRvIHRoZSBjbGllbnQuCiAgICAiIiIKICAgIHJldHVybiB7ImVycm9yIjogZ2l0aHViX2Vycm9yX21lc3NhZ2UoZXhjKSwgImtpbmQiOiBleGMua2luZH0KCgpkZWYgZ2l0aHViX29hdXRoX2Vycm9yX2Zyb21fY2FsbGJhY2soCiAgICBlcnJvcjogc3RyIHwgTm9uZSwgZXJyb3JfZGVzY3JpcHRpb246IHN0ciB8IE5vbmUgPSBOb25lCikgLT4gR2l0SHViRXJyb3IgfCBOb25lOgogICAgIiIiVHJhbnNsYXRlIGFuIE9BdXRoIGNhbGxiYWNrIGBgZXJyb3JgYCBpbnRvIGEgdHlwZWQsIHNhbml0aXplZCBlcnJvci4KCiAgICBHaXRIdWIgYXBwZW5kcyBgYGVycm9yYGAggKGFuZCBzb21ldGltZXMgYGB lcnJvcl9kZXNjcmlwdGlvbmBgKSB0byB0aGUKICAgIGNhbGxiYWNrIFVSTCB3aGVuIHRoZSBjb25zZW50IGZsb3cgZmFpbHMuIGBgYWNjZXNzX2RlbmllZGBgIG1lYW5zIHRoZSB1c2VyCiAgICBjbGlja2VkICJDYW5jZWwiIGFuZCBpcyB0cmVhdGVkIGFzIGEgY2FuY2VsbGF0aW9uLCBub3QgYSBmYWlsdXJlLiBBbnkgb3RoZXIKICAgIGNvZGUgYmVjb21lcyBhIGdlbmVyaWMgYXV0aCBlcnJvci4gVGhlIHJhdyBgYGVycm9yX2Rlc2NyaXB0aW9uYGAgaXMga2VwdCBvbgogICAgYGBkZXRhaWxgYCBmb3Igc2VydmVyLXNpZGUgbG9nZ2luZyBvbmx5IGFuZCBpcyBuZXZlciBzdXJmYWNlZCB0byB0aGUgY2xpZW50LgogICAgIiIiCiAgICBjb2RlID0gKGVycm9yIG9yICIiKS5zdHJpcCgpCiAgICBpZiBub3QgY29kZToKICAgICAgICByZXR1cm4gTm9uZQogICAgZGV0YWlsID0gKGVycm9yX2Rlc2NyaXB0aW9uIG9yICIiKS5zdHJpcCgpIG9yIE5vbmUKICAgIGlmIGNvZGUgaW4gT0FVVEhfQ0FOQ0VMTEFUSU9OX0NPREVTOgogICAgICAgIHJldHVybiBHaXRIdWJPQXV0aENhbmNlbGxlZEVycm9yKAogICAgICAgICAgICAiWW91IGNhbmNlbGxlZCB0aGUgR2l0SHViIGNvbm5lY3Rpb24uIENvbm5lY3QgR2l0SHViIHRvIGNvbnRpbnVlLiIsCiAgICAgICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICAgICAgKQogICAgcmV0dXJuIEdpdEh1YkF1dGhFcnJvcigKICAgICAgICAiR2l0SHViIGNvdWxkIG5vdCBjb21wbGV0ZSB0aGUgY29ubmVjdGlvbi4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICApCgoKZGVmIF9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlOiByZXF1ZXN0cy5SZXNwb25zZSkgLT4gc3RyOgogICAgIiIiRXh0cmFjdCBhIHNob3J0IGh1bWFuLXJlYWRhYmxlIG1lc3NhZ2UgZm9yIHNlcnZlci1zaWRlIGxvZ2dpbmcgb25seS4iIiIKICAgIHRyeToKICAgICAgICBkYXRhID0gcmVzcG9uc2UuanNvbigpCiAgICBleGNlcHQgVmFsdWVFcnJvcjoKICAgICAgICByZXR1cm4gcmVzcG9uc2UudGV4dFs6MjAwXSBvciBmIkhUVFAge3Jlc3BvbnNlLnN0YXR1c19jb2RlfSIKICAgIGlmIGlzaW5zdGFuY2UoZGF0YSwgZGljdCk6CiAgICAgICAgbWVzc2FnZSA9IGRhdGEuZ2V0KCJtZXNzYWdlIikKICAgICAgICBpZiBpc2luc3RhbmNlKG1lc3NhZ2UsIHN0cik6CiAgICAgICAgICAgIHJldHVybiBtZXNzYWdlCiAgICByZXR1cm4gZiJIVFRQIHtyZXNwb25zZS5zdGF0dXNfY29kZX0iCgoKY2xhc3MgR2l0SHViQ2xpZW50OgogICAgIiIiQXV0aGVudGljYXRlZCBjbGllbnQgZm9yIHRoZSBHaXRIdWIgUkVTVCBBUEkuCgogICAgUHJvdmlkZXMgYm91bmRlZCByZXRyeSBiZWhhdmlvdXI6IHRyYW5zaWVudCBmYWlsdXJlcyAobmV0d29yayBlcnJvcnMgYW5kCiAgICBIVFRQIDV4eCkgYXJlIHJldHJpZWQgd2l0aCBleHBvbmVudGlhbCBiYWNrb2ZmLCBhbmQgcmF0ZS1saW1pdCByZXNwb25zZXMKICAgICg0Mjkgb3IgNDAzIHdpdGggZXhoYXVzdGVkIHF1b3RhKSBwYXVzZSB1bnRpbCB0aGUgZG9jdW1lbnRlZCByZXNldCB0aW1lLgogICAgIiIiCgogICAgZGVmIF9faW5pdF9fKAogICAgICAgIHNlbGYsCiAgICAgICAgYWNjZXNzX3Rva2VuOiBzdHIsCiAgICAgICAgKiwKICAgICAgICBhcGlfdXJsOiBzdHIgfCBOb25lID0gTm9uZSwKICAgICAgICB0aW1lb3V0OiBpbnQgfCBOb25lID0gTm9uZSwKICAgICAgICBtYXhfcmV0cmllczogaW50ID0gMywKICAgICkgLT4gTm9uZToKICAgICAgICBzZWxmLmFwaV91cmwgPSAoYXBpX3VybCBvciBDb25maWcuR0lUSFVCX0FQSV9VUkwpLnJzdHJpcCgiLyIpCiAgICAgICAgc2VsZi50aW1lb3V0ID0gdGltZW91dCBvciBDb25maWcuR0lUSFVCX1JFUVVFU1RfVElNRU9VVAogICAgICAgIHNlbGYubWF4X3JldHJpZXMgPSBtYXhfcmV0cmllcwogICAgICAgIHNlbGYuc2Vzc2lvbiA9IHJlcXVlc3RzLlNlc3Npb24oKQogICAgICAgIHNlbGYuc2Vzc2lvbi5oZWFkZXJzLnVwZGF0ZSgKICAgICAgICAgICAgewogICAgICAgICAgICAgICAgIkF1dGhvcml6YXRpb24iOiBmIkJlYXJlciB7YWNjZXNzX3Rva2VufSIsCiAgICAgICAgICAgICAgICAiQWNjZXB0IjogImFwcGxpY2F0aW9uL3ZuZC5naXRodWIrZ2l0aHViLmpzb24iLAogICAgICAgICAgICAgICAgIlgtR2l0SHViLUFwaS1WZXJzaW9uIjogQVBJX1ZFUlNJT04sCiAgICAgICAgICAgIH0KICAgICAgICApCgogICAgIyAtLSBDb3JlIHJlcXVlc3QgbWFjaGluZXJ5IC0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0KCiAgICBkZWYgX3JlcXVlc3Qoc2VsZiwgbWV0aG9kOiBzdHIsIHBhdGg6IHN0ciwgKiwgcGFyYW1zOiBkaWN0IHwgTm9uZSA9IE5vbmUpIC0+IGRpY3QgfCBsaXN0OgogICAgICAgICIiIlBlcmZvcm0gYSByZXF1ZXN0IHdpdGggcmV0cmllcywgcmFpc2luZyB0eXBlZCBlcnJvcnMgb24gZmFpbHVyZS4iIiIKICAgICAgICB1cmwgPSBmIntzZWxmLmFwaV91cmx9e3BhdGh9IgogICAgICAgIGxhc3RfZXhjOiBFeGNlcHRpb24gfCBOb25lID0gTm9uZQogICAgICAgIGZvciBhdHRlbXB0IGluIHJhbmdlKHNlbGYubWF4X3JldHJpZXMpOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICByZXNwb25zZSA9IHNlbGYuc2Vzc2lvbi5yZXF1ZXN0KG1ldGhvZCwgdXJsLCBwYXJhbXM9cGFyYW1zLCB0aW1lb3V0PXNlbGYudGltZW91dCkKICAgICAgICAgICAgZXhjZXB0IHJlcXVlc3RzLlJlcXVlc3RFeGNlcHRpb24gYXMgZXhjOgogICAgICAgICAgICAgICAgbGFzdF9leGMgPSBleGMKICAgICAgICAgICAgICAgIGxvZ2dlci53YXJuaW5nKCJHaXRIdWIgbmV0d29yayBlcnJvciBvbiAlczogJXMiLCBwYXRoLCBleGMpCiAgICAgICAgICAgICAgICBpZiBhdHRlbXB0IDwgc2VsZi5tYXhfcmV0cmllcyAtIDE6CiAgICAgICAgICAgICAgICAgICAgdGltZS5zbGVlcCgyKiphdHRlbXB0KQogICAgICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgICAgICByYWlzZSBHaXRIdWJOZXR3b3JrRXJyb3IoCiAgICAgICAgICAgICAgICAgICAgIkNvdWxkIG5vdCByZWFjaCB0aGUgR2l0SHViIEFQSS4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICAgICAgICAgIGRldGFpbD1zdHIoZXhjKSwKICAgICAgICAgICAgICAgICkgZnJvbSBleGMKICAgICAgICAgICAgZWxzZToKICAgICAgICAgICAgICAgIGxhc3RfZXhjID0gTm9uZQoKICAgICAgICAgICAgc3RhdHVzID0gcmVzcG9uc2Uuc3RhdHVzX2NvZGUKICAgICAgICAgICAgaWYgc3RhdHVzID09IDQwMToKICAgICAgICAgICAgICAgIHJhaXNlIEdpdEh1YkF1dGhFcnJvcigKICAgICAgICAgICAgICAgICAgICAiWW91ciBHaXRIdWIgY29ubmVjdGlvbiBpcyBubyBsb25nZXIgdmFsaWQuIFJlY29ubmVjdCB5b3VyIGFjY291bnQuIiwKICAgICAgICAgICAgICAgICAgICBkZXRhaWw9X3BhcnNlX2Vycm9yX2JvZHkocmVzcG9uc2UpLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICBpZiBzdGF0dXMgPT0gNDAzOgogICAgICAgICAgICAgICAgcmFpc2UgR2l0SHViUGVybWlzc2lvbkVycm9yKAogICAgICAgICAgICAgICAgICAgICJHaXRIdWIgZGVuaWVkIGFjY2VzcyB0byB0aGlzIHJlc291cmNlLiIsCiAgICAgICAgICAgICAgICAgICAgZGV0YWlsPV9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlKSwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgaWYgc3RhdHVzID09IDQwNDoKICAgICAgICAgICAgICAgIHJhaXNlIEdpdEh1Yk5vdEZvdW5kRXJyb3IoCiAgICAgICAgICAgICAgICAgICAgIlRoZSByZXF1ZXN0ZWQgR2l0SHViIHJlc291cmNlIHdhcyBub3QgZm91bmQuIiwKICAgICAgICAgICAgICAgICAgICBkZXRhaWw9X3BhcnNlX2Vycm9yX2JvZHkocmVzcG9uc2UpLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICBpZiBzdGF0dXMgPT0gNDIyOgogICAgICAgICAgICAgICAgcmFpc2UgR2l0SHViSW52YWxpZEVycm9yKAogICAgICAgICAgICAgICAgICAgICJUaGUgR2l0SHViIHJlcXVlc3Qgd2FzIGludmFsaWQuIiwKICAgICAgICAgICAgICAgICAgICBkZXRhaWw9X3BhcnNlX2Vycm9yX2JvZHkocmVzcG9uc2UpLAogICAgICAgICAgICAgICAgKQogICAgICAgICAgICBpZiBzdGF0dXMgPT0gNDI5IG9yIChzdGF0dXMgPT0gNDAzIGFuZCByZXNwb25zZS5oZWFkZXJzLmdldCgiWC1SYXRlTGltaXQtUmVtYWluaW5nIikgPT0gIjAiKToKICAgICAgICAgICAgICAgIHJhaXNlIEdpdEh1YlJhdGVMaW1pdEVycm9yKAogICAgICAgICAgICAgICAgICAgICJHaXRIdWIgQVBJIHJhdGUgbGltaXQgcmVhY2hlZC4gUGxlYXNlIHRyeSBhZ2FpbiBsYXRlci4iLAogICAgICAgICAgICAgICAgICAgIGRldGFpbD1fcGFyc2VfZXJyb3JfYm9keShyZXNwb25zZSksCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgIGlmIDUwMCA8PSBzdGF0dXMgPCA2MDA6CiAgICAgICAgICAgICAgICBsYXN0X2V4YyA9IEdpdEh1Yk5ldHdvcmtFcnJvcigKICAgICAgICAgICAgICAgICAgICAiQ291bGQgbm90IHJlYWNoIHRoZSBHaXRIdWIgQVBJLiBQbGVhc2UgdHJ5IGFnYWluLiIsCiAgICAgICAgICAgICAgICAgICAgZGV0YWlsPV9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlKSwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgIGlmIGF0dGVtcHQgPCBzZWxmLm1heF9yZXRyaWVzIC0gMToKICAgICAgICAgICAgICAgICAgICB0aW1lLnNsZWVwKDIqKmF0dGVtcHQpCiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgICAgIHJhaXNlIGxhc3RfZXhjCiAgICAgICAgICAgIGlmIHN0YXR1cyA+PSA0MDA6CiAgICAgICAgICAgICAgICByYWlzZSBHaXRIdWJFcnJvcigKICAgICAgICAgICAgICAgICAgICAiVGhlIEdpdEh1YiByZXF1ZXN0IGZhaWxlZC4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICAgICAgICAgIGRldGFpbD1fcGFyc2VfZXJyb3JfYm9keShyZXNwb25zZSksCiAgICAgICAgICAgICAgICApCiAgICAgICAgICAgIGlmIG5vdCByZXNwb25zZS5jb250ZW50OgogICAgICAgICAgICAgICAgcmV0dXJuIHt9CiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIHJldHVybiByZXNwb25zZS5qc29uKCkKICAgICAgICAgICAgZXhjZXB0IFZhbHVlRXJyb3I6CiAgICAgICAgICAgICAgICByZXR1cm4ge30KICAgICAgICByYWlzZSBsYXN0X2V4YyBvciBHaXRIdWJFcnJvcigiVGhlIEdpdEh1YiByZXF1ZXN0IGZhaWxlZC4gUGxlYXNlIHRyeSBhZ2Fpbi4iKQoKICAgICMgLS0gUHVibGljIEFQSSAtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tCgogICAgZGVmIGdldF91c2VyKHNlbGYpIC0+IGRpY3Q6CiAgICAgICAgIiIiUmV0dXJuIHRoZSBhdXRoZW50aWNhdGVkIHVzZXIncyBwcm9maWxlLiIiIgogICAgICAgIHJldHVybiBzZWxmLl9yZXF1ZXN0KCJHRVQiLCAiL3VzZXIiKQoKICAgIGRlZiBsaXN0X3JlcG9zKAogICAgICAgIHNlbGYsCiAgICAgICAgKiwKICAgICAgICBwYWdlOiBpbnQgPSAxLAogICAgICAgIHBlcl9wYWdlOiBpbnQgPSBQQUdFX1NJWkVfREVGQVVMVCwKICAgICAgICBzb3J0OiBzdHIgPSAidXBkYXRlZCIsCiAgICApIC0+IEdpdEh1YlBhZ2U6CiAgICAgICAgIiIiTGlzdCByZXBvc2l0b3JpZXMgdmlzaWJsZSB0byB0aGUgYXV0aGVudGljYXRlZCB1c2VyLiIiIgogICAgICAgIHBhcmFtcyA9IHsicGVyX3BhZ2UiOiBwZXJfcGFnZSwgInBhZ2UiOiBwYWdlLCAic29ydCI6IHNvcnR9CiAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnNlc3Npb24uZ2V0KAogICAgICAgICAgICBmIntzZWxmLmFwaV91cmx9L3VzZXIvcmVwb3MiLAogICAgICAgICAgICBwYXJhbXM9cGFyYW1zLAogICAgICAgICAgICB0aW1lb3V0PXNlbGYudGltZW91dCwKICAgICAgICApCiAgICAgICAgcmV0dXJuIHNlbGYuX3BhZ2VfZnJvbV9yZXNwb25zZShyZXNwb25zZSwgcGFnZSwgcGVyX3BhZ2UpCgogICAgZGVmIGxpc3RfaXNzdWVzKAogICAgICAgIHNlbGYsCiAgICAgICAgb3duZXI6IHN0ciwKICAgICAgICByZXBvOiBzdHIsCiAgICAgICAgKiwKICAgICAgICBzdGF0ZTogc3RyID0gIm9wZW4iLAogICAgICAgIHBhZ2U6IGludCA9IDEsCiAgICAgICAgcGVyX3BhZ2U6IGludCA9IFBBR0VfU0laRV9ERUZBVUxULAogICAgKSAtPiBHaXRIdWJQYWdlOgogICAgICAgICIiIkxpc3QgaXNzdWVzIGZvciBhIHJlcG9zaXRvcnkuIiIiCiAgICAgICAgcGFyYW1zID0geyJzdGF0ZSI6IHN0YXRlLCAicGVyX3BhZ2UiOiBwZXJfcGFnZSwgInBhZ2UiOiBwYWdlfQogICAgICAgIHJlc3BvbnNlID0gc2VsZi5zZXNzaW9uLmdldCgKICAgICAgICAgICAgZiJ7c2VsZi5hcGlfdXJsfS9yZXBvcy97b3duZXJ9L3tyZXBvfS9pc3N1ZXMiLAogICAgICAgICAgICBwYXJhbXM9cGFyYW1zLAogICAgICAgICAgICB0aW1lb3V0PXNlbGYudGltZW91dCwKICAgICAgICApCiAgICAgICAgcmV0dXJuIHNlbGYuX3BhZ2VfZnJvbV9yZXNwb25zZShyZXNwb25zZSwgcGFnZSwgcGVyX3BhZ2UpCgogICAgZGVmIGxpc3RfcHVsbF9yZXF1ZXN0cygKICAgICAgICBzZWxmLAogICAgICAgIG93bmVyOiBzdHIsCiAgICAgICAgcmVwbzogc3RyLAogICAgICAgICosCiAgICAgICAgc3RhdGU6IHN0ciA9ICJvcGVuIiwKICAgICAgICBwYWdlOiBpbnQgPSAxLAogICAgICAgIHBlcl9wYWdlOiBpbnQgPSBQQUdFX1NJWkVfREVGQVVMVCwKICAgICkgLT4gR2l0SHViUGFnZToKICAgICAgICAiIiJMaXN0IHB1bGwgcmVxdWVzdHMgZm9yIGEgcmVwb3NpdG9yeS4iIiIKICAgICAgICBwYXJhbXMgPSB7InN0YXRlIjogc3RhdGUsICJwZXJfcGFnZSI6IHBlcl9wYWdlLCAicGFnZSI6IHBhZ2V9CiAgICAgICAgcmVzcG9uc2UgPSBzZWxmLnNlc3Npb24uZ2V0KAogICAgICAgICAgICBmIntzZWxmLmFwaV91cmx9L3JlcG9zL3tvd25lcn0ve3JlcG99L3B1bGxzIiwKICAgICAgICAgICAgcGFyYW1zPXBhcmFtcywKICAgICAgICAgICAgdGltZW91dD1zZWxmLnRpbWVvdXQsCiAgICAgICAgKQogICAgICAgIHJldHVybiBzZWxmLl9wYWdlX2Zyb21fcmVzcG9uc2UocmVzcG9uc2UsIHBhZ2UsIHBlcl9wYWdlKQoKICAgIGRlZiBnZXRfcmVwbyhzZWxmLCBvd25lcjogc3RyLCByZXBvOiBzdHIpIC0+IGRpY3Q6CiAgICAgICAgIiIiRmV0Y2ggbWV0YWRhdGEgZm9yIGEgc2luZ2xlIHJlcG9zaXRvcnkuIiIiCiAgICAgICAgcmV0dXJuIHNlbGYuX3JlcXVlc3QoIkdFVCIsIGYicmVwb3Mve293bmVyfS97cmVwb30iKQoKICAgIGRlZiBnZXRfcmVhZG1lKHNlbGYsIG93bmVyOiBzdHIsIHJlcG86IHN0cikgLT4gc3RyOgogICAgICAgICIiIkZldGNoIGFuZCBkZWNvZGUgYSByZXBvc2l0b3J5J3MgUkVBRE1FIGFzIHRleHQuIiIiCiAgICAgICAgZGF0YSA9IHNlbGYuX3JlcXVlc3QoIkdFVCIsIGYicmVwb3Mve293bmVyfS97cmVwb30vcmVhZG1lIikKICAgICAgICBjb250ZW50ID0gZGF0YS5nZXQoImNvbnRlbnQiKSBpZiBpc2luc3RhbmNlKGRhdGEsIGRpY3QpIGVsc2UgTm9uZQogICAgICAgIGlmIG5vdCBjb250ZW50OgogICAgICAgICAgICByZXR1cm4gIiIKICAgICAgICB0cnk6CiAgICAgICAgICAgIHJldHVybiBiYXNlNjQuYjY0ZGVjb2RlKGNvbnRlbnQpLmRlY29kZSgidXRmLTgiLCAicmVwbGFjZSIpCiAgICAgICAgZXhjZXB0IChWYWx1ZUVycm9yLCBUeXBlRXJyb3IpOgogICAgICAgICAgICByZXR1cm4gIiIKCiAgICBkZWYgX3BhZ2VfZnJvbV9yZXNwb25zZShzZWxmLCByZXNwb25zZTogcmVxdWVzdHMuUmVzcG9uc2UsIHBhZ2U6IGludCwgcGVyX3BhZ2U6IGludCkgLT4gR2l0SHViUGFnZToKICAgICAgICBpZiByZXNwb25zZS5zdGF0dXNfY29kZSA+PSA0MDA6CiAgICAgICAgICAgIHNlbGYuX3JhaXNlX2Zvcl9zdGF0dXMocmVzcG9uc2UpCiAgICAgICAgdHJ5OgogICAgICAgICAgICBpdGVtcyA9IHJlc3BvbnNlLmpzb24oKQogICAgICAgIGV4Y2VwdCBWYWx1ZUVycm9yOgogICAgICAgICAgICBpdGVtcyA9IFtdCiAgICAgICAgaWYgbm90IGlzaW5zdGFuY2UoaXRlbXMsIGxpc3QpOgogICAgICAgICAgICBpdGVtcyA9IFtdCiAgICAgICAgbGlua3MgPSBwYXJzZV9saW5rX2hlYWRlcihyZXNwb25zZS5oZWFkZXJzLmdldCgiTGluayIsICIiKSkKICAgICAgICBuZXh0X3BhZ2UgPSBfcGFnZV9udW1iZXJfZnJvbV91cmwobGlua3MuZ2V0KCJuZXh0IikpCiAgICAgICAgcHJldl9wYWdlID0gX3BhZ2VfbnVtYmVyX2Zyb21fdXJsKGxpbmtzLmdldCgicHJldiIpKQogICAgICAgIGxhc3RfcGFnZSA9IF9wYWdlX251bWJlcl9mcm9tX3VybChsaW5rcy5nZXQoImxhc3QiKSkKICAgICAgICByZXR1cm4gR2l0SHViUGFnZSgKICAgICAgICAgICAgaXRlbXM9aXRlbXMsCiAgICAgICAgICAgIHBhZ2U9cGFnZSwKICAgICAgICAgICAgcGVyX3BhZ2U9cGVyX3BhZ2UsCiAgICAgICAgICAgIGhhc19uZXh0PW5leHRfcGFnZSBpcyBub3QgTm9uZSwKICAgICAgICAgICAgaGFzX3ByZXY9cHJldl9wYWdlIGlzIG5vdCBOb25lLAogICAgICAgICAgICB0b3RhbF9wYWdlcz1sYXN0X3BhZ2UsCiAgICAgICAgKQoKICAgIGRlZiBfcmFpc2VfZm9yX3N0YXR1cyhzZWxmLCByZXNwb25zZTogcmVxdWVzdHMuUmVzcG9uc2UpIC0+IE5vbmU6CiAgICAgICAgc3RhdHVzID0gcmVzcG9uc2Uuc3RhdHVzX2NvZGUKICAgICAgICBkZXRhaWwgPSBfcGFyc2VfZXJyb3JfYm9keShyZXNwb25zZSkKICAgICAgICBpZiBzdGF0dXMgPT0gNDAxOgogICAgICAgICAgICByYWlzZSBHaXRIdWJBdXRoRXJyb3IoCiAgICAgICAgICAgICAgICAiWW91ciBHaXRIdWIgY29ubmVjdGlvbiBpcyBubyBsb25nZXIgdmFsaWQuIFJlY29ubmVjdCB5b3VyIGFjY291bnQuIiwKICAgICAgICAgICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICAgICAgICAgICkKICAgICAgICBpZiBzdGF0dXMgPT0gNDAzOgogICAgICAgICAgICByYWlzZSBHaXRIdWJQZXJtaXNzaW9uRXJyb3IoCiAgICAgICAgICAgICAgICAiR2l0SHViIGRlbmllZCBhY2Nlc3MgdG8gdGhpcyByZXNvdXJjZS4iLAogICAgICAgICAgICAgICAgZGV0YWlsPWRldGFpbCwKICAgICAgICAgICAgKQogICAgICAgIGlmIHN0YXR1cyA9PSA0MDQ6CiAgICAgICAgICAgIHJhaXNlIEdpdEh1Yk5vdEZvdW5kRXJyb3IoCiAgICAgICAgICAgICAgICAiVGhlIHJlcXVlc3RlZCBHaXRIdWIgcmVzb3VyY2Ugd2FzIG5vdCBmb3VuZC4iLAogICAgICAgICAgICAgICAgZGV0YWlsPWRldGFpbCwKICAgICAgICAgICAgKQogICAgICAgIGlmIHN0YXR1cyA9PSA0MjI6CiAgICAgICAgICAgIHJhaXNlIEdpdEh1YkludmFsaWRFcnJvcigKICAgICAgICAgICAgICAgICJUaGUgR2l0SHViIHJlcXVlc3Qgd2FzIGludmFsaWQuIiwKICAgICAgICAgICAgICAgIGRldGFpbD1kZXRhaWwsCiAgICAgICAgICAgICkKICAgICAgICBpZiBzdGF0dXMgPT0gNDI5OgogICAgICAgICAgICByYWlzZSBHaXRIdWJSYXRlTGltaXRFcnJvcigKICAgICAgICAgICAgICAgICJHaXRIdWIgQVBJIHJhdGUgbGltaXQgcmVhY2hlZC4gUGxlYXNlIHRyeSBhZ2FpbiBsYXRlci4iLAogICAgICAgICAgICAgICAgZGV0YWlsPWRldGFpbCwKICAgICAgICAgICAgKQogICAgICAgIHJhaXNlIEdpdEh1YkVycm9yKAogICAgICAgICAgICAiVGhlIEdpdEh1YiByZXF1ZXN0IGZhaWxlZC4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICBkZXRhaWw9ZGV0YWlsLAogICAgICAgICkKCgpkZWYgYnVpbGRfYXV0aG9yaXphdGlvbl91cmwoc3RhdGU6IHN0ciB8IE5vbmUgPSBOb25lLCBzY29wZXM6IHN0ciB8IE5vbmUgPSBOb25lKSAtPiBzdHI6CiAgICAiIiJCdWlsZCB0aGUgR2l0SHViIE9BdXRoIGF1dGhvcml6YXRpb24gVVJMIHRoZSB1c2VyIGlzIHJlZGlyZWN0ZWQgdG8uIiIiCiAgICBjbGllbnRfaWQgPSBfZ2l0aHViX2NvbmZpZygiR0lUSFVCX0NMSUVOVF9JRCIpCiAgICByZWRpcmVjdF91cmkgPSBfZ2l0aHViX2NvbmZpZygiR0lUSFVCX1JFRElSRUNUX1VSSSIpCiAgICBzY29wZSA9IHNjb3BlcyBvciBfZ2l0aHViX2NvbmZpZygiR0lUSFVCX1NDT1BFUyIpCiAgICBwYXJhbXMgPSB7CiAgICAgICAgImNsaWVudF9pZCI6IGNsaWVudF9pZCwKICAgICAgICAicmVkaXJlY3RfdXJpIjogcmVkaXJlY3RfdXJpLAogICAgICAgICJzY29wZSI6IHNjb3BlLAogICAgfQogICAgaWYgc3RhdGU6CiAgICAgICAgcGFyYW1zWyJzdGF0ZSJdID0gc3RhdGUKICAgIHJldHVybiBmIntHSVRIVUJfQVVUSE9SSVpFX1VSTH0/e3VybGVuY29kZShwYXJhbXMpfSIKCgpkZWYgZXhjaGFuZ2VfY29kZV9mb3JfdG9rZW4oY29kZTogc3RyKSAtPiBkaWN0OgogICAgIiIiRXhjaGFuZ2UgYW4gT0F1dGggY29kZSBmb3IgYW4gYWNjZXNzIHRva2VuLiIiIgogICAgY2xpZW50X2lkID0gX2dpdGh1Yl9jb25maWcoIkdJVEhVQl9DTElFTlRfSUQiKQogICAgY2xpZW50X3NlY3JldCA9IF9naXRodWJfY29uZmlnKCJHSVRIVUJfQ0xJRU5UX1NFQ1JFVCIpCiAgICByZXNwb25zZSA9IHJlcXVlc3RzLnBvc3QoCiAgICAgICAgR0lUSFVCX1RPS0VOX1VSTCwKICAgICAgICBoZWFkZXJzPXsiQWNjZXB0IjogImFwcGxpY2F0aW9uL2pzb24ifSwKICAgICAgICBkYXRhPXsKICAgICAgICAgICAgImNsaWVudF9pZCI6IGNsaWVudF9pZCwKICAgICAgICAgICAgImNsaWVudF9zZWNyZXQiOiBjbGllbnRfc2VjcmV0LAogICAgICAgICAgICAiY29kZSI6IGNvZGUsCiAgICAgICAgfSwKICAgICAgICB0aW1lb3V0PUNvbmZpZy5HSVRIVUJfUkVRVUVTVF9USU1FT1VULAogICAgKQogICAgaWYgcmVzcG9uc2Uuc3RhdHVzX2NvZGUgPj0gNDAwOgogICAgICAgIHJhaXNlIEdpdEh1YkF1dGhFcnJvcigKICAgICAgICAgICAgIkdpdEh1YiBjb3VsZCBub3QgY29tcGxldGUgdGhlIGNvbm5lY3Rpb24uIFBsZWFzZSB0cnkgYWdhaW4uIiwKICAgICAgICAgICAgZGV0YWlsPV9wYXJzZV9lcnJvcl9ib2R5KHJlc3BvbnNlKSwKICAgICAgICApCiAgICB0cnk6CiAgICAgICAgcmV0dXJuIHJlc3BvbnNlLmpzb24oKQogICAgZXhjZXB0IFZhbHVlRXJyb3I6CiAgICAgICAgcmFpc2UgR2l0SHViQXV0aEVycm9yKAogICAgICAgICAgICAiR2l0SHViIGNvdWxkIG5vdCBjb21wbGV0ZSB0aGUgY29ubmVjdGlvbi4gUGxlYXNlIHRyeSBhZ2Fpbi4iLAogICAgICAgICAgICBkZXRhaWw9cmVzcG9uc2UudGV4dFs6MjAwXSwKICAgICAgICApCgoKZGVmIGdldF9jbGllbnRfZm9yX3VzZXIodXNlcl9pZDogaW50KSAtPiBHaXRIdWJDbGllbnQ6CiAgICAiIiJSZXR1cm4gYSBHaXRIdWIgY2xpZW50IGZvciB0aGUgdXNlcidzIGNvbm5lY3RlZCBhY2NvdW50LiIiIgogICAgYWNjb3VudCA9IEdpdGh1YkFjY291bnQucXVlcnkuZmlsdGVyX2J5KHVzZXJfaWQ9dXNlcl9pZCkuZmlyc3QoKQogICAgaWYgYWNjb3VudCBpcyBOb25lOgogICAgICAgIHJhaXNlIEdpdEh1Yk5vdENvbm5lY3RlZEVycm9yKAogICAgICAgICAgICAiQ29ubmVjdCB5b3VyIEdpdEh1YiBhY2NvdW50IHRvIHVzZSB0aGlzIGZlYXR1cmUuIgogICAgICAgICkKICAgIHRva2VuID0gZGVjcnlwdF9zZWNyZXQoYWNjb3VudC5hY2Nlc3NfdG9rZW5fZW5jcnlwdGVkKQogICAgcmV0dXJuIEdpdEh1YkNsaWVudCh0b2tlbiwgYXBpX3VybD1Db25maWcuR0lUSFVCX0FQSV9VUkwpCg== \ No newline at end of file +"""GitHub API service layer. + +A thin, retrying client for the GitHub REST API used by the repository +browser, issues, pull requests, and chat context features. All requests are +made on behalf of the current user's connected GitHub account, so GitHub's own +permissions model decides which repositories (public or private) are +accessible. + +Errors are raised as :class:`GitHubError` subclasses so routes can translate +them into user-facing responses without swallowing failures. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import re +import time +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from urllib.parse import parse_qs, urlparse + +import requests +from flask import current_app + +from app.config import Config +from app.extensions import db +from app.models import GithubAccount +from app.services.crypto import decrypt_secret + +logger = logging.getLogger(__name__) + +GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_REVOKE_URL = "https://api.github.com/applications/{client_id}/token" +DEFAULT_API_URL = "https://api.github.com" +API_VERSION = "2022-11-28" + +# GitHub repository names / owners: letters, digits, dashes, dots, underscores. +_FULL_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + +#: Default and maximum ``per_page`` for list endpoints (GitHub caps at 100). +PAGE_SIZE_DEFAULT = 50 +PAGE_SIZE_MAX = 100 + +#: Matches one ``; rel="name"`` entry of a GitHub ``Link`` header. +_LINK_RE = re.compile(r'<([^>]+)>\s*;\s*rel="([^"]+)"') + + +@dataclass(frozen=True) +class GitHubPage: + """A single page of a GitHub list endpoint plus its navigation metadata. + + ``has_next`` / ``has_prev`` / ``total_pages`` are derived from the ``Link`` + response header GitHub returns, so the caller never has to guess where the + next page is. + """ + + items: list[dict] + page: int + per_page: int + has_next: bool = False + has_prev: bool = False + total_pages: int | None = None + + +def parse_link_header(header: str) -> dict[str, str]: + """Return ``{rel: url}`` parsed from a GitHub ``Link`` header.""" + return {rel: url for url, rel in _LINK_RE.findall(header or "")} + + +def _page_number_from_url(url: str | None) -> int | None: + """Extract the ``page`` query parameter from a GitHub paging URL.""" + if not url: + return None + values = parse_qs(urlparse(url).query).get("page") + if not values: + return None + try: + return int(values[0]) + except (TypeError, ValueError): + return None + + +def _github_config(name: str): + """Read GitHub settings from the active app, with class-config fallback.""" + return current_app.config.get(name, getattr(Config, name)) + + +class GitHubError(RuntimeError): + """Base class for GitHub API errors surfaced to the user.""" + + kind = "github_error" + + def __init__(self, message: str, *, kind: str | None = None, detail: str | None = None) -> None: + super().__init__(message) + if kind is not None: + self.kind = kind + # Server-side-only detail (e.g. GitHub's raw response message). Never + # surfaced to the client; logged instead. See ``github_error_payload``. + self.detail = detail + + +class GitHubNotConnectedError(GitHubError): + kind = "not_connected" + + +class GitHubAuthError(GitHubError): + kind = "auth" + + +class GitHubPermissionError(GitHubError): + kind = "permission" + + +class GitHubNotFoundError(GitHubError): + kind = "not_found" + + +class GitHubRateLimitError(GitHubError): + kind = "rate_limit" + + +class GitHubNetworkError(GitHubError): + kind = "network" + + +class GitHubInvalidError(GitHubError): + kind = "validation" + + +#: Stable, sanitized messages shown to end users, keyed by error ``kind``. +#: GitHub's raw response bodies are never surfaced: they can contain URLs, +#: headers, rate-limit data, or implementation details. Those are kept on +#: ``GitHubError.detail`` for server-side logging only. +ERROR_MESSAGES = { + "not_connected": "Connect your GitHub account to use this feature.", + "auth": "Your GitHub connection is no longer valid. Reconnect your account.", + "cancelled": "GitHub connection was cancelled. You can retry whenever you're ready.", + "permission": "GitHub denied access to this resource.", + "not_found": "The requested GitHub resource was not found.", + "rate_limit": "GitHub API rate limit reached. Please try again later.", + "network": "Could not reach the GitHub API. Please try again.", + "validation": "The GitHub request was invalid.", + "github_error": "The GitHub request failed. Please try again.", +} + + +def is_access_denied(error: str | None) -> bool: + """Return ``True`` when GitHub reports the user denied the OAuth consent. + + GitHub sends ``error=access_denied`` on the callback when the user clicks + "Cancel" on the consent screen. That is a user cancellation, not a + failure, so callers should treat it as such (issue: OAuth denial page). + """ + return (error or "").strip().lower() == "access_denied" + + +class GitHubCancelledError(GitHubError): + kind = "cancelled" + + +def github_error_message(exc: GitHubError) -> str: + """Return the stable user-facing message for ``exc``'s kind.""" + return ERROR_MESSAGES.get(exc.kind, ERROR_MESSAGES["github_error"]) + + +def github_error_payload(exc: GitHubError) -> dict: + """Build a sanitized JSON error payload for a :class:`GitHubError`. + + Route handlers must use this instead of ``str(exc)`` so no raw GitHub + message (or token/header material) is ever echoed to the client. + """ + return {"error": github_error_message(exc), "kind": exc.kind} + + +def _parse_error_body(response: requests.Response) -> str: + """Extract a short human-readable message for server-side logging only.""" + try: + data = response.json() + except ValueError: + return response.text[:200] or f"HTTP {response.status_code}" + if isinstance(data, dict): + message = data.get("message") + if isinstance(message, str): + return message + return f"HTTP {response.status_code}" + + +class GitHubClient: + """Authenticated client for the GitHub REST API. + + Provides bounded retry behaviour: transient failures (network errors and + HTTP 5xx) are retried with exponential backoff, and rate-limit responses + (429 or 403 with exhausted quota) pause until the documented reset time. + """ + + def __init__( + self, + access_token: str, + *, + api_url: str | None = None, + timeout: int | None = None, + max_retries: int = 3, + ) -> None: + self.api_url = (api_url or Config.GITHUB_API_URL).rstrip("/") + self.timeout = timeout or Config.GITHUB_REQUEST_TIMEOUT + self.max_retries = max_retries + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": API_VERSION, + } + ) + + # -- Core request machinery -------------------------------------------- + + def _request(self, method: str, path: str, *, params: dict | None = None) -> dict | list: + """Perform a request with retries, raising typed errors on failure.""" + url = f"{self.api_url}{path}" + last_exc: Exception | None = None + for attempt in range(self.max_retries): + try: + response = self.session.request(method, url, params=params, timeout=self.timeout) + except requests.RequestException as exc: + last_exc = exc + logger.warning("GitHub network error on %s: %s", path, exc) + if attempt < self.max_retries - 1: + time.sleep(2**attempt) + continue + raise GitHubNetworkError( + "Could not reach the GitHub API. Please try again.", detail=str(exc) + ) from exc + + if response.status_code == 404: + raise GitHubNotFoundError("The requested GitHub resource was not found.") + if response.status_code == 401: + raise GitHubAuthError( + "Your GitHub connection is no longer valid. Reconnect your account." + ) + + if response.status_code in (403, 429): + remaining = response.headers.get("X-RateLimit-Remaining", "") + if remaining == "0" or response.status_code == 429: + reset_at = int(response.headers.get("X-RateLimit-Reset", "0")) + wait = max(reset_at - int(time.time()), 1) + raise GitHubRateLimitError( + f"GitHub API rate limit reached. Retry in about {wait} seconds." + ) + detail = _parse_error_body(response) + logger.warning("GitHub permission error on %s: %s", path, detail) + raise GitHubPermissionError("GitHub denied access to this resource.", detail=detail) + + if response.status_code >= 500: + last_exc = GitHubError(f"GitHub API returned {response.status_code}.") + if attempt < self.max_retries - 1: + time.sleep(2**attempt) + continue + raise GitHubNetworkError( + f"GitHub API is experiencing issues (HTTP {response.status_code})." + ) from last_exc + + if response.status_code >= 400: + detail = _parse_error_body(response) + logger.warning( + "GitHub API error on %s (%s): %s", path, response.status_code, detail + ) + raise GitHubError("The GitHub request failed. Please try again.", detail=detail) + + if response.status_code == 204 or not response.content: + return {} + + try: + return response.json() + except ValueError as exc: + raise GitHubError("GitHub API returned an unexpected response.") from exc + + raise GitHubNetworkError( + "Could not reach the GitHub API. Please try again.", detail=str(last_exc) + ) + + def _get(self, path: str, *, params: dict | None = None) -> dict | list: + return self._request("GET", path, params=params) + + def _get_paginated( + self, path: str, *, params: dict | None = None, max_items: int = 300 + ) -> list[dict]: + """Fetch paginated results, following the ``Link`` header up to a cap.""" + items: list[dict] = [] + page_params = dict(params or {}) + page_params.setdefault("per_page", 100) + url = f"{self.api_url}{path}" + + while url and len(items) < max_items: + response = self.session.get(url, params=page_params, timeout=self.timeout) + if response.status_code >= 400: + return self._request("GET", path, params=params) # translate error + try: + page: list[dict] = response.json() + except ValueError: + page = [] + items.extend(page) + + link = response.headers.get("Link", "") + next_url = None + for part in link.split(","): + if 'rel="next"' in part: + next_url = part[part.find("<") + 1 : part.find(">")] + break + if next_url is None or not page: + break + url = next_url + page_params = None + + return items[:max_items] + + def _get_page( + self, + path: str, + *, + params: dict | None = None, + page: int = 1, + per_page: int = PAGE_SIZE_DEFAULT, + ) -> GitHubPage: + """Fetch one page of a GitHub list endpoint. + + ``page`` is 1-based and ``per_page`` is clamped to ``PAGE_SIZE_MAX`` + (GitHub's own maximum) so a single response is always bounded. The + ``Link`` header GitHub sends back is parsed to report whether a + next/previous page exists and, when GitHub includes it, the last page + number. + """ + page = max(1, int(page or 1)) + per_page = max(1, min(int(per_page or PAGE_SIZE_DEFAULT), PAGE_SIZE_MAX)) + request_params = dict(params or {}) + request_params.update({"page": page, "per_page": per_page}) + + response = self.session.get( + f"{self.api_url}{path}", params=request_params, timeout=self.timeout + ) + if response.status_code >= 400: + # Re-issue through _request so failures become the typed errors the + # rest of the app expects (auth, rate limit, not found, ...). + self._request("GET", path, params=params) + + try: + items = response.json() + except ValueError: + items = [] + if not isinstance(items, list): + items = [] + + links = parse_link_header(response.headers.get("Link", "")) + has_next = "next" in links + has_prev = "prev" in links + total_pages = _page_number_from_url(links.get("last")) or (page if not has_next else None) + return GitHubPage( + items=items[:per_page], + page=page, + per_page=per_page, + has_next=has_next, + has_prev=has_prev, + total_pages=total_pages, + ) + + # -- GitHub account ----------------------------------------------------- + + def get_user(self) -> dict: + return self._get("/user") + + def get_rate_limit(self) -> dict | None: + """Return the caller's core rate-limit budget, or ``None`` if unknown. + + Uses GitHub's dedicated ``/rate_limit`` endpoint, which does not count + against the caller's quota. The result is normalized to + ``{"limit", "remaining", "reset", "used"}`` where ``reset`` is a Unix + timestamp. Only numeric fields are returned: no token, headers, or raw + GitHub response is ever surfaced (issue #77). + """ + data = self._get("/rate_limit") + if not isinstance(data, dict): + return None + resources = data.get("resources") + core = (resources or {}).get("core") if isinstance(resources, dict) else None + if not isinstance(core, dict): + core = data.get("rate") + if not isinstance(core, dict): + return None + remaining = core.get("remaining") + reset = core.get("reset") + if remaining is None or reset is None: + return None + limit = core.get("limit") + return { + "limit": limit, + "remaining": remaining, + "reset": reset, + "used": core.get("used"), + } + + # -- Repositories ------------------------------------------------------- + + def list_repositories(self, *, per_page: int = 100) -> list[dict]: + return self._get_paginated( + "/user/repos", + params={"affiliation": "owner,collaborator", "sort": "updated", "per_page": per_page}, + ) + + def get_repository(self, full_name: str) -> dict: + return self._get(f"/repos/{full_name}") + + def list_branches(self, full_name: str) -> list[dict]: + return self._get_paginated(f"/repos/{full_name}/branches") + + # -- Contents / tree ---------------------------------------------------- + + def get_contents( + self, full_name: str, path: str = "", ref: str | None = None + ) -> list[dict] | dict: + params = {} + if ref: + params["ref"] = ref + return self._get(f"/repos/{full_name}/contents/{path}", params=params or None) + + def get_tree(self, full_name: str, ref: str, *, recursive: bool = True) -> dict: + return self._get( + f"/repos/{full_name}/git/trees/{ref}", + params={"recursive": 1 if recursive else None}, + ) + + def search_files(self, full_name: str, query: str, ref: str) -> list[dict]: + """Return repository paths whose file name matches ``query`` (case-insensitive).""" + tree = self.get_tree(full_name, ref) + matches = [] + needle = query.strip().lower() + for entry in tree.get("tree", []): + if entry.get("type") != "blob": + continue + path = entry.get("path", "") + if not needle or needle in path.lower(): + matches.append({"path": path, "size": entry.get("size")}) + return matches[:100] + + def get_file_text(self, full_name: str, path: str, ref: str | None = None) -> str: + """Return the decoded UTF-8 text of a file, enforcing a size cap. + + Raises :class:`GitHubError` for binary or oversized files. + """ + params = {} + if ref: + params["ref"] = ref + response = self.session.get( + f"{self.api_url}/repos/{full_name}/contents/{path}", + params=params or None, + timeout=self.timeout, + ) + if response.status_code >= 400: + self._request("GET", f"/repos/{full_name}/contents/{path}", params=params or None) + raise GitHubError("Could not read file contents.") + + if response.headers.get("Content-Type", "").startswith("application/json"): + try: + data = response.json() + except ValueError: + raise GitHubError("Could not read file contents.") from None + if isinstance(data, dict) and "content" in data: + raw = base64.b64decode(data["content"]) + if len(raw) > Config.GITHUB_MAX_CONTEXT_CHARS * 2: + raise GitHubError("This file is too large to display or analyze.") + return raw.decode("utf-8", errors="replace") + raise GitHubError("This file is not a text file or is too large to display.") + + def get_file_text_batch( + self, + full_name: str, + paths: list[str], + ref: str | None = None, + *, + max_files: int = 25, + max_chars: int = 200_000, + ) -> list[dict]: + """Fetch bounded text contents for a list of repo paths (best-effort). + + Used to assemble a small, detection-relevant slice of a repository for + Stellar/Soroban analysis without downloading the whole repo. Files that + are binary, oversized, or cannot be read are skipped silently so a + failed fetch never breaks the analysis. + + Returns ``[{"path": str, "content": str}, ...]`` with at most + ``max_files`` entries, each content capped at ``max_chars`` characters. + """ + rows: list[dict] = [] + for path in paths: + if len(rows) >= max_files: + break + try: + text = self.get_file_text(full_name, path, ref=ref) + except GitHubError: + continue + if text is None: + continue + rows.append({"path": path, "content": text[:max_chars]}) + return rows + + # -- Commits ------------------------------------------------------------ + + def list_commits( + self, full_name: str, *, ref: str | None = None, path: str | None = None + ) -> list[dict]: + params = {"per_page": 50} + if ref: + params["sha"] = ref + if path: + params["path"] = path + return self._get(f"/repos/{full_name}/commits", params=params) + + def list_commits_page( + self, + full_name: str, + *, + ref: str | None = None, + path: str | None = None, + page: int = 1, + per_page: int = PAGE_SIZE_DEFAULT, + ) -> GitHubPage: + """Fetch one page of a repository's commit history (issue #66). + + Uses the ``Link`` header GitHub returns so the caller can offer + "load more" without guessing where the next page is. ``ref`` and + ``path`` filter the history and are preserved across pages. + """ + params: dict = {} + if ref: + params["sha"] = ref + if path: + params["path"] = path + return self._get_page( + f"/repos/{full_name}/commits", + params=params or None, + page=page, + per_page=per_page, + ) + + def get_commit(self, full_name: str, sha: str) -> dict: + return self._get(f"/repos/{full_name}/commits/{sha}") + + def _graphql(self, query: str, variables: dict) -> dict: + """Execute a GraphQL query against the GitHub GraphQL API.""" + url = f"{self.api_url}/graphql" + try: + response = self.session.request( + "POST", + url, + json={"query": query, "variables": variables}, + timeout=self.timeout, + ) + except requests.RequestException as exc: + raise GitHubNetworkError( + "Could not reach the GitHub API. Please try again.", detail=str(exc) + ) from exc + if response.status_code == 401: + raise GitHubAuthError( + "Your GitHub connection is no longer valid. Reconnect your account." + ) + if response.status_code >= 400: + raise GitHubError( + "The GitHub request failed. Please try again.", + detail=_parse_error_body(response), + ) + try: + payload = response.json() + except ValueError as exc: + raise GitHubError("GitHub API returned an unexpected response.") from exc + if payload.get("errors"): + raise GitHubError("The GitHub request failed. Please try again.") + return payload.get("data") or {} + + def get_last_commits(self, full_name: str, paths: list[str], ref: str) -> dict[str, dict]: + """Return the last commit touching each path in a single request. + + GitHub's REST API can only answer "last commit for this path" one path + at a time. Batching the per-path history lookups into one GraphQL query + keeps this to a single API call regardless of how many files are shown. + Files whose history cannot be resolved are simply omitted from the + result. + """ + if not paths: + return {} + owner, _, name = full_name.partition("/") + fields = [] + for index, path in enumerate(paths): + literal = json.dumps(path) # safe GraphQL string literal + fields.append( + f"f{index}: object(expression: $ref) {{ ... on Commit {{ " + f"history(first: 1, path: {literal}) {{ nodes {{ oid messageHeadline " + f"committedDate author {{ name }} }} }} }} }}" + ) + query = ( + "query($owner: String!, $name: String!, $ref: String!) { " + "repository(owner: $owner, name: $name) {" + "\n".join(fields) + "}}" + ) + data = self._graphql(query, {"owner": owner, "name": name, "ref": ref}) + repository = data.get("repository") or {} + commits: dict[str, dict] = {} + for index, path in enumerate(paths): + node = repository.get(f"f{index}") or {} + nodes = (node.get("history") or {}).get("nodes") or [] + if not nodes: + continue + commit = nodes[0] + commits[path] = { + "sha": commit.get("oid"), + "message": commit.get("messageHeadline"), + "author": (commit.get("author") or {}).get("name"), + "date": commit.get("committedDate"), + } + return commits + + # -- Issues ------------------------------------------------------------- + + def list_issues(self, full_name: str, *, state: str = "open", per_page: int = 50) -> list[dict]: + """Return issues (excluding pull requests).""" + data = self._get( + f"/repos/{full_name}/issues", + params={"state": state, "per_page": per_page}, + ) + return issues_only(data) + + def list_issues_page( + self, + full_name: str, + *, + state: str = "open", + page: int = 1, + per_page: int = PAGE_SIZE_DEFAULT, + ) -> GitHubPage: + """Return one page of issues (excluding pull requests). + + GitHub's issues endpoint includes pull requests, so the payload is + filtered; the returned navigation metadata still reflects GitHub's own + paging links for the endpoint. + """ + result = self._get_page( + f"/repos/{full_name}/issues", + params={"state": state}, + page=page, + per_page=per_page, + ) + return GitHubPage( + items=issues_only(result.items), + page=result.page, + per_page=result.per_page, + has_next=result.has_next, + has_prev=result.has_prev, + total_pages=result.total_pages, + ) + + def get_issue(self, full_name: str, number: int) -> dict: + return self._get(f"/repos/{full_name}/issues/{number}") + + # -- Pull requests ------------------------------------------------------ + + def list_pull_requests(self, full_name: str, *, state: str = "open") -> list[dict]: + return self._get( + f"/repos/{full_name}/pulls", + params={"state": state, "per_page": 50}, + ) + + def list_pull_requests_page( + self, + full_name: str, + *, + state: str = "open", + page: int = 1, + per_page: int = PAGE_SIZE_DEFAULT, + ) -> GitHubPage: + """Return one page of pull requests with Link-header navigation.""" + return self._get_page( + f"/repos/{full_name}/pulls", + params={"state": state}, + page=page, + per_page=per_page, + ) + + def get_pull_request(self, full_name: str, number: int) -> dict: + return self._get(f"/repos/{full_name}/pulls/{number}") + + def list_pull_request_files(self, full_name: str, number: int) -> list[dict]: + return self._get(f"/repos/{full_name}/pulls/{number}/files", params={"per_page": 100}) + + def list_pull_request_reviews(self, full_name: str, number: int) -> list[dict]: + return self._get(f"/repos/{full_name}/pulls/{number}/reviews") + + def list_pull_request_comments(self, full_name: str, number: int) -> list[dict]: + return self._get(f"/repos/{full_name}/pulls/{number}/comments", params={"per_page": 100}) + + # -- README ------------------------------------------------------------- + + def get_readme(self, full_name: str, ref: str | None = None) -> str | None: + params = {} + if ref: + params["ref"] = ref + try: + response = self.session.get( + f"{self.api_url}/repos/{full_name}/readme", + params=params or None, + headers={"Accept": "application/vnd.github.raw+json"}, + timeout=self.timeout, + ) + except requests.RequestException: + return None + if response.status_code != 200: + return None + text = response.text + return text[: Config.GITHUB_MAX_CONTEXT_CHARS] if text else None + + +# -- Payload normalization --------------------------------------------------- + + +def _license_label(license_data: dict | None) -> str | None: + """Return a short license label, preferring the SPDX id.""" + if not license_data: + return None + spdx = license_data.get("spdx_id") + if spdx and spdx != "NOASSERTION": + return spdx + return license_data.get("name") + + +def repo_payload(repo: dict) -> dict: + """Normalize a repository dict into the shape consumed by the UI.""" + return { + "full_name": repo.get("full_name"), + "name": repo.get("name"), + "description": repo.get("description"), + "owner": repo.get("owner", {}).get("login"), + "visibility": repo.get("visibility", "private" if repo.get("private") else "public"), + "private": bool(repo.get("private")), + "default_branch": repo.get("default_branch"), + "language": repo.get("language"), + "updated_at": repo.get("updated_at"), + "html_url": repo.get("html_url"), + "size": repo.get("size"), + "fork": bool(repo.get("fork")), + "stars": repo.get("stargazers_count") or 0, + "forks": repo.get("forks_count") or 0, + "open_issues_count": repo.get("open_issues_count") or 0, + "license": _license_label(repo.get("license")), + "topics": repo.get("topics") or [], + "homepage": repo.get("homepage") or "", + } + + +def issues_only(items: list[dict]) -> list[dict]: + """Return only true issues from a GitHub issues payload (drop PRs).""" + return [item for item in items if "pull_request" not in item] + + +def issue_payload(issue: dict) -> dict: + return { + "number": issue.get("number"), + "title": issue.get("title"), + "body": issue.get("body"), + "state": issue.get("state"), + "author": (issue.get("user") or {}).get("login"), + "created_at": issue.get("created_at"), + "updated_at": issue.get("updated_at"), + "labels": [label.get("name") for label in issue.get("labels", [])], + "html_url": issue.get("html_url"), + "comments": issue.get("comments", 0), + } + + +def commit_payload(commit: dict) -> dict: + commit_data = commit.get("commit") or {} + author = commit_data.get("author") or {} + return { + "sha": commit.get("sha"), + "message": commit_data.get("message"), + "author": author.get("name") or (commit.get("author") or {}).get("login"), + "date": author.get("date"), + "html_url": commit.get("html_url"), + "url": commit.get("url"), + "files": [ + { + "filename": f.get("filename"), + "additions": f.get("additions"), + "deletions": f.get("deletions"), + "status": f.get("status"), + "patch": f.get("patch"), + } + for f in commit.get("files", []) + ], + } + + +def pull_request_payload(pr: dict) -> dict: + return { + "number": pr.get("number"), + "title": pr.get("title"), + "body": pr.get("body"), + "state": pr.get("state"), + "author": (pr.get("user") or {}).get("login"), + "created_at": pr.get("created_at"), + "updated_at": pr.get("updated_at"), + "merged": bool(pr.get("merged")), + "mergeable": pr.get("mergeable"), + "head": (pr.get("head") or {}).get("ref"), + "base": (pr.get("base") or {}).get("ref"), + "additions": pr.get("additions"), + "deletions": pr.get("deletions"), + "changed_files": pr.get("changed_files"), + "html_url": pr.get("html_url"), + "diff_url": pr.get("diff_url"), + } + + +def validate_full_name(full_name: str) -> str: + """Validate ``owner/repo`` shape; raises :class:`GitHubInvalidError`.""" + name = (full_name or "").strip() + if not _FULL_NAME_RE.match(name) or name.count("/") != 1: + raise GitHubInvalidError("Invalid repository name.") + return name + + +def validate_path(path: str) -> str: + """Validate a repository-relative file path against path traversal. + + An empty/root path is accepted and normalized to ``""``. + """ + cleaned = (path or "").strip().lstrip("/") + if not cleaned: + return "" + parts = cleaned.split("/") + if any(part in ("", ".", "..") for part in parts): + raise GitHubInvalidError("Invalid path.") + return "/".join(parts) + + +def revoke_github_token(access_token: str) -> None: + """Best-effort revocation of a GitHub OAuth token.""" + try: + response = requests.delete( + GITHUB_REVOKE_URL.format(client_id=_github_config("GITHUB_CLIENT_ID")), + auth=(_github_config("GITHUB_CLIENT_ID"), _github_config("GITHUB_CLIENT_SECRET")), + json={"access_token": access_token}, + headers={"Accept": "application/vnd.github+json"}, + timeout=_github_config("GITHUB_REQUEST_TIMEOUT"), + ) + if response.status_code >= 400: + logger.warning("GitHub token revocation failed with HTTP %s", response.status_code) + except requests.RequestException as exc: + logger.warning("GitHub token revocation failed: %s", exc) + + +def _refresh_github_token(refresh_token: str) -> dict | None: + """Exchange a GitHub refresh token for a new token pair.""" + try: + response = requests.post( + GITHUB_TOKEN_URL, + headers={"Accept": "application/json"}, + data={ + "client_id": _github_config("GITHUB_CLIENT_ID"), + "client_secret": _github_config("GITHUB_CLIENT_SECRET"), + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + timeout=_github_config("GITHUB_REQUEST_TIMEOUT"), + ) + data = response.json() + except (requests.RequestException, ValueError): + return None + if response.status_code >= 400 or "access_token" not in data: + return None + return data + + +def get_github_client(user=None) -> GitHubClient: + """Return an authenticated client for ``user`` (default: current user). + + Raises :class:`GitHubNotConnectedError` when the user has not connected a + GitHub account, and :class:`GitHubAuthError` when the stored token cannot + be decrypted. + """ + from flask_login import current_user + + owner = user or current_user + account = GithubAccount.query.filter_by(user_id=owner.id).first() + if account is None: + raise GitHubNotConnectedError("Connect your GitHub account to use this feature.") + try: + token = decrypt_secret(account.access_token_encrypted) + refresh_token = ( + decrypt_secret(account.refresh_token_encrypted) + if account.refresh_token_encrypted + else None + ) + except ValueError as exc: + raise GitHubAuthError( + "Your GitHub connection is no longer valid. Reconnect your account.", + detail=str(exc), + ) from exc + expires_at = account.token_expires_at + if expires_at and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + if expires_at and expires_at <= datetime.now(UTC): + refreshed = _refresh_github_token(refresh_token) if refresh_token else None + if refreshed is None: + db.session.delete(account) + db.session.commit() + raise GitHubNotConnectedError("Connect your GitHub account to use this feature.") + account.set_access_token(refreshed["access_token"]) + if "refresh_token" in refreshed: + account.set_refresh_token(refreshed["refresh_token"]) + account.token_expires_at = ( + datetime.now(UTC) + timedelta(seconds=int(refreshed["expires_in"])) + if refreshed.get("expires_in") + else None + ) + db.session.commit() + token = refreshed["access_token"] + return GitHubClient(token) + + +# -- Chat context references (issue #74) ------------------------------------- + + +#: Matches ``owner/repo#123`` and a bare ``owner/repo``. The lookbehind stops a +#: leading word character, ``@`` or ``/`` from being read as part of the name. +CONTEXT_REFERENCE_RE = re.compile( + r"(? list[dict]: + """Return the GitHub references found in ``text`` (issue #74). + + Three forms are recognized: ``owner/repo#123`` (an issue or pull request), + ``owner/repo`` (a repository), and a bare ``#123`` when the same message also + names a repository to resolve it against. Order is preserved and duplicates + collapse, so mentioning the same target twice fetches it once. A bare + ``#123`` with no repository named is ignored rather than guessed at. + """ + references: list[dict] = [] + seen: set[tuple[str, str, int | None]] = set() + repositories: list[str] = [] + + for match in CONTEXT_REFERENCE_RE.finditer(text or ""): + try: + full_name = validate_full_name(match.group(1)) + except GitHubInvalidError: + continue + number = match.group(2) + if number: + key = ("thread", full_name, int(number)) + if key not in seen: + seen.add(key) + references.append({"kind": "thread", "full_name": full_name, "number": int(number)}) + else: + key = ("repo", full_name, None) + if key not in seen: + seen.add(key) + references.append({"kind": "repo", "full_name": full_name, "number": None}) + repositories.append(full_name) + + for match in CONTEXT_NUMBER_RE.finditer(text or ""): + number = int(match.group(1)) + for full_name in repositories: + key = ("thread", full_name, number) + if key in seen: + continue + seen.add(key) + references.append({"kind": "thread", "full_name": full_name, "number": number}) + + return references + + +def _repo_context_section(client: GitHubClient, full_name: str) -> str: + """Return the model-facing context block for a whole repository.""" + repo = client.get_repository(full_name) + lines = [ + f"### Repository {repo.get('full_name') or full_name}", + f"- Description: {repo.get('description') or '(none)'}", + f"- Primary language: {repo.get('language') or 'unknown'}", + f"- Default branch: {repo.get('default_branch') or 'main'}", + f"- URL: {repo.get('html_url') or 'https://github.com/' + full_name}", + ] + try: + readme = client.get_readme(full_name, repo.get("default_branch")) + except GitHubError: + readme = None + if readme: + lines.extend(["", "README:", readme]) + return "\n".join(lines) + + +def _pull_request_context_section(client: GitHubClient, full_name: str, number: int) -> str: + """Return the model-facing context block for a pull request.""" + pull = client.get_pull_request(full_name, number) + state = pull.get("state") or "unknown" + if pull.get("merged_at"): + state = f"{state} (merged)" + lines = [ + f"### Pull request {full_name}#{number}", + f"- Title: {pull.get('title') or '(no title)'}", + f"- State: {state}", + f"- Author: {(pull.get('user') or {}).get('login') or 'unknown'}", + f"- Branch: {(pull.get('head') or {}).get('ref')} -> {(pull.get('base') or {}).get('ref')}", + ( + f"- Changed files: {pull.get('changed_files', 0)} " + f"(+{pull.get('additions', 0)} -{pull.get('deletions', 0)})" + ), + f"- URL: {pull.get('html_url') or ''}", + ] + if pull.get("body"): + lines.extend(["", "Body:", pull["body"]]) + return "\n".join(lines) + + +def _issue_context_section(client: GitHubClient, full_name: str, number: int) -> str: + """Return the model-facing context block for an issue.""" + issue = client.get_issue(full_name, number) + labels = ", ".join( + label.get("name", "") for label in issue.get("labels") or [] if isinstance(label, dict) + ) + lines = [ + f"### Issue {full_name}#{number}", + f"- Title: {issue.get('title') or '(no title)'}", + f"- State: {issue.get('state') or 'unknown'}", + f"- Author: {(issue.get('user') or {}).get('login') or 'unknown'}", + f"- Labels: {labels or '(none)'}", + f"- URL: {issue.get('html_url') or ''}", + ] + if issue.get("body"): + lines.extend(["", "Body:", issue["body"]]) + return "\n".join(lines) + + +def _thread_context_section(client: GitHubClient, full_name: str, number: int) -> str: + """Return the context block for ``#number``, preferring a pull request. + + GitHub's issues API also returns pull requests, so probing the pull request + endpoint first (and falling back to the issue endpoint on 404) resolves both + kinds without a second round trip when the reference is a pull request. + """ + try: + return _pull_request_context_section(client, full_name, number) + except GitHubNotFoundError: + return _issue_context_section(client, full_name, number) + + +def build_github_context(user, text: str) -> dict: + """Assemble model-facing context for the GitHub references in ``text`` (issue #74). + + Returns ``{"context", "references", "notices"}``. ``context`` is the block + handed to the model and never exceeds ``Config.GITHUB_MAX_CONTEXT_CHARS`` + characters, so one chat message cannot push an unbounded amount of repository + data into the prompt. A reference that cannot be read (missing, private, or + rate limited) becomes an entry in ``notices`` instead of failing the whole + message. + + Raises :class:`GitHubNotConnectedError` when ``user`` has no GitHub + connection, so the caller can prompt them to connect first. + """ + references = parse_context_references(text) + if not references: + return {"context": "", "references": [], "notices": []} + + client = get_github_client(user) + budget = int(Config.GITHUB_MAX_CONTEXT_CHARS) + sections: list[str] = [] + notices: list[str] = [] + + for reference in references: + full_name = reference["full_name"] + number = reference["number"] + label = f"{full_name}#{number}" if number else full_name + try: + if reference["kind"] == "repo": + sections.append(_repo_context_section(client, full_name)) + else: + sections.append(_thread_context_section(client, full_name, number)) + except GitHubNotFoundError: + notices.append(f"{label} was not found or is not visible to your GitHub account.") + except GitHubError as exc: + notices.append(f"{label}: {github_error_message(exc)}") + + context = "\n\n".join(section for section in sections if section) + if len(context) > budget: + context = context[:budget] + CONTEXT_TRUNCATED_MARKER + + return {"context": context, "references": references, "notices": notices} diff --git a/app/templates/errors/oauth_denied.html b/app/templates/errors/oauth_denied.html index 0f86c84..5b8a82f 100644 --- a/app/templates/errors/oauth_denied.html +++ b/app/templates/errors/oauth_denied.html @@ -1,6 +1,6 @@ -{{ extends "base.html" }} +{% extends "base.html" %} -{k block title }}GitHub connection cancelled - {{ config.APP_NAME }}{% endblock %} +{% block title %}GitHub connection cancelled - {{ config.APP_NAME }}{% endblock %} {% block content %}
From 27fcbe98d382c8670b87876a7163bd5aa0c97703 Mon Sep 17 00:00:00 2001 From: Victoria_Devo <330871768+Victoria-Devo@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:00:48 +0000 Subject: [PATCH 4/4] fix(ci): repair the corrupted Jinja delimiters in the GitHub dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{{ endblock %}` closed the title block of `github/index.html`, so the template failed with `TemplateSyntaxError: unexpected '}'` and every test that renders it — the dashboard, the OAuth callback redirects and the disconnect flow, 7 in total — failed. * app/templates/github/index.html: restore `{% endblock %}` and the missing space in the low-quota message. --- app/templates/github/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/templates/github/index.html b/app/templates/github/index.html index 307470e..d1b2382 100644 --- a/app/templates/github/index.html +++ b/app/templates/github/index.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}GitHub - {{ config.APP_NAME }}{{ endblock %} +{% block title %}GitHub - {{ config.APP_NAME }}{% endblock %} {% block content %}
@@ -75,7 +75,7 @@

GitHub connection cancelled

if (!rl || !rl.available || !rl.low) return; var remaining = rl.remaining; var limit = rl.limit; - var message = "Low GitHub API quota: " + remaining + "of " + limit + + var message = "Low GitHub API quota: " + remaining + " of " + limit + " requests remaining."; if (rl.reset) { message += " Resets " + new Date(rl.reset * 1000).toLocaleString() + ".";