Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a0b2772
feat: GitHub OAuth login + dev-env fixes (port 9000, relative REPOS_DIR)
MohamedAklamaash May 11, 2026
54904ce
chore: standardize on pnpm lockfile, ignore swap files
MohamedAklamaash May 11, 2026
e79bcd2
refactor(repos): centralize status as TextChoices
MohamedAklamaash May 11, 2026
55c9cf1
refactor: extract github_api and view helpers for DRY
MohamedAklamaash May 11, 2026
321c10f
chore: remove redundant comments in backend
MohamedAklamaash May 11, 2026
eb5e1cd
refactor(ui): replace AI-cliche palette with terracotta+sage
MohamedAklamaash May 11, 2026
59c04e4
fix(ui): align processing steps with backend, add done state
MohamedAklamaash May 11, 2026
b82c3e9
chore: remove redundant comments in frontend
MohamedAklamaash May 11, 2026
7ca668a
test: cover new auth_github and repos view helpers
MohamedAklamaash May 11, 2026
eca2aa5
fix(ui): preserve session on needs_reauth 401s
MohamedAklamaash May 11, 2026
ff55cbd
fix(ui): show active step during pending status, stop polling on ready
MohamedAklamaash May 11, 2026
98f5823
refactor(github_api): return stable 3-tuple for errors
MohamedAklamaash May 11, 2026
83f6e3e
fix(ui): replace stale blue accent literals with terracotta vars
MohamedAklamaash May 11, 2026
fc1c146
refactor(repos): remove dead get_user_repo_or_404 raising helper
MohamedAklamaash May 11, 2026
8e082fa
feat(backend): streaming chat, resilient AI, server-side graph filter
MohamedAklamaash Jun 25, 2026
6e92ddb
feat(ui): AI-first chat, design-system overhaul, enterprise landing
MohamedAklamaash Jun 25, 2026
0d1671a
chore(dev): run stack on non-default ports (db 5435, api 9002, ui 5003)
MohamedAklamaash Jun 25, 2026
e637aca
Merge pull request #1 from MohamedAklamaash/feat/ai-first-ux-overhaul
MohamedAklamaash Jun 25, 2026
60a4813
docs: update demo screenshot to the new UI (#2)
MohamedAklamaash Jun 25, 2026
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
38 changes: 37 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
.env
*.env
!env.local
!local.env

__pycache__/
*.pyc
*.pyo
*.pyd
.Python

.DS_Store

node_modules/
dist/
*.egg-info/

.venv/
venv/
venv/
.testvenv/

.claude/

.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
coverage.xml
htmlcov/

*.sqlite3
*.sqlite3-journal

*.log

/repos/
.repos/

.idea/
.vscode/

*.tsbuildinfo
.vite/

*.swp
package-lock.json
Empty file.
5 changes: 5 additions & 0 deletions backend/apps/auth_github/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class AuthGithubConfig(AppConfig):
name = "apps.auth_github"
21 changes: 21 additions & 0 deletions backend/apps/auth_github/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from cryptography.fernet import Fernet
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured


def _fernet() -> Fernet:
key = settings.GITHUB_TOKEN_ENC_KEY
if not key:
raise ImproperlyConfigured(
"GITHUB_TOKEN_ENC_KEY is required. Generate with: "
"python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)
return Fernet(key.encode() if isinstance(key, str) else key)


def encrypt(plain: str) -> str:
return _fernet().encrypt(plain.encode("utf-8")).decode("utf-8")


def decrypt(enc: str) -> str:
return _fernet().decrypt(enc.encode("utf-8")).decode("utf-8")
70 changes: 70 additions & 0 deletions backend/apps/auth_github/github_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging

import requests
from rest_framework.response import Response

from .crypto import decrypt
from .models import GitHubIdentity

logger = logging.getLogger(__name__)


def get_identity_or_reauth(user):
try:
identity = user.github_identity
except GitHubIdentity.DoesNotExist:
return None, Response({"needs_reauth": True}, status=401)
if identity.needs_reauth:
return None, Response({"needs_reauth": True}, status=401)
return identity, None


def decrypt_token_or_reauth(identity):
try:
return decrypt(identity.access_token_enc), None
except Exception:
logger.warning("Failed to decrypt GitHub token for user %s", identity.user_id)
identity.needs_reauth = True
identity.save(update_fields=["needs_reauth", "updated_at"])
return None, Response({"needs_reauth": True}, status=401)


def github_get(token, url, params=None, timeout=20):
"""Call GitHub's REST API with standard error mapping.

Returns either the raw `requests.Response` (on 2xx / 401 / 404, where the
caller wants to inspect status semantically) or a stable 3-tuple of
`(error_key, http_status, retry_after)` for error mappings. `retry_after`
is the `Retry-After` header value for `rate_limited` and `None` for every
other error key. Callers can destructure unconditionally:
key, status, retry_after = result

Error keys:
- ("github_unreachable", 503, None) — network/timeout failure
- ("rate_limited", 503, retry_after) — 403/429
- ("github_error", 502, None) — other 4xx/5xx upstream
"""
try:
resp = requests.get(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
},
params=params,
timeout=timeout,
)
except requests.RequestException as e:
logger.warning("GitHub network error on %s: %s", url, type(e).__name__)
return ("github_unreachable", 503, None)

if resp.status_code in (403, 429):
return ("rate_limited", 503, resp.headers.get("Retry-After"))

if resp.status_code >= 400 and resp.status_code != 401 and resp.status_code != 404:
# 401 and 404 carry semantic meaning the caller handles — bubble the
# response up. Other 4xx/5xx are folded into a generic upstream error.
logger.warning("GitHub upstream error %s on %s", resp.status_code, url)
return ("github_error", 502, None)

return resp
32 changes: 32 additions & 0 deletions backend/apps/auth_github/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.11 on 2026-05-10 13:23

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='GitHubIdentity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('github_user_id', models.BigIntegerField(db_index=True, unique=True)),
('login', models.CharField(max_length=255)),
('avatar_url', models.URLField(blank=True, default='')),
('access_token_enc', models.TextField()),
('scopes', models.TextField(blank=True, default='')),
('needs_reauth', models.BooleanField(default=False)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='github_identity', to=settings.AUTH_USER_MODEL)),
],
),
]
Empty file.
21 changes: 21 additions & 0 deletions backend/apps/auth_github/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.conf import settings
from django.db import models


class GitHubIdentity(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="github_identity",
)
github_user_id = models.BigIntegerField(unique=True, db_index=True)
login = models.CharField(max_length=255)
avatar_url = models.URLField(blank=True, default="")
access_token_enc = models.TextField()
scopes = models.TextField(blank=True, default="")
needs_reauth = models.BooleanField(default=False)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"gh:{self.login}"
Empty file.
42 changes: 42 additions & 0 deletions backend/apps/auth_github/tests/test_crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Tests for apps.auth_github.crypto (item 1)."""
import pytest
from cryptography.fernet import InvalidToken
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings

from apps.auth_github.crypto import decrypt, encrypt


class TestCryptoRoundTrip:
def test_encrypt_decrypt_round_trip(self):
plain = "ghp_AbCdEf123456789_synthetic_token"
token = encrypt(plain)
assert token != plain
assert decrypt(token) == plain

def test_encrypt_round_trip_unicode(self):
plain = "tokén-with-üñîçødé"
assert decrypt(encrypt(plain)) == plain

def test_decrypt_tampered_ciphertext_raises(self):
token = encrypt("synthetic_token_for_tamper_test")
# flip a byte near the end of the ciphertext
tampered = token[:-3] + ("A" if token[-3:] != "AAA" else "B") + token[-2:]
with pytest.raises(InvalidToken):
decrypt(tampered)

def test_decrypt_garbage_raises(self):
with pytest.raises(Exception):
decrypt("not-a-valid-fernet-token")

def test_missing_enc_key_raises_improperly_configured(self):
with override_settings(GITHUB_TOKEN_ENC_KEY=""):
with pytest.raises(ImproperlyConfigured):
encrypt("anything")
with pytest.raises(ImproperlyConfigured):
decrypt("anything")

def test_none_enc_key_raises_improperly_configured(self):
with override_settings(GITHUB_TOKEN_ENC_KEY=None):
with pytest.raises(ImproperlyConfigured):
encrypt("anything")
Loading
Loading