Skip to content
Open
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
24 changes: 0 additions & 24 deletions bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@
"github_app_client_secret",
"github_app_public_link",
"github_app_private_key",
"github_access_token",
"github_username",
"github_authorized_at",
"agent_section",
"agent_orchestration_enabled",
"benchspace_api_url",
Expand Down Expand Up @@ -98,27 +95,6 @@
"label": "GitHub App Private Key",
"read_only": 1
},
{
"fieldname": "github_access_token",
"fieldtype": "Password",
"hidden": 1,
"label": "GitHub Access Token",
"read_only": 1
},
{
"fieldname": "github_username",
"fieldtype": "Data",
"hidden": 1,
"label": "GitHub Username",
"read_only": 1
},
{
"fieldname": "github_authorized_at",
"fieldtype": "Datetime",
"hidden": 1,
"label": "GitHub Authorized At",
"read_only": 1
},
{
"fieldname": "agent_section",
"fieldtype": "Section Break",
Expand Down
88 changes: 85 additions & 3 deletions bwh_hive/bwh_hive/github.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import time
from urllib.parse import urlencode

import jwt
import requests

import frappe

OAUTH_STATE_TTL_SECONDS = 10 * 60


def _get_app_jwt(settings) -> str:
"""Generate a short-lived JWT signed with the GitHub App's private key."""
Expand Down Expand Up @@ -58,6 +61,80 @@ def _get_installation_token(settings) -> str:
return resp.json()["token"]


def _oauth_state_key(nonce: str) -> str:
return f"github_oauth_state:{nonce}"


def _new_oauth_state() -> str:
"""Mint a single-use nonce tied to the current user for the OAuth `state` parameter."""
nonce = frappe.generate_hash(length=32)
frappe.cache.set_value(
_oauth_state_key(nonce), frappe.session.user, expires_in_sec=OAUTH_STATE_TTL_SECONDS
)
return nonce


def consume_oauth_state(nonce: str) -> str | None:
"""Return the user who started this OAuth flow, invalidating the nonce."""
key = _oauth_state_key(nonce)
user = frappe.cache.get_value(key)
frappe.cache.delete_value(key)
return user


def _get_user_token() -> str:
"""Get the current user's GitHub OAuth token."""
token = None
if frappe.db.exists("GitHub Token", frappe.session.user):
token = frappe.get_doc("GitHub Token", frappe.session.user).get_password(
"access_token", raise_exception=False
)

if not token:
frappe.throw(frappe._("Connect your GitHub account from Settings first."))

return token


@frappe.whitelist(methods=["POST"])
def connect_url() -> str:
"""Build the GitHub OAuth URL that links the current user's GitHub account."""
settings = frappe.get_single("Hive Settings")
if not settings.github_app_client_id:
frappe.throw(frappe._("GitHub App not configured."))

params = urlencode(
{
"client_id": settings.github_app_client_id,
"redirect_uri": frappe.utils.get_url("/github/authorize"),
"state": _new_oauth_state(),
}
)
return f"https://github.com/login/oauth/authorize?{params}"


@frappe.whitelist(methods=["POST"])
def install_url() -> str:
"""Build the GitHub App installation URL.

Carries an OAuth state so the post-install redirect also connects the user's account,
since the App manifest sets `request_oauth_on_install`.
"""
settings = frappe.get_single("Hive Settings")
if not settings.github_app_public_link:
frappe.throw(frappe._("GitHub App not configured."))

params = urlencode({"state": _new_oauth_state()})
return f"{settings.github_app_public_link}/installations/new?{params}"


@frappe.whitelist(methods=["POST"])
def disconnect() -> None:
"""Unlink the current user's GitHub account."""
if frappe.db.exists("GitHub Token", frappe.session.user):
frappe.delete_doc("GitHub Token", frappe.session.user, ignore_permissions=True)


@frappe.whitelist()
def status() -> dict:
settings = frappe.get_single("Hive Settings")
Expand Down Expand Up @@ -85,10 +162,16 @@ def status() -> dict:
except Exception:
pass

github_token = frappe.db.get_value(
"GitHub Token", frappe.session.user, "github_username", as_dict=True
)

return {
"app_configured": app_configured,
"connected": connected,
"installed_account": installed_account,
"account_connected": bool(github_token),
"github_username": github_token.github_username if github_token else None,
}


Expand Down Expand Up @@ -132,7 +215,7 @@ def get_repos() -> list[dict]:

@frappe.whitelist()
def create_issue(task_name: str) -> dict:
"""Convert a Hive Task into a GitHub issue using an installation token."""
"""Convert a Hive Task into a GitHub issue authored by the current user."""
task = frappe.get_doc("Hive Task", task_name)

if task.github_issue_url:
Expand All @@ -142,8 +225,7 @@ def create_issue(task_name: str) -> dict:
if not project.github_repo:
frappe.throw("No GitHub repository linked to this project. Set it in the project settings.")

settings = frappe.get_single("Hive Settings")
token = _get_installation_token(settings)
token = _get_user_token()

# Build issue body
body = ""
Expand Down
32 changes: 16 additions & 16 deletions bwh_hive/www/github/authorize.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
import json
from base64 import b64decode

import requests

import frappe
from frappe.utils import now_datetime

from bwh_hive.bwh_hive.github import consume_oauth_state


def get_context(context):
code = frappe.form_dict.get("code")
state = frappe.form_dict.get("state")

if not code or not state:
if not code:
frappe.flags.redirect_location = "/hive"
raise frappe.Redirect

# Validate state
try:
state_data = json.loads(b64decode(state).decode())
except Exception:
frappe.throw("Invalid state parameter.")

if state_data.get("user") != frappe.session.user:
frappe.throw("State mismatch. Please try connecting again.")
if not state or consume_oauth_state(state) != frappe.session.user:
frappe.throw(
frappe._("This GitHub authorization link is invalid or has expired. Try connecting again.")
)

# Exchange code for access token
settings = frappe.get_single("Hive Settings")
Expand Down Expand Up @@ -55,10 +50,15 @@ def get_context(context):
github_user = user_response.json()
github_username = github_user.get("login", "")

# Store on Hive Settings (site-level)
settings.db_set("github_access_token", access_token)
settings.db_set("github_username", github_username)
settings.db_set("github_authorized_at", now_datetime())
if frappe.db.exists("GitHub Token", frappe.session.user):
token = frappe.get_doc("GitHub Token", frappe.session.user)
else:
token = frappe.new_doc("GitHub Token", user=frappe.session.user)

token.access_token = access_token
token.github_username = github_username
token.authorized_at = now_datetime()
token.save(ignore_permissions=True)
frappe.db.commit()

frappe.flags.redirect_location = "/hive"
Expand Down
88 changes: 75 additions & 13 deletions frontend/src/components/settings/GitHubSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useFrappeGetCall, useFrappeGetDoc } from "frappe-react-sdk"
import { useFrappeGetCall, useFrappeGetDoc, useFrappePostCall } from "frappe-react-sdk"
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
Expand All @@ -16,22 +16,63 @@ interface GitHubStatus {
app_configured: boolean
connected: boolean
installed_account: string | null
account_connected: boolean
github_username: string | null
}

export function GitHubSection() {
const { data: settings, isLoading: settingsLoading } = useFrappeGetDoc<HiveSettings>(
"Hive Settings",
"Hive Settings",
)
const { data: ghStatus, isLoading: statusLoading } = useFrappeGetCall<{ message: GitHubStatus }>(
"bwh_hive.bwh_hive.github.status",
const {
data: ghStatus,
isLoading: statusLoading,
mutate: mutateStatus,
} = useFrappeGetCall<{ message: GitHubStatus }>("bwh_hive.bwh_hive.github.status")
const { call: getConnectUrl } = useFrappePostCall<{ message: string }>(
"bwh_hive.bwh_hive.github.connect_url",
)
const { call: getInstallUrl } = useFrappePostCall<{ message: string }>(
"bwh_hive.bwh_hive.github.install_url",
)
const { call: disconnectAccount } = useFrappePostCall<{ message: null }>(
"bwh_hive.bwh_hive.github.disconnect",
)
const [submitting, setSubmitting] = useState(false)
const [orgName, setOrgName] = useState("")

const isConfigured = Boolean(settings?.github_app_id)
const status = ghStatus?.message

const handleConnect = async () => {
try {
const res = await getConnectUrl({})
window.location.href = res.message
} catch {
toast.error("Failed to start GitHub authorization")
}
}

const handleInstall = async () => {
try {
const res = await getInstallUrl({})
window.location.href = res.message
} catch {
toast.error("Failed to open GitHub App installation")
}
}

const handleDisconnect = async () => {
try {
await disconnectAccount({})
await mutateStatus()
toast.success("GitHub account disconnected")
} catch {
toast.error("Failed to disconnect GitHub account")
}
}

const handleCreateApp = async () => {
setSubmitting(true)
try {
Expand Down Expand Up @@ -158,20 +199,41 @@ export function GitHubSection() {
Not installed yet.
</p>
{settings?.github_app_public_link && (
<a
href={`${settings.github_app_public_link}/installations/new`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<Button variant="outline" size="sm">
Install GitHub App
</Button>
</a>
<Button variant="outline" size="sm" onClick={handleInstall}>
Install GitHub App
</Button>
)}
</div>
)}
</div>

{/* Per-user account connection */}
<div className="space-y-3 border-t pt-4">
<div>
<h4 className="text-sm font-medium">Your GitHub account</h4>
<p className="text-xs text-muted-foreground">
Connect your account so issues you create are authored by you.
</p>
</div>

{status?.account_connected ? (
<div className="flex items-center gap-3">
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Connected
</Badge>
<span className="text-sm text-muted-foreground">
as <span className="font-medium text-foreground">{status.github_username}</span>
</span>
<Button variant="outline" size="sm" onClick={handleDisconnect}>
Disconnect
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={handleConnect}>
Connect GitHub account
</Button>
)}
</div>
</div>
)}
</div>
Expand Down
Loading