Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions app/github/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ def callback():
error = request.args.get("error")
if error:
ratelimit.record(key)
flash(f"GitHub authorization failed: {error}", "error")
if error == "access_denied":
# 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")
Expand Down Expand Up @@ -135,8 +139,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"]
Expand All @@ -145,7 +149,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()
Expand Down
15 changes: 15 additions & 0 deletions app/services/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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"])
Expand Down
59 changes: 56 additions & 3 deletions app/static/js/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,25 @@
(function () {
"use strict";

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;
Expand All @@ -30,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;
}
Expand All @@ -54,7 +71,7 @@
});
},

// Escape HTML, then treat newlines as <br> and URLs as links.
// Escape HTML, then treat newlines as <br /> and URLs as links.
renderMarkdownish: function (text) {
if (!text) return "";
var html = this.escapeHtml(text);
Expand Down Expand Up @@ -83,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
Expand Down
25 changes: 25 additions & 0 deletions app/templates/errors/oauth_denied.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% extends "base.html" %}

{% block title %}GitHub connection cancelled - {{ config.APP_NAME }}{% endblock %}

{% block content %}
<div class="github-denied">
<h1>GitHub connection cancelled</h1>
<p>You cancelled the GitHub authorization request, so no access was granted. Nothing was changed on your account.</p>

<div class="github-denied-explanation">
<h2>What the app would access if you connect</h2>
<ul>
<li>Read your repositories, files, branches, and commits.</li>
<li>Read and analyze issues and pull requests.</li>
<li>Store an encrypted token so the app can query GitHub on your behalf.</li>
</ul>
<p class="github-hint">You can revoke access at any time from your GitHub settings, and you can disconnect here at any time.</p>
</div>

<div class="github-denied-actions">
<a class="btn btn-primary" href="{{ url_for('github.connect') }}">Retry connecting GitHub</a>
<a class="btn btn-ghost" href="{{ url_for('github.index') }}">Back to GitHub integration</a>
</div>
</div>
{% endblock %}
17 changes: 16 additions & 1 deletion app/templates/github/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,32 @@ <h3>Repository browser</h3>
<p>Browse your repositories, files, branches, and commits.</p>
</a>
<a class="feature-card" href="{{ url_for('github.repos') }}">
<h3>Issues & pull requests</h3>
<h3>Issues &amp; pull requests</h3>
<p>Open any repository to review its issues and PRs with AI analysis.</p>
</a>
</div>
</div>
{% else %}
{% if denied %}
<div class="github-empty github-denied" role="alert">
<h2>GitHub connection cancelled</h2>
<p>You canceled the GitHub authorization request. No changes were made to your account.</p>
<p>When you connect, this app will be able to:</p>
<ul class="github-scope-list">
<li>Read your repository list, files, branches, and commits.</li>
<li>View issues and pull requests for repositories you grant access to.</li>
<li>Read your GitHub profile information to display your username.</li>
</ul>
<p class="github-hint">Your token is encrypted before it is stored and is never exposed in the browser. You can disconnect at any time.</p>
<a class="btn btn-primary" href="{{ url_for('github.connect') }}">Connect GitHub</a>
</div>
{% else %}
<div class="github-empty">
<p>Connect your GitHub account to browse repositories, review issues and pull requests, and ask the AI to analyze your code.</p>
<a class="btn btn-primary" href="{{ url_for('github.connect') }}">Connect GitHub</a>
<p class="github-hint">You will be redirected to GitHub to authorize access. Your token is encrypted before it is stored and is never exposed in the browser.</p>
</div>
{% endif %}
{% endif %}
{% endblock %}

Expand Down
Loading