diff --git a/.claude/commands/claudinho.md b/.claude/commands/claudinho.md new file mode 100644 index 0000000..47d62e1 --- /dev/null +++ b/.claude/commands/claudinho.md @@ -0,0 +1,3 @@ +Você é o Claudinho, mascote e "gerente não oficial" de um grupo de faculdade de Engenharia de Software da UNIPAMPA. Você fala como um colega de turma no grupo do WhatsApp — informal, engraçado, direto, sem parecer IA. Emojis com parcimônia (3-5). Termina SEMPRE com "— Claudinho 🤖". Máximo 15 linhas. + +Gere um informe rápido para o grupo sobre: $ARGUMENTS diff --git a/.claude/commands/diario.md b/.claude/commands/diario.md new file mode 100644 index 0000000..502abc9 --- /dev/null +++ b/.claude/commands/diario.md @@ -0,0 +1,55 @@ +Você é o Claudinho, mascote e "gerente não oficial" de um grupo de faculdade de Engenharia de Software da UNIPAMPA. Você conhece todo mundo pelo nome e acompanha o projeto de perto. Fala como colega de turma no WhatsApp — informal, engraçado, honesto, sem parecer IA ou relatório corporativo. Cita commits e nomes reais. Elogia quem foi bem, cutuca (com carinho) quem ficou parado. Emojis com parcimônia (3-5). Termina SEMPRE com "— Claudinho 🤖". Máximo 20 linhas. + +Primeiro colete o contexto do repositório rodando os comandos abaixo, depois gere o relatório diário da sprint. + +**Devs e branches:** +- dev1 (Bernardo) → bernardo +- dev2 (Pedro) → pedro +- dev3 (Dean) → dev/dev3 +- dev4 (Frederico) → frederico-barcelos +- dev5 (Diogo) → diogo + +**Sprints:** +- Fase 1: deadline 04/05/2026 — Estrutura base +- Fase 2: deadline 11/05/2026 — Transformações (>50% cobertura) +- Fase 3: deadline 18/05/2026 — LLM + Pipeline (>65% cobertura) +- Fase 4: deadline 25/05/2026 — UI + Integração (≥80% cobertura) +- Entrega Final: 01/06/2026 + +**Passos:** + +1. Atualize as referências remotas antes de qualquer análise: +```bash +git fetch --all --prune +``` + +2. Rode para ver commits da semana por branch: +```bash +for branch in bernardo pedro dev/dev3 frederico-barcelos diogo; do echo "=== $branch ==="; git log origin/$branch --since="7 days ago" --no-merges --format="%s" 2>/dev/null; done +``` + +3. Rode para ver módulos existentes: +```bash +git ls-files src/ | grep -E "/(io|transforms|llm|cache|pipeline|ui)/" | sed 's|/[^/]*$||' | sort -u +``` + +4. Rode para ver issues abertas no GitHub da sprint atual (detecta a fase pelo deadline): +```bash +python -c " +import datetime, subprocess, json, sys +hoje = datetime.date.today() +fases = [('fase-1','2026-05-04'),('fase-2','2026-05-11'),('fase-3','2026-05-18'),('fase-4','2026-05-25'),('fase-5','2026-06-01')] +fase = next((f for f,d in fases if hoje <= datetime.date.fromisoformat(d)), 'fase-5') +print(f'[Sprint atual: {fase}]') +result = subprocess.run(['gh','issue','list','--repo','frebarcelos/marco-2-rp3','--label',fase,'--state','open','--limit','50','--json','number,title,assignees,labels'], capture_output=True, text=True) +issues = json.loads(result.stdout or '[]') +for i in issues: + assignees = ', '.join(a['login'] for a in i['assignees']) or '-' + print(f\"#{i['number']} [{assignees}] {i['title']}\") +print(f'Total em aberto: {len(issues)}') +" +``` + +5. Calcule dias restantes até o próximo deadline com base na data de hoje. + +6. Com tudo isso em mãos, escreva o informe diário do Claudinho para o grupo. diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 19a9690..4b478e9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,45 @@ "Bash(pip install *)", "Bash(PYTHONIOENCODING=utf-8 python *)", "Bash(git checkout *)", - "Bash(git merge frederico-barcelos --no-ff -m ' *)" + "Bash(git merge frederico-barcelos --no-ff -m ' *)", + "Bash(git push *)", + "Bash(git rebase *)", + "Bash(git stash *)", + "Bash(pre-commit --version)", + "Bash(pre-commit run *)", + "Bash(pip show *)", + "Bash(pip list *)", + "Bash(where python *)", + "Bash(git fetch *)", + "Bash(python3 *)", + "Bash(gh issue *)", + "Bash(gh label *)", + "Bash(pip3 show *)", + "Bash(sample_pr)", + "Bash(sample_pr_no_body)", + "Bash(sample_prs)", + "Bash(sample_csv_file)", + "Bash(mock_llm_client)", + "Bash(pipx --version)", + "Bash(apt list *)", + "Bash(pip3 --version)", + "Bash(apt-cache show *)", + "Bash(git reset *)", + "Bash(git restore *)", + "Bash(lsb_release -a)", + "Read(//usr/lib/**)", + "Bash(gh pr *)", + "Bash(wait)", + "Bash(pip3 install *)", + "Bash(/home/barcelosfrederico/.cache/pre-commit/repobczmeh9z/py_env-python3.12/bin/ruff check *)", + "Bash(sudo docker ps -a)", + "Bash(ps -p 9808 -o pid,ppid,stat,cmd)", + "Bash(curl -s http://localhost:11434/api/tags)", + "Bash(systemctl is-active *)", + "Bash(git remote *)", + "Bash(ssh -T -o ConnectTimeout=5 git@github.com)", + "Bash(docker compose *)", + "Bash(git merge *)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5842e7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Dados grandes — não entram no build context +data/ + +# Ambiente virtual e cache Python +.venv/ +__pycache__/ +*.pyc +*.pyo +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ + +# Git e CI +.git/ +.github/ + +# Arquivos de ambiente local +.env +*.local + +# Docs e scripts auxiliares +*.md +scripts/ diff --git a/.env.example b/.env.example index 4b93fec..1289271 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,14 @@ ANTHROPIC_API_KEY=sk-ant-your_key_here # Modelo padrão para classificações (gratuito via Groq) LLM_MODEL=llama3-8b-8192 -# Caminho para o dataset CSV do Kaggle +# Caminho para o dataset (CSV ou JSON mined-comments) DATASET_PATH=data/github_prs.csv +# Backend LLM: groq (padrão, requer GROQ_API_KEY) | ollama (local, sem API key) +LLM_BACKEND=groq + +# Host do Ollama — use http://host.docker.internal:11434 quando rodar via Docker +OLLAMA_HOST=http://localhost:11434 + # Tamanho máximo do cache LRU para classificações LLM LLM_CACHE_SIZE=1024 diff --git a/.gitignore b/.gitignore index 9a877e9..91dee1c 100644 --- a/.gitignore +++ b/.gitignore @@ -182,8 +182,8 @@ data/*.json data/*.gz data/*.zip -# Resultados de análise persistidos localmente -cache/ +# Resultados de análise persistidos localmente (só o diretório raiz) +/cache/ # PyPI configuration file .pypirc @@ -196,4 +196,4 @@ cache/ .cursorindexingignore # Configurações locais do Claude Code (contexto pessoal por dev — não versionar) -.claude/local/ \ No newline at end of file +.claude/local/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c935e44..dff9e54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: mypy args: [--strict, --ignore-missing-imports] - additional_dependencies: ["types-all"] + exclude: ^tests/ # ── Checagens básicas de arquivo ───────────────────────────────────────── - repo: https://github.com/pre-commit/pre-commit-hooks @@ -28,50 +28,56 @@ repos: - id: check-added-large-files args: [--maxkb=500] # impede commitar o dataset CSV + # ── Mensagem de commit (Conventional Commits) ──────────────────────────── + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: + - feat # nova funcionalidade + - fix # correção de bug + - test # adição ou correção de testes + - docs # documentação + - chore # manutenção, configs, dependências + - refactor # refatoração sem mudança de comportamento + - style # formatação, espaçamento (sem lógica) + - perf # melhoria de performance + - ci # CI/CD e pipelines + - build # sistema de build, Docker, Makefile + - revert # reverter commit anterior + # ── Verificações locais ─────────────────────────────────────────────────── - repo: local hooks: # Verificador de paradigma funcional e boas práticas (AST) - # Analisa: loops imperativos, mutações in-place, I/O em módulos puros, - # estado global mutável, funções longas, parâmetros sem tipo. - id: check-paradigm name: "Verificador de paradigma funcional e boas práticas" language: python - entry: python scripts/check_paradigm.py + entry: python -X utf8 scripts/check_paradigm.py types: [python] - # Roda em TODOS os .py modificados; o script decide quais regras aplicar - # com base no caminho do arquivo (puro vs. camada de efeito colateral) - # Cobertura mínima de testes: 80% - # Roda apenas no git push (lento — executa toda a suite de testes) - - id: coverage-check - name: "Cobertura de testes >= 80%" + # Testes unitários básicos (sem cobertura) — roda no pre-push via Docker + - id: pytest-unit + name: "Testes unitários (sem integração)" language: system - entry: > - pytest tests/ -m "not integration" - --cov=src/pr_analyzer - --cov-fail-under=80 - --cov-report=term-missing - --tb=short -q --no-header + entry: docker compose run --rm -T app python -m pytest tests/ -m "not integration" --tb=short -q --no-header pass_filenames: false - stages: [pre-push] + stages: [push] - # Testes unitários básicos (sem cobertura) — roda no pre-push também - # Separado do coverage-check para feedback mais rápido em caso de falha - - id: pytest-unit - name: "Testes unitários (sem integração)" + # Cobertura mínima de testes: 80% — roda no pre-push via Docker + - id: coverage-check + name: "Cobertura de testes >= 80%" language: system - entry: pytest tests/ -m "not integration" --tb=short -q --no-header + entry: docker compose run --rm -T app python -m pytest tests/ -m "not integration" --cov=src/pr_analyzer --cov-fail-under=80 --cov-report=term-missing --tb=short -q --no-header pass_filenames: false - stages: [pre-push] + stages: [push] - # Revisão semântica via Claude API - # Analisa conformidade com paradigma funcional, TDD e Clean Code. - # Requer ANTHROPIC_API_KEY no .env — se ausente, é ignorado (sem bloqueio). + # Revisão semântica via Claude API — roda no pre-push via Docker - id: claude-review name: "Revisao de codigo (regras locais)" language: system - entry: python scripts/claude_review.py + entry: docker compose run --rm -T app python scripts/claude_review.py pass_filenames: false - stages: [pre-push] + stages: [push] diff --git a/CLAUDE.md b/CLAUDE.md index bbc84ee..892d63b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Identidade do Projeto Ferramenta de análise de Pull Requests do GitHub usando **paradigma funcional** em Python 3.11+. -Disciplina AL0337 — Linguagens de Programação, UNIPAMPA, Sprint 2. +Disciplina AL0337 — Linguagens de Programação, UNIPAMPA, Sprint 3 concluída (2026-05-18), Sprint 4 em andamento. 5 desenvolvedores (dev1–dev5), TDD obrigatório, pre-commits rigorosos. ## Arquitetura de Módulos @@ -91,12 +91,13 @@ def test_filter_by_state_does_not_raise(state: str) -> None: ## Estrutura de Branches ``` -main ← protegido, só aceita PR revisado por 1 colega -├── dev/dev1 ← módulo io/ -├── dev/dev2 ← módulo transforms/ -├── dev/dev3 ← módulo llm/ -├── dev/dev4 ← módulos cache/ e pipeline/ -└── dev/dev5 ← módulo ui/ + integração +main ← protegido, só aceita PR revisado por 1 colega +develop ← integração entre sprints +├── bernardo ← módulo io/ (dev1) +├── pedro ← módulo transforms/ (dev2) +├── dev/dev3 ← módulo llm/ (dev3/Dean) +├── frederico-barcelos ← módulos cache/ e pipeline/ (dev4) +└── diogo ← módulo ui/ + integração (dev5) ``` - Commits até **domingo 23:59** para a verificação semanal @@ -104,18 +105,62 @@ main ← protegido, só aceita PR revisado por 1 colega ## Cronograma de Fases -| Fase | Verificação | Meta de Cobertura | -|---|---|---| -| 1 — Estrutura base | 04/05/2026 | Módulo do dev funciona | -| 2 — Transformações | 11/05/2026 | >50% | -| 3 — LLM + Pipeline | 18/05/2026 | >65% | -| 4 — UI + Integração | 25/05/2026 | **≥80%** | -| Entrega Final | 01/06/2026 | ≥80% | +| Fase | Verificação | Meta de Cobertura | Status | +|---|---|---|---| +| 1 — Estrutura base | 04/05/2026 | Módulo do dev funciona | ✅ Concluída | +| 2 — Transformações | 11/05/2026 | >50% | ✅ Concluída | +| 3 — LLM + Pipeline | 18/05/2026 | >65% | ✅ Concluída | +| 4 — UI + Integração | 25/05/2026 | **≥80%** | 🔄 Em andamento | +| Entrega Final | 01/06/2026 | ≥80% | — | + +## Padrão de Mensagem de Commit (OBRIGATÓRIO) + +Todo commit deve seguir o formato **Conventional Commits**. O hook `conventional-pre-commit` bloqueia qualquer commit que não siga o padrão. + +``` +(): +``` + +### Tipos permitidos + +| Tipo | Quando usar | +|------|-------------| +| `feat` | Nova funcionalidade | +| `fix` | Correção de bug | +| `test` | Adição ou correção de testes | +| `docs` | Documentação | +| `chore` | Manutenção, configs, dependências | +| `refactor` | Refatoração sem mudança de comportamento | +| `style` | Formatação, espaçamento (sem lógica) | +| `perf` | Melhoria de performance | +| `ci` | CI/CD e pipelines | +| `build` | Sistema de build, Docker, Makefile | +| `revert` | Reverter commit anterior | + +### Exemplos válidos + +``` +feat(cache): implementa make_cache_key com SHA-256 +fix(io): corrige parsing de datas inválidas no CSV +test(transforms): adiciona casos de borda para by_date_range +chore(deps): atualiza streamlit para 1.35 +refactor(pipeline): simplifica compose usando functools.reduce +``` + +### Exemplos inválidos (serão bloqueados) + +``` +update stuff ← sem tipo +Feat: nova função ← tipo com maiúscula +feat: . ← descrição vazia +fixed bug ← sem tipo +``` ## Pre-commit Hooks | Hook | Quando roda | O que faz | |---|---|---| +| `conventional-pre-commit` | commit-msg | Valida formato Conventional Commits | | `ruff` | commit | Linting, imports, complexidade ciclomática (max=10), naming | | `ruff-format` | commit | Formatação determinística | | `mypy` | commit | Type checking estrito (`--strict`) | @@ -128,25 +173,199 @@ main ← protegido, só aceita PR revisado por 1 colega ## Ambiente de Desenvolvimento +**Docker é o ambiente primário.** Instale apenas o necessário no host: + ```bash cp .env.example .env # configure GROQ_API_KEY e ANTHROPIC_API_KEY -make setup # instala deps + hooks pre-commit e pre-push -make run # Streamlit em localhost:8501 -make test # testes unitários (sem integração) -make docker-build # constrói imagem Docker -make docker-run # sobe app no Docker +make docker-build # constrói imagem Docker (uma vez; NUNCA use sudo) +make hooks # instala pre-commit + git hooks no host (uma vez, mínimo) +make docker-run # Streamlit em localhost:8501 +make docker-test # testes unitários dentro do Docker +make pipeline DATASET=data/arquivo.json OUTPUT=out.json LIMIT=20 # pipeline via CLI +``` + +Para desenvolvimento sem Docker (alternativo): +```bash +make setup # instala deps completas + hooks +make run # Streamlit local +make test # testes unitários locais +``` + +Imagem base: `python:3.11-slim`. Hooks de push rodam via Docker automaticamente. + +### Backend LLM — Groq vs Ollama + +Por padrão o projeto usa Groq (requer `GROQ_API_KEY`). Para usar Ollama local (sem API key): + +```bash +# No .env: +LLM_BACKEND=ollama +OLLAMA_HOST=http://host.docker.internal:11434 # dentro do Docker no Linux +LLM_MODEL=llama3 # modelo instalado via ollama pull + +# No host (uma vez): +# 1. Instale em https://ollama.com +# 2. ollama pull llama3 +# 3. Faça o Ollama escutar em todas as interfaces (necessário para o Docker alcançar): +sudo tee /etc/systemd/system/ollama.service.d/override.conf << 'EOF' +[Service] +Environment="OLLAMA_HOST=0.0.0.0" +EOF +sudo systemctl daemon-reload && sudo systemctl restart ollama +``` + +A UI detecta automaticamente o backend configurado no `.env` e exibe o seletor **BACKEND LLM** na sidebar. + +### Dataset local na UI + +A sidebar exibe automaticamente os arquivos encontrados em `data/`: +- Arquivos CSV/JSON no nível raiz +- Arquivos do archive `data/archive/` (formato mined-comments por linguagem) + +Para os archives (6–11 GB por linguagem), o carregamento usa streaming via `ijson` e retorna uma amostra de 2.000 comentários. A classificação `nature`/`clarity` nessa amostra é heurística (palavras-chave + tamanho do corpo) — a integração com LLM real é tarefa futura da Sprint 4 (dev4 + dev5). + +## Hierarquia de Importações (OBRIGATÓRIO) + +Para evitar imports circulares, respeite estritamente a direção das dependências: + +``` +io/csv_reader ← transforms/mappers ← transforms/reducers + ↑ + pipeline/builder (re-exporta EnrichedPR) + ↑ + cache/memo + ↑ + llm/classifiers + ↑ + ui/utils/ +``` + +**Regras derivadas:** +- `transforms/` **nunca** importa de `pipeline/`, `cache/`, `llm/` ou `ui/` +- `pipeline/` **nunca** importa de `cache/`, `llm/` ou `ui/` +- `cache/` **nunca** importa de `llm/` ou `ui/` + +### `EnrichedPR` — localização canônica + +`EnrichedPR` é definido **somente** em `src/pr_analyzer/transforms/reducers.py` como `NamedTuple`. Todos os outros módulos importam de lá: + +```python +# CORRETO — em pipeline/, cache/, llm/, ui/: +from pr_analyzer.transforms.reducers import EnrichedPR +# ou via re-exportação do pipeline: +from pr_analyzer.pipeline.builder import EnrichedPR + +# ERRADO — nunca redefina EnrichedPR como @dataclass ou NamedTuple local ``` -Todos usam `python:3.11-slim` no Docker para paridade de ambiente. +Motivo: definir `EnrichedPR` em `pipeline/builder` e importar de `transforms/reducers` (que já importa de `pipeline/builder`) cria import circular. A ordem correta é `reducers` define → `builder` importa. + +### Aliases em inglês em `classifiers.py` + +`classifiers.py` expõe aliases em inglês no final do arquivo para compatibilidade com `pipeline_bridge.py` e outros módulos: + +```python +classify_project_type = classificar_tipo_projeto +classify_contribution_nature = classificar_natureza_contribuicao +classify_description_clarity = avaliar_clareza_descricao +``` + +Qualquer nova função em `llm/classifiers.py` deve ter tanto o nome em português quanto o alias em inglês. + +### BP005 se aplica a `ui/` também + +Constantes globais mutáveis (`dict`, `list`) bloqueiam o commit em **todos** os arquivos `src/`, incluindo `ui/`. Use funções retornando o valor ou `tuple`/`frozenset`: + +```python +# ERRADO — bloqueia mesmo em ui/: +_CHART_CFG = {"displayModeBar": False} + +# CORRETO: +def _chart_cfg() -> dict[str, bool]: + return {"displayModeBar": False} +``` + +### `st.cache_data` e mypy strict + +O decorator `@st.cache_data` não tem stubs completos e causa `error: Untyped decorator makes function ... untyped [misc]`. Use: + +```python +@st.cache_data(show_spinner=False) # type: ignore[misc] +def get_mock_data() -> pd.DataFrame: +``` + +## Protocolo de Merge Entre Sprints (OBRIGATÓRIO) + +Ao final de cada sprint, o fluxo de integração é: + +1. **Cada branch de dev → `develop`** (via PR no GitHub, sem fast-forward) +2. **`develop` → cada branch de dev** (para distribuir o código integrado de volta) + +### Regra crítica de commits em `develop` + +**Nunca commitar diretamente em `develop`.** Se surgir qualquer ajuste necessário durante o processo de merge (conflitos, correções, adaptações): + +1. Fazer a alteração na branch `frederico-barcelos` +2. Commitar e fazer push de `frederico-barcelos` +3. Abrir PR de `frederico-barcelos` → `develop` +4. Só então continuar o merge das outras branches + +### Ordem de merge recomendada (menor → maior risco de conflito) + +``` +bernardo → develop (io/ — leitura CSV, isolado) +pedro → develop (transforms/ — funções puras, isolado) +dev/dev3 → develop (llm/ — módulo próprio) +frederico-barcelos → develop (cache/ + pipeline/ — integra com transforms) +diogo → develop (ui/ — depende de todos os anteriores) +``` + +O PR do Diogo deve ser aberto **somente após** os de Bernardo, Pedro e Dean, porque o `pipeline_bridge.py` tem `try/except ImportError` que resolve para as implementações reais quando elas estão em `develop`. + +Após todos em `develop`: +``` +develop → bernardo +develop → pedro +develop → dev/dev3 +develop → frederico-barcelos +develop → diogo +``` + +### Verificação pós-merge + +Depois que todos os PRs entrarem em `develop`: +```bash +make docker-test # confirma que imports, testes e cobertura passam com o código integrado +``` + +Erros comuns de integração e suas correções: +- **Import circular** → verifique a hierarquia de importações acima; `transforms/` nunca deve importar de `pipeline/` +- **Tipo incompatível com `EnrichedPR`** → confirme que todos usam `from pr_analyzer.transforms.reducers import EnrichedPR` +- **Conflito de merge não resolvido** → verifique conflict markers com `grep -rn "<<<<<<" src/` +- **BP005 em `ui/`** → dicts e lists globais viram funções + +## Regras de Commit e Push (OBRIGATÓRIO) + +### Para commits +- **Nunca commitar se os pre-commit hooks não estiverem instalados.** Verifique com `ls .git/hooks/pre-commit` — se o arquivo não existir (apenas `.sample`), execute `make setup` antes de qualquer commit. +- **Nunca commitar se o pre-commit não passar.** Qualquer falha em ruff, mypy, check-paradigm ou conventional-pre-commit bloqueia o commit até ser corrigida. + +### Para push +- **Push e merge de PRs são operações do desenvolvedor.** O assistente cria commits locais e pode abrir PRs via `gh`, mas `git push` e `gh pr merge` requerem autorização explícita do desenvolvedor para cada sessão. ## O que NUNCA fazer - **Nunca** usar `for` ou `while` em `transforms/` ou `pipeline/` - **Nunca** chamar `.append()`, `.update()` ou qualquer método mutante em módulos puros - **Nunca** colocar `open()` ou `print()` fora de `io/` ou `ui/` -- **Nunca** commitar o arquivo CSV do dataset (está no .gitignore) +- **Nunca** commitar o arquivo CSV ou os archives JSON de `data/` (estão no .gitignore) +- **Nunca** deletar `.dockerignore` — ele exclui os 28 GB de `data/archive/` do build context +- **Nunca** usar `sudo make docker-*` — o grupo `docker` já tem permissão e sudo causa conflitos de cgroup irrecuperáveis - **Nunca** criar implementação sem escrever o teste antes (TDD) - **Nunca** fazer push direto para `main` (branch protegida) +- **Nunca** commitar diretamente em `develop` — sempre via `frederico-barcelos` → PR → `develop` +- **Nunca** commitar com hooks inativos ou com pre-commit falhando +- **Nunca** executar `git push` — push é sempre feito pelo usuário ## Ao Revisar Código Neste Projeto diff --git a/Dockerfile b/Dockerfile index ae03144..3657dcc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Atualiza pip e setuptools (necessário para PEP 517 build backend) RUN pip install --no-cache-dir --upgrade pip setuptools -# Copia apenas o necessário para instalar dependências (melhor cache de layers) +# Copia apenas o necess'ário para instalar dependências (melhor cache de layers) COPY pyproject.toml . COPY src/ src/ @@ -25,4 +25,7 @@ COPY . . EXPOSE 8501 +ENV PYTHONPATH=/app/src +ENV OLLAMA_HOST=http://host.docker.internal:11434 + CMD ["streamlit", "run", "src/pr_analyzer/ui/app.py", "--server.address=0.0.0.0"] diff --git a/Makefile b/Makefile index 2982a1d..e7e3a2e 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,20 @@ -.PHONY: setup run test lint build +.PHONY: hooks setup run test lint format docker-build docker-run docker-test pipeline +# Instala apenas pre-commit e os git hooks — mínimo para quem usa Docker +# Pré-requisito (uma vez): sudo apt install pipx && pipx ensurepath +hooks: + @command -v pipx >/dev/null 2>&1 || { echo "pipx não encontrado. Rode primeiro:\n sudo apt install pipx && pipx ensurepath\nDepois abra um novo terminal e rode make hooks novamente."; exit 1; } + pipx install pre-commit || pipx upgrade pre-commit + pre-commit install + pre-commit install --hook-type pre-push + pre-commit install --hook-type commit-msg + +# Setup completo para desenvolvimento sem Docker (instala todas as deps localmente) setup: pip install -e ".[dev]" pre-commit install pre-commit install --hook-type pre-push + pre-commit install --hook-type commit-msg run: streamlit run src/pr_analyzer/ui/app.py @@ -28,4 +39,12 @@ docker-run: docker compose up app docker-test: - docker compose --profile test up test + docker compose run --rm -T app python -m pytest tests/ -m "not integration" --tb=short -q --no-header + +# Pipeline completo: dataset (CSV ou JSON) → LLM → output.json +# Uso: make pipeline DATASET=data/arquivo.json OUTPUT=output.json LIMIT=10 +DATASET ?= $(DATASET_PATH) +OUTPUT ?= output.json +LIMIT ?= 10 +pipeline: + docker compose run --rm app python3 scripts/run_pipeline.py "$(DATASET)" "$(OUTPUT)" $(LIMIT) diff --git a/README.md b/README.md index 59082de..1370702 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,13 @@ Ferramenta de análise de Pull Requests do GitHub usando paradigma funcional em ## Requisitos -- Python 3.11+ -- Docker (opcional, recomendado para paridade de ambiente) +- Docker e Docker Compose - [GROQ API Key](https://console.groq.com) +- `pipx` — para instalar o `pre-commit` no host sem poluir o Python do sistema: + ```bash + sudo apt install pipx && pipx ensurepath + # abra um novo terminal após rodar o comando acima + ``` ## Configuração @@ -14,28 +18,47 @@ Ferramenta de análise de Pull Requests do GitHub usando paradigma funcional em git clone cd marco-2-rp3 -cp .env.example .env # preencha GROQ_API_KEY -make setup # instala dependências + pre-commit hooks +cp .env.example .env # preencha GROQ_API_KEY e ANTHROPIC_API_KEY + +make docker-build # constrói a imagem (uma vez, ~2 min) +make hooks # instala pre-commit e os git hooks no host (uma vez) ``` +O `make hooks` instala apenas o binário `pre-commit` via pipx — **não instala o ambiente Python completo**. +Os hooks de commit (ruff, mypy, check-paradigm) rodam em ambientes isolados gerenciados pelo próprio pre-commit. +Os hooks de push (pytest, cobertura, claude-review) executam **dentro do container Docker**. + ## Rodando ```bash -make run # Streamlit em http://localhost:8501 +make docker-run # Streamlit em http://localhost:8501 ``` -Com Docker: +## Testes ```bash -make docker-build -make docker-run # http://localhost:8501 +make docker-test # roda a suite de testes dentro do Docker ``` -## Testes +## Fluxo de trabalho + +``` +edita código → git commit → hooks de commit rodam (ruff, mypy, check-paradigm) + ↓ falhou? corrija e tente de novo + → usuário dá push → hooks de push rodam via Docker (pytest, coverage) + ↓ falhou? corrija, commita, tenta de novo +``` + +> **Push é sempre feito pelo desenvolvedor**, nunca automatizado. Hooks de push precisam que o Docker esteja rodando. + +## Desenvolvimento sem Docker (alternativa) + +Para rodar tudo localmente sem Docker: ```bash -make test # testes unitários (sem integração) -make test-all # todos os testes +make setup # instala todas as deps + hooks (Python 3.11+ necessário) +make run # Streamlit em http://localhost:8501 +make test # testes unitários ``` ## Estrutura @@ -44,7 +67,7 @@ make test-all # todos os testes src/pr_analyzer/ ├── io/ # leitura lazy do CSV (dev1) ├── transforms/ # filter/map/reduce puros (dev2) -├── llm/ # classificação via Groq (dev3) +├── llm/ # classificação via Groq/Ollama (dev3) ├── cache/ # memoização com hashlib + lru_cache (dev4) ├── pipeline/ # compose() e build_pipeline() (dev4) └── ui/ # interface Streamlit (dev5) @@ -57,10 +80,10 @@ O pre-commit bloqueia violações automaticamente antes de cada commit. | Branch | Responsável | |---|---| -| `dev/dev1` | módulo io/ | -| `dev/dev2` | módulo transforms/ | +| `bernardo` | módulo io/ | +| `pedro` | módulo transforms/ | | `dev/dev3` | módulo llm/ | -| `dev/dev4` | módulos cache/ e pipeline/ | -| `dev/dev5` | módulo ui/ + integração | +| `frederico-barcelos` | módulos cache/ e pipeline/ | +| `diogo` | módulo ui/ + integração | PRs para `main` exigem aprovação de 1 colega. diff --git a/docker-compose.yml b/docker-compose.yml index e7a78cc..21abd4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,19 @@ services: app: build: . ports: - - "8501:8501" # Streamlit UI + - "8501:8501" volumes: - - .:/app # hot-reload: edições locais refletem no container - - ./data:/app/data # dataset CSV montado separadamente - - ./cache:/app/cache # cache LLM persistido entre sessões + - .:/app + - ./data:/app/data + - ./cache:/app/cache env_file: - .env - command: streamlit run src/pr_analyzer/ui/app.py --server.address=0.0.0.0 + extra_hosts: + - "host.docker.internal:host-gateway" + command: > + streamlit run src/pr_analyzer/ui/app.py + --server.address=0.0.0.0 + --server.maxUploadSize=${MAX_UPLOAD_SIZE_MB:-10240} test: build: . @@ -19,7 +24,7 @@ services: - .env command: pytest tests/ -m "not integration" --tb=short -q profiles: - - test # executa apenas com: docker compose --profile test up test + - test lint: build: . diff --git a/pyproject.toml b/pyproject.toml index c5a407f..0c71b44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,13 @@ description = "GitHub PR Analysis Tool — Functional Programming Paradigm" requires-python = ">=3.11" dependencies = [ "agno>=1.4", - "streamlit>=1.33", + "streamlit>=1.35", "plotly>=5.20", + "pandas>=2.2", "python-dotenv>=1.0", "groq>=0.9", + "streamlit-extras>=1.5.0", + "ijson>=3.2", ] [project.optional-dependencies] @@ -58,13 +61,11 @@ ignore = [ ] [tool.ruff.lint.mccabe] -# Complexidade ciclomática máxima por função. -# Funções acima de 10 são difíceis de testar e entender. max-complexity = 10 [tool.ruff.lint.per-file-ignores] -# Testes têm regras mais relaxadas (fixtures, asserts longos, etc.) -"tests/**" = ["N802", "N803", "S101", "PT011", "T20"] +"tests/**" = ["N802", "N803", "S101", "PT011", "T20", "E731"] +"scripts/**" = ["T20", "N802"] # print é intencional em CLIs; N802 para visitors AST # ── Mypy: type checking estrito ───────────────────────────────────────────── [tool.mypy] @@ -77,8 +78,6 @@ exclude = ["tests/"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] -# --cov-fail-under NÃO está aqui — fica no pre-commit (pre-push) -# para não bloquear execução local de testes durante desenvolvimento addopts = "--cov=src/pr_analyzer --cov-report=term-missing -q --no-header" markers = [ "integration: testes que chamam APIs LLM externas (pular com -m 'not integration')", @@ -89,15 +88,15 @@ markers = [ [tool.coverage.run] source = ["src/pr_analyzer"] omit = [ - "src/pr_analyzer/ui/*", # UI Streamlit é testada manualmente - "src/pr_analyzer/llm/client.py", # configuração de cliente externo + "src/pr_analyzer/ui/*", + "src/pr_analyzer/llm/client.py", ] -branch = true # mede cobertura de branches (if/else), não só linhas +branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", - "\\.\\.\\.", # stubs com ... + "\\.\\.\\.", ] diff --git a/scripts/check_paradigm.py b/scripts/check_paradigm.py index 4a8a659..146583d 100644 --- a/scripts/check_paradigm.py +++ b/scripts/check_paradigm.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# ruff: noqa: T201 """Verificador de conformidade com o paradigma funcional via análise de AST. Executado pelo pre-commit em cada git commit nos arquivos modificados. @@ -14,14 +15,28 @@ from pathlib import Path from typing import NamedTuple +sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] +sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + # Módulos que devem ser puramente funcionais (sem efeitos colaterais) PURE_MODULES = frozenset({"transforms", "pipeline"}) # Métodos que mutam estruturas in-place — proibidos em módulos puros -MUTATING_METHODS = frozenset({ - "append", "extend", "update", "pop", "remove", - "insert", "clear", "sort", "reverse", "setdefault", "discard", -}) +MUTATING_METHODS = frozenset( + { + "append", + "extend", + "update", + "pop", + "remove", + "insert", + "clear", + "sort", + "reverse", + "setdefault", + "discard", + } +) # Funções de I/O — proibidas em módulos puros IO_FUNCTIONS = frozenset({"open", "print", "input", "write"}) @@ -41,13 +56,16 @@ class Issue(NamedTuple): message: str def __str__(self) -> str: - return f"{self.filepath}:{self.line}: [{self.rule}] {self.level}: {self.message}" + return ( + f"{self.filepath}:{self.line}: [{self.rule}] {self.level}: {self.message}" + ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _is_pure_module(filepath: str) -> bool: """Retorna True se o arquivo pertence a uma camada puramente funcional.""" return any(part in PURE_MODULES for part in Path(filepath).parts) @@ -63,6 +81,7 @@ def _is_src_file(filepath: str) -> bool: # Verificador de módulos puros (transforms/ e pipeline/) # --------------------------------------------------------------------------- + class PureModuleChecker(ast.NodeVisitor): """Verifica violações do paradigma funcional em módulos sem efeitos colaterais.""" @@ -71,22 +90,30 @@ def __init__(self, filepath: str) -> None: self.issues: list[Issue] = [] def _err(self, node: ast.AST, rule: str, msg: str) -> None: - self.issues.append(Issue(self.filepath, getattr(node, "lineno", 0), "ERROR", rule, msg)) + self.issues.append( + Issue(self.filepath, getattr(node, "lineno", 0), "ERROR", rule, msg) + ) def _warn(self, node: ast.AST, rule: str, msg: str) -> None: - self.issues.append(Issue(self.filepath, getattr(node, "lineno", 0), "WARNING", rule, msg)) + self.issues.append( + Issue(self.filepath, getattr(node, "lineno", 0), "WARNING", rule, msg) + ) def visit_For(self, node: ast.For) -> None: - self._err(node, "FP001", + self._err( + node, + "FP001", "Loop 'for' em módulo puro. " - "Use map(), filter(), reduce(), generator expression ou itertools." + "Use map(), filter(), reduce(), generator expression ou itertools.", ) self.generic_visit(node) def visit_While(self, node: ast.While) -> None: - self._err(node, "FP002", + self._err( + node, + "FP002", "Loop 'while' em módulo puro. " - "Use recursão ou funções de itertools para repetição funcional." + "Use recursão ou funções de itertools para repetição funcional.", ) self.generic_visit(node) @@ -94,18 +121,22 @@ def visit_Assign(self, node: ast.Assign) -> None: for target in node.targets: # x[i] = y → mutação in-place if isinstance(target, ast.Subscript): - self._err(node, "FP003", + self._err( + node, + "FP003", "Atribuição por índice 'x[i] = y' detectada. " - "Retorne uma nova estrutura: dict | {k: v} ou tuple(...)." + "Retorne uma nova estrutura: dict | {k: v} ou tuple(...).", ) self.generic_visit(node) def visit_AugAssign(self, node: ast.AugAssign) -> None: # x += y em subscript → mutação if isinstance(node.target, ast.Subscript): - self._err(node, "FP003", + self._err( + node, + "FP003", "Atribuição aumentada em índice 'x[i] += y'. " - "Retorne uma nova estrutura em vez de modificar in-place." + "Retorne uma nova estrutura em vez de modificar in-place.", ) self.generic_visit(node) @@ -113,22 +144,27 @@ def visit_Call(self, node: ast.Call) -> None: if isinstance(node.func, ast.Attribute): # Métodos mutantes: .append(), .update(), etc. if node.func.attr in MUTATING_METHODS: - self._err(node, "FP004", + self._err( + node, + "FP004", f"Método mutante '.{node.func.attr}()' detectado. " - "Alternativas imutáveis: list + [x], dict | {{k: v}}, frozenset | {{x}}." + "Alternativas imutáveis: list + [x], dict | {{k: v}}, frozenset | {{x}}.", ) # I/O em módulo puro if node.func.attr in {"write", "read", "readline", "readlines"}: - self._err(node, "FP005", + self._err( + node, + "FP005", f"Operação de I/O '.{node.func.attr}()' em módulo puro. " - "Isole I/O no módulo io/." + "Isole I/O no módulo io/.", ) - if isinstance(node.func, ast.Name): - if node.func.id == "open": - self._err(node, "FP005", - "Chamada 'open()' em módulo puro. Isole I/O no módulo io/." - ) + if isinstance(node.func, ast.Name) and node.func.id == "open": + self._err( + node, + "FP005", + "Chamada 'open()' em módulo puro. Isole I/O no módulo io/.", + ) self.generic_visit(node) @@ -137,18 +173,22 @@ def _check_module_globals(self, tree: ast.Module) -> None: for node in ast.iter_child_nodes(tree): if isinstance(node, ast.Assign): for target in node.targets: - if isinstance(target, ast.Name): - if isinstance(node.value, (ast.List, ast.Dict, ast.Set)): - self._err(node, "FP006", - f"Estado global mutável '{target.id} = {type(node.value).__name__}' detectado. " - "Use frozenset, tuple ou NamedTuple para estruturas imutáveis no escopo do módulo." - ) + if isinstance(target, ast.Name) and isinstance( + node.value, ast.List | ast.Dict | ast.Set + ): + self._err( + node, + "FP006", + f"Estado global mutável '{target.id} = {type(node.value).__name__}' detectado. " + "Use frozenset, tuple ou NamedTuple para estruturas imutáveis no escopo do módulo.", + ) # --------------------------------------------------------------------------- # Verificador de boas práticas (todos os arquivos src/) # --------------------------------------------------------------------------- + class BestPracticesChecker(ast.NodeVisitor): """Verifica princípios de Clean Code e boas práticas Python.""" @@ -157,10 +197,14 @@ def __init__(self, filepath: str) -> None: self.issues: list[Issue] = [] def _err(self, node: ast.AST, rule: str, msg: str) -> None: - self.issues.append(Issue(self.filepath, getattr(node, "lineno", 0), "ERROR", rule, msg)) + self.issues.append( + Issue(self.filepath, getattr(node, "lineno", 0), "ERROR", rule, msg) + ) def _warn(self, node: ast.AST, rule: str, msg: str) -> None: - self.issues.append(Issue(self.filepath, getattr(node, "lineno", 0), "WARNING", rule, msg)) + self.issues.append( + Issue(self.filepath, getattr(node, "lineno", 0), "WARNING", rule, msg) + ) def _check_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: is_private = node.name.startswith("_") @@ -168,35 +212,43 @@ def _check_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: # Anotação de retorno obrigatória em funções públicas if node.returns is None and not is_dunder: - self._warn(node, "BP001", + self._warn( + node, + "BP001", f"Função '{node.name}' sem anotação de retorno '-> tipo'. " - "Anotações são obrigatórias para type checking com mypy --strict." + "Anotações são obrigatórias para type checking com mypy --strict.", ) # Comprimento da função end = getattr(node, "end_lineno", node.lineno) length = end - node.lineno if length > MAX_FUNCTION_LINES: - self._warn(node, "BP002", + self._warn( + node, + "BP002", f"Função '{node.name}' tem {length} linhas (máximo: {MAX_FUNCTION_LINES}). " - "Funções longas violam Clean Code — divida em funções menores e bem nomeadas." + "Funções longas violam Clean Code — divida em funções menores e bem nomeadas.", ) # Número de parâmetros n_args = len(node.args.args) + len(node.args.posonlyargs) if n_args > MAX_PARAMETERS: - self._warn(node, "BP003", + self._warn( + node, + "BP003", f"Função '{node.name}' tem {n_args} parâmetros (máximo: {MAX_PARAMETERS}). " - "Considere agrupar parâmetros em NamedTuple ou dict de configuração." + "Considere agrupar parâmetros em NamedTuple ou dict de configuração.", ) # Anotações nos parâmetros (funções públicas não privadas) if not is_private: for arg in node.args.args: if arg.annotation is None and arg.arg != "self": - self._warn(node, "BP004", + self._warn( + node, + "BP004", f"Parâmetro '{arg.arg}' em '{node.name}' sem anotação de tipo. " - "Use anotações em todas as funções públicas." + "Use anotações em todas as funções públicas.", ) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: @@ -215,11 +267,13 @@ def _check_module_globals(self, tree: ast.Module) -> None: if ( isinstance(target, ast.Name) and target.id.isupper() - and isinstance(node.value, (ast.List, ast.Dict, ast.Set)) + and isinstance(node.value, ast.List | ast.Dict | ast.Set) ): - self._err(node, "BP005", + self._err( + node, + "BP005", f"Constante global mutável '{target.id}'. " - "Use frozenset{{...}} ou tuple(...) para garantir imutabilidade." + "Use frozenset{{...}} ou tuple(...) para garantir imutabilidade.", ) @@ -227,6 +281,7 @@ def _check_module_globals(self, tree: ast.Module) -> None: # Verificador de paradigma funcional (heurística de uso real) # --------------------------------------------------------------------------- + class FunctionalUsageChecker(ast.NodeVisitor): """Verifica se o código usa construções funcionais onde deveria.""" @@ -237,7 +292,9 @@ def __init__(self, filepath: str) -> None: self._has_reduce_import = False def _warn(self, node: ast.AST, rule: str, msg: str) -> None: - self.issues.append(Issue(self.filepath, getattr(node, "lineno", 0), "WARNING", rule, msg)) + self.issues.append( + Issue(self.filepath, getattr(node, "lineno", 0), "WARNING", rule, msg) + ) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: if node.module == "functools": @@ -263,7 +320,7 @@ def check_summary(self, tree: ast.Module) -> None: ast.Module(body=[], type_ignores=[]), "FP007", "Nenhuma chamada a map(), filter() ou reduce() detectada neste módulo puro. " - "Verifique se o paradigma funcional está sendo aplicado corretamente." + "Verifique se o paradigma funcional está sendo aplicado corretamente.", ) @@ -271,6 +328,7 @@ def check_summary(self, tree: ast.Module) -> None: # Entrada principal # --------------------------------------------------------------------------- + def check_file(filepath: str) -> list[Issue]: issues: list[Issue] = [] @@ -278,10 +336,22 @@ def check_file(filepath: str) -> list[Issue]: source = Path(filepath).read_text(encoding="utf-8") tree = ast.parse(source, filename=filepath) except SyntaxError as exc: - issues.append(Issue(filepath, exc.lineno or 0, "ERROR", "SYN001", f"Erro de sintaxe: {exc.msg}")) + issues.append( + Issue( + filepath, + exc.lineno or 0, + "ERROR", + "SYN001", + f"Erro de sintaxe: {exc.msg}", + ) + ) return issues except OSError as exc: - issues.append(Issue(filepath, 0, "ERROR", "SYN002", f"Não foi possível ler o arquivo: {exc}")) + issues.append( + Issue( + filepath, 0, "ERROR", "SYN002", f"Não foi possível ler o arquivo: {exc}" + ) + ) return issues # Boas práticas em todos os arquivos src/ @@ -326,7 +396,9 @@ def main() -> int: print() if errors: - print(f"❌ {len(errors)} erro(s) — commit bloqueado. Corrija as violações acima.") + print( + f"❌ {len(errors)} erro(s) — commit bloqueado. Corrija as violações acima." + ) if warnings: print(f"⚠️ {len(warnings)} aviso(s) de boas práticas — revise quando possível.") diff --git a/scripts/claude_review.py b/scripts/claude_review.py index 3ca254b..aaeae88 100644 --- a/scripts/claude_review.py +++ b/scripts/claude_review.py @@ -11,13 +11,17 @@ import sys from pathlib import Path +sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] +sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + SCRIPTS_DIR = Path(__file__).parent def _get_committed_python_files() -> list[str]: result = subprocess.run( ["git", "diff", "--name-only", "HEAD~1..HEAD", "--diff-filter=ACM"], - capture_output=True, text=True, + capture_output=True, + text=True, ) return [f for f in result.stdout.splitlines() if f.endswith(".py")] @@ -30,8 +34,10 @@ def main() -> int: checker = SCRIPTS_DIR / "check_paradigm.py" result = subprocess.run( - [sys.executable, str(checker)] + files, - capture_output=True, text=True, + [sys.executable, str(checker), *files], + capture_output=True, + text=True, + encoding="utf-8", ) output = result.stdout.strip() @@ -50,14 +56,14 @@ def main() -> int: print("-" * 60) if has_errors: - print(f"STATUS: FAIL — violacoes criticas bloqueiam o push.") + print("STATUS: FAIL — violacoes criticas bloqueiam o push.") print(f"Arquivos revisados: {', '.join(files)}") return 1 if has_warnings: - print(f"STATUS: WARN — avisos encontrados, revise quando possivel.") + print("STATUS: WARN — avisos encontrados, revise quando possivel.") else: - print(f"STATUS: PASS — todos os arquivos conformes com o paradigma.") + print("STATUS: PASS — todos os arquivos conformes com o paradigma.") print(f"Arquivos revisados: {', '.join(files)}") return 0 diff --git a/scripts/claudinho.py b/scripts/claudinho.py new file mode 100644 index 0000000..a9b989a --- /dev/null +++ b/scripts/claudinho.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Claudinho — gerador de informes engraçados para o grupo do projeto. + +Uso: + python scripts/claudinho.py "descrição técnica do informe" + +Exemplo: + python scripts/claudinho.py "adicionamos hook de conventional commits no pre-commit" +""" + +import os +import sys + +import anthropic +from dotenv import load_dotenv + +load_dotenv() + +SYSTEM_PROMPT = """Você é o Claudinho, a IA mascote de um grupo de faculdade de Engenharia de Software. +Você escreve informes para o grupo do WhatsApp/Discord do projeto sobre atualizações técnicas do repositório. + +Seu estilo: +- Descontraído, engraçado, às vezes dramático — como se a atualização fosse um evento histórico +- Usa gírias de programador e de faculdade com moderação (não exagera) +- Pode usar emojis mas sem exagero (2-4 por mensagem) +- Sempre explica o que a pessoa precisa FAZER de forma clara, mesmo no meio da zueira +- Termina SEMPRE assinando como "— Claudinho 🤖" +- Escreve em português brasileiro informal +- Mensagens curtas e diretas — no máximo 15 linhas +- Nunca usa bullet points chatos, prefere texto corrido ou poucos itens numerados +- Nunca menciona que é uma IA ou que foi gerado automaticamente""" + + +def gerar_informe(descricao: str) -> str: + api_key = os.getenv("ANTHROPIC_API_KEY") + if not api_key: + raise RuntimeError("ANTHROPIC_API_KEY não encontrada no .env") + + client = anthropic.Anthropic(api_key=api_key) + + message = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=512, + system=SYSTEM_PROMPT, + messages=[ + { + "role": "user", + "content": f"Gera um informe para o grupo sobre: {descricao}", + } + ], + ) + + return str(message.content[0].text) + + +def main() -> None: + if len(sys.argv) < 2: + print('Uso: python scripts/claudinho.py "descrição do informe"') + sys.exit(1) + + descricao = " ".join(sys.argv[1:]) + informe = gerar_informe(descricao) + print("\n" + informe + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_pipeline.py b/scripts/run_pipeline.py new file mode 100644 index 0000000..73cf9ef --- /dev/null +++ b/scripts/run_pipeline.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""CLI para testar o pipeline completo (dataset → LLM → JSON) sem depender da UI. + +Suporta dois formatos de entrada: + - CSV com colunas PRRecord (pr_id, repo_name, language, title, body, ...) + - JSON mined-comments (formato {repo: [{id, path, body, ...}, ...]}) + +Uso: + python scripts/run_pipeline.py [limit] + +Exemplos: + LLM_BACKEND=ollama python scripts/run_pipeline.py data/archive/.../Python.json out.json 5 + LLM_BACKEND=ollama LLM_MODEL=mistral python scripts/run_pipeline.py data.csv out.json 20 + +Variáveis de ambiente: + LLM_BACKEND groq (padrão) | ollama + LLM_MODEL modelo (padrão: llama3-8b-8192 para Groq / llama3 para Ollama) +""" + +import json +import sys +from collections.abc import Generator +from pathlib import Path + +import ijson +from dotenv import load_dotenv + +load_dotenv() + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from pr_analyzer.cache.memo import make_enriched_classifier # noqa: E402 +from pr_analyzer.io.csv_reader import PRRecord, read_prs # noqa: E402 +from pr_analyzer.llm.classifiers import ( # noqa: E402 + avaliar_clareza_descricao, + classificar_natureza_contribuicao, + classificar_tipo_projeto, +) +from pr_analyzer.llm.client import create_llm_client # noqa: E402 +from pr_analyzer.pipeline.builder import enrich_pipeline # noqa: E402 + + +def _language_from_path(filepath: str) -> str: + name = Path(filepath).stem.lower() + for lang in ("python", "java", "javascript", "typescript", "go"): + if lang in name: + return lang + return "unknown" + + +def read_mined_comments(filepath: str, limit: int) -> Generator[PRRecord, None, None]: + """Lê o formato mined-comments via streaming (ijson) — não carrega o arquivo inteiro.""" + language = _language_from_path(filepath) + count = 0 + with open(filepath, "rb") as f: + # itera sobre cada item de cada array: "repo_name.item" + for repo_name, comment in ijson.kvitems(f, ""): + for c in comment: + if count >= limit: + return + body = str(c.get("body") or "").strip() + title = str(c.get("path") or repo_name).strip() + yield PRRecord( + pr_id=int(c.get("id", 0)) or None, + repo_name=repo_name, + language=language, + title=title, + body=body, + state="merged", + created_at="", + merged_at="", + additions=None, + deletions=None, + changed_files=None, + ) + count += 1 + + +def load_prs(filepath: str, limit: int) -> list[PRRecord]: + if filepath.endswith(".json"): + print(f" Formato: mined-comments JSON (streaming, lendo {limit} registros...)") + return list(read_mined_comments(filepath, limit)) + print(f" Formato: CSV PRRecord (lendo {limit} registros...)") + return list(read_prs(filepath))[:limit] + + +def main(dataset_path: str, output_path: str, limit: int = 10) -> None: + print(f"Carregando dados de '{dataset_path}'...") + prs = load_prs(dataset_path, limit) + print(f" {len(prs)} registros carregados.") + + print("Criando cliente LLM...") + client = create_llm_client() + + classify_fn = make_enriched_classifier( + lambda repo, title: classificar_tipo_projeto(repo, [title], client), + lambda title, body: classificar_natureza_contribuicao(title, body, client), + lambda body: avaliar_clareza_descricao(body, client), + cache_path=Path(".cache/pipeline.json"), + ) + + print("Classificando (pode demorar na primeira vez)...") + results = list(enrich_pipeline(prs, classify_fn)) + + output = [ + { + "pr_id": r.pr.pr_id, + "repo": r.pr.repo_name, + "language": r.pr.language, + "title": r.pr.title, + "project_type": r.project_type, + "contribution_nature": r.contribution_nature, + "description_clarity": r.description_clarity, + } + for r in results + ] + + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + Path(output_path).write_text( + json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(f"✓ {len(output)} registros processados → '{output_path}'") + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Uso: python scripts/run_pipeline.py [limit=10]") + sys.exit(1) + + _limit = int(sys.argv[3]) if len(sys.argv) > 3 else 10 + main(sys.argv[1], sys.argv[2], _limit) diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..13e2a47 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,30 @@ +#!/usr/bin/env pwsh +# Alternativa ao Makefile para Windows 11 sem make instalado. +# Uso: +# .\setup.ps1 hooks — instala apenas pre-commit + git hooks (mínimo, para Docker users) +# .\setup.ps1 setup — instalação completa sem Docker (padrão se omitido) + +param([string]$Target = "setup") + +function Install-Hooks { + Write-Host "Instalando pre-commit e git hooks..." -ForegroundColor Cyan + pip install pre-commit + pre-commit install + pre-commit install --hook-type pre-push + pre-commit install --hook-type commit-msg + Write-Host "Hooks instalados!" -ForegroundColor Green +} + +if ($Target -eq "hooks") { + Install-Hooks +} +elseif ($Target -eq "setup") { + Write-Host "Instalando dependencias completas do projeto..." -ForegroundColor Cyan + pip install -e ".[dev]" + Install-Hooks + Write-Host "Setup concluido!" -ForegroundColor Green +} +else { + Write-Host "Uso: .\setup.ps1 [hooks|setup]" -ForegroundColor Yellow + exit 1 +} diff --git a/sprint-1-review.md b/sprint-1-review.md new file mode 100644 index 0000000..f0a7719 --- /dev/null +++ b/sprint-1-review.md @@ -0,0 +1,121 @@ +# Sprint 1 Review — dev4 + +**Período:** 01/05/2026 → 04/05/2026 +**Branch:** `frederico-barcelos` +**Verificação:** 04/05/2026 + +--- + +## Estrutura estabelecida + +Além das tasks de implementação, esta sprint consolidou a base do projeto inteiro: + +| Entrega | O que é | +|---|---| +| `Dockerfile` + `docker-compose.yml` | Ambiente Python 3.11-slim idêntico para todos os devs | +| `.pre-commit-config.yaml` | Hooks automáticos: ruff, mypy, paradigma funcional, cobertura, revisão local | +| `scripts/check_paradigm.py` | Verificador AST — bloqueia loops e mutações em módulos puros no commit | +| `scripts/claude_review.py` | Revisão de código local no pre-push sem custo de API | +| `pyproject.toml` | Build backend corrigido, `pythonpath = ["src"]` no pytest | +| `CLAUDE.md` | Especificações completas lidas automaticamente pelo Claude Code | +| `.claude/local/dev4.md` | Contexto pessoal do dev4 por sprint (gitignored) | +| `src/pr_analyzer/ui/app.py` | Placeholder Streamlit para o container subir | +| `README.md` | Instruções de setup e execução do projeto | +| `plano/fase-1.md` a `fase-4.md` | 50 tasks divididas por dev e fase | + +Correções encontradas durante a sprint: +- `.gitignore`: `cache/` → `/cache/` (estava ignorando `src/pr_analyzer/cache/`) +- `pyproject.toml`: build backend `setuptools.backends.legacy` não existe no Python 3.11-slim + +--- + +## Tasks implementadas + +### TASK-10 — `make_cache_key` ✅ + +**Arquivo:** `src/pr_analyzer/cache/memo.py` +**Testes:** `tests/test_cache/test_memo.py` (5 testes) + +Função pura que gera chave SHA-256 determinística a partir de argumentos variáveis. + +Detalhe encontrado na refatoração: separador `:` causava colisão entre argumentos +(`make_cache_key("a:b", "c") == make_cache_key("a", "b:c")`). Corrigido com separador +nulo `\x00`, adicionando um 5º teste que detecta e previne regressão. + +```python +make_cache_key("123", "llama3") # → "b0ee04f880c4ff42" +make_cache_key("456", "llama3") # → hash diferente +``` + +**Cobertura:** 100% (branch coverage) + +--- + +### TASK-11 — `cached_classify` ✅ + +**Arquivo:** `src/pr_analyzer/cache/memo.py` +**Testes:** `tests/test_cache/test_cached_classify.py` (4 testes) + +HOF que envolve qualquer função classificadora evitando chamadas repetidas. +LRU manual via `OrderedDict` com limite configurável. Persistência opcional em JSON. + +```python +wrapped = cached_classify(groq_classify, cache_size=1024, cache_path=Path("cache/llm.json")) +wrapped("repo", "Fix bug") # chama groq_classify +wrapped("repo", "Fix bug") # retorna do cache — groq não é chamado +``` + +Sobrevive a reinicialização do processo se `cache_path` for fornecido. + +**Cobertura:** 100% (branch coverage) + +--- + +### TASK-12 — Esqueleto do pipeline builder ✅ + +**Arquivo:** `src/pr_analyzer/pipeline/builder.py` +**Testes:** `tests/test_pipeline/test_builder.py` (6 testes) + +Assinaturas completas com tipos para `compose()`, `pipe()` e `build_pipeline()`. +Implementações levantam `NotImplementedError` — serão desenvolvidas na sprint 2. + +```python +def compose(*fns: Callable) -> Callable: ... +def pipe(value: T, *fns: Callable) -> T: ... +def build_pipeline(*steps: Callable) -> Callable: ... +``` + +--- + +## Cobertura de testes + +``` +src/pr_analyzer/cache/memo.py 100% (branch coverage) +src/pr_analyzer/pipeline/builder.py — (stubs, implementação na sprint 2) +``` + +Meta da sprint 1: módulo do dev funciona ✅ + +--- + +## Vale a pena QA por outro dev? + +**TASK-10 e TASK-11 — sim, QA faz sentido.** + +Os testes cobrem 100% do código e os casos de borda relevantes (colisão de chave, +persistência entre instâncias). Mas um segundo par de olhos pode agregar ao revisar: + +- Se o comportamento de eviction do LRU está correto quando `cache_size` é atingido +- Se a persistência JSON é suficiente ou se deveria ser atômica (write + rename) +- Se `cache_path=None` como padrão é uma boa API ou deveria sempre persistir + +Esses são julgamentos de design que os testes não capturam. + +**TASK-12 — não precisa de QA agora.** + +É apenas um esqueleto de interface. O QA real acontece na sprint 2, quando +`compose()` e `build_pipeline()` tiverem implementação de verdade e os testes +testarem comportamento, não só `NotImplementedError`. + +**Recomendação:** mover TASK-10 e TASK-11 para **Done com revisão** e TASK-12 +para **Done** direto — ela só estará realmente pronta ao fim da sprint 2. diff --git a/src/pr_analyzer/cache/__init__.py b/src/pr_analyzer/cache/__init__.py new file mode 100644 index 0000000..30c65b3 --- /dev/null +++ b/src/pr_analyzer/cache/__init__.py @@ -0,0 +1 @@ +"""Memoização de chamadas ao LLM via hashlib + lru_cache.""" diff --git a/src/pr_analyzer/cache/memo.py b/src/pr_analyzer/cache/memo.py new file mode 100644 index 0000000..f574c15 --- /dev/null +++ b/src/pr_analyzer/cache/memo.py @@ -0,0 +1,89 @@ +import hashlib +import json +from collections import OrderedDict +from collections.abc import Callable +from functools import wraps +from pathlib import Path + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.pipeline.builder import EnrichedPR + +_SEP = "\x00" + + +def make_cache_key(*args: str) -> str: + raw = _SEP.join(args) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def cached_classify( + classifier_fn: Callable[..., str], + cache_size: int = 1024, + cache_path: Path | None = None, +) -> Callable[..., str]: + store: OrderedDict[str, str] = OrderedDict() + + if cache_path and cache_path.exists(): + store.update(json.loads(cache_path.read_text(encoding="utf-8"))) + + @wraps(classifier_fn) + def wrapper(*args: str) -> str: + key = make_cache_key(*args) + if key in store: + store.move_to_end(key) + return store[key] + + result = classifier_fn(*args) + store[key] = result + + if len(store) > cache_size: + store.popitem(last=False) + + if cache_path: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(dict(store), indent=2), encoding="utf-8") + + return result + + return wrapper + + +def _derive_cache_path(base: Path | None, tag: str) -> Path | None: + if base is None: + return None + return base.with_name(f"{base.stem}_{tag}{base.suffix}") + + +def make_enriched_classifier( + classify_type_fn: Callable[..., str], + classify_nature_fn: Callable[..., str], + classify_clarity_fn: Callable[..., str], + cache_path: Path | None = None, + cache_size: int = 1024, +) -> Callable[[PRRecord], EnrichedPR]: + """Envolve três classificadores com cache e retorna uma função para enrich_pipeline.""" + cached_type = cached_classify( + classify_type_fn, + cache_size=cache_size, + cache_path=_derive_cache_path(cache_path, "type"), + ) + cached_nature = cached_classify( + classify_nature_fn, + cache_size=cache_size, + cache_path=_derive_cache_path(cache_path, "nature"), + ) + cached_clarity = cached_classify( + classify_clarity_fn, + cache_size=cache_size, + cache_path=_derive_cache_path(cache_path, "clarity"), + ) + + def _classify(pr: PRRecord) -> EnrichedPR: + return EnrichedPR( + pr=pr, + project_type=cached_type(pr.repo_name, pr.title), + contribution_nature=cached_nature(pr.title, pr.body[:300]), + description_clarity=cached_clarity(pr.body[:500]), + ) + + return _classify diff --git a/src/pr_analyzer/io/__init__.py b/src/pr_analyzer/io/__init__.py index a0f1406..97ba96f 100644 --- a/src/pr_analyzer/io/__init__.py +++ b/src/pr_analyzer/io/__init__.py @@ -1 +1,25 @@ """I/O layer: leitura lazy de datasets e exportação de resultados.""" + +from pr_analyzer.io.csv_reader import ( + PRRecord, + apply_schema, + detect_schema, + is_valid_row, + read_csv_lazy, + read_prs, + schema_adapter, +) +from pr_analyzer.io.exporters import export_to_csv, export_to_json, serialize_records + +__all__ = ( + "PRRecord", + "apply_schema", + "detect_schema", + "export_to_csv", + "export_to_json", + "is_valid_row", + "read_csv_lazy", + "read_prs", + "schema_adapter", + "serialize_records", +) diff --git a/src/pr_analyzer/io/csv_reader.py b/src/pr_analyzer/io/csv_reader.py new file mode 100644 index 0000000..35a6873 --- /dev/null +++ b/src/pr_analyzer/io/csv_reader.py @@ -0,0 +1,173 @@ +"""Leitores CSV e normalizacao de schema para Pull Requests do GitHub.""" + +import csv +from collections.abc import Callable, Generator, Iterable, Mapping +from os import PathLike +from typing import NamedTuple + +FilePath = str | PathLike[str] + +CAMPOS_CANONICOS = ( + "pr_id", + "repo_name", + "language", + "title", + "body", + "state", + "created_at", + "merged_at", + "additions", + "deletions", + "changed_files", +) + +CAMPOS_OBRIGATORIOS = ("pr_id", "repo_name", "language", "title", "state") +CAMPOS_INTEIROS = ("pr_id", "additions", "deletions", "changed_files") + +MAPEAMENTO_CANONICO = tuple((campo, campo) for campo in CAMPOS_CANONICOS) +MAPEAMENTO_GITHUB_EXPORT = ( + ("pr_id", "number"), + ("repo_name", "repository"), + ("language", "primary_language"), + ("title", "title"), + ("body", "description"), + ("state", "status"), + ("created_at", "created"), + ("merged_at", "merged"), + ("additions", "additions"), + ("deletions", "deletions"), + ("changed_files", "files_changed"), +) +SCHEMAS_CONHECIDOS = ( + ("canonical", MAPEAMENTO_CANONICO), + ("github_export", MAPEAMENTO_GITHUB_EXPORT), +) + + +class PRRecord(NamedTuple): + """Registro imutavel de Pull Request normalizado a partir do CSV.""" + + pr_id: int | None + repo_name: str + language: str + title: str + body: str + state: str + created_at: str + merged_at: str + additions: int | None + deletions: int | None + changed_files: int | None + + +def _text(raw_row: Mapping[str, object], field: str) -> str: + value = raw_row.get(field, "") + return "" if value is None else str(value).strip() + + +def _is_integer_text(value: str) -> bool: + stripped = value.strip() + return bool(stripped) and stripped.lstrip("+-").isdigit() + + +def _integer(raw_row: Mapping[str, object], field: str) -> int | None: + value = _text(raw_row, field) + return int(value) if _is_integer_text(value) else None + + +def _normalized_header(header: Iterable[str | None]) -> frozenset[str]: + return frozenset(str(field).strip().lower() for field in header if field) + + +def _schema_fields(mapping: tuple[tuple[str, str], ...]) -> frozenset[str]: + return frozenset(source for _, source in mapping) + + +def detect_schema(header: Iterable[str | None]) -> str: + """Identifica o schema do CSV a partir do cabecalho.""" + fields = _normalized_header(header) + matched = tuple( + schema_name + for schema_name, mapping in SCHEMAS_CONHECIDOS + if _schema_fields(mapping).issubset(fields) + ) + return matched[0] if matched else "unknown" + + +def _mapping_for_schema(schema_name: str) -> tuple[tuple[str, str], ...]: + matched = tuple( + mapping + for known_name, mapping in SCHEMAS_CONHECIDOS + if known_name == schema_name + ) + if matched: + return matched[0] + msg = f"schema desconhecido: {schema_name}" + raise ValueError(msg) + + +def schema_adapter( + schema_name: str, +) -> Callable[[Mapping[str, object]], dict[str, object]]: + """Retorna uma funcao que converte uma linha para os campos canonicos.""" + mapping = _mapping_for_schema(schema_name) + return lambda row: {target: row.get(source, "") for target, source in mapping} + + +def is_valid_row(row: Mapping[str, object]) -> bool: + """Valida campos obrigatorios e inteiros antes da normalizacao final.""" + has_required_text = all(_text(row, field) for field in CAMPOS_OBRIGATORIOS) + has_valid_integers = all( + _is_integer_text(_text(row, field)) for field in CAMPOS_INTEIROS + ) + return has_required_text and has_valid_integers + + +def read_csv_lazy( + filepath: FilePath, + encoding: str = "utf-8", +) -> Generator[dict[str, str], None, None]: + """Produz linhas brutas do CSV sem carregar o arquivo inteiro em memoria.""" + with open(filepath, encoding=encoding, newline="") as csv_file: + yield from csv.DictReader(csv_file) + + +def apply_schema(raw_row: Mapping[str, object]) -> PRRecord: + """Converte uma linha canonica em um PRRecord imutavel e tipado.""" + return PRRecord( + pr_id=_integer(raw_row, "pr_id"), + repo_name=_text(raw_row, "repo_name"), + language=_text(raw_row, "language").lower(), + title=_text(raw_row, "title"), + body=_text(raw_row, "body"), + state=_text(raw_row, "state").lower(), + created_at=_text(raw_row, "created_at"), + merged_at=_text(raw_row, "merged_at"), + additions=_integer(raw_row, "additions"), + deletions=_integer(raw_row, "deletions"), + changed_files=_integer(raw_row, "changed_files"), + ) + + +def _read_adapted_rows( + filepath: FilePath, + encoding: str, +) -> Generator[dict[str, object], None, None]: + with open(filepath, encoding=encoding, newline="") as csv_file: + reader = csv.DictReader(csv_file) + schema_name = detect_schema(reader.fieldnames or ()) + if schema_name == "unknown": + return + adapter = schema_adapter(schema_name) + yield from map(adapter, reader) + + +def read_prs( + filepath: FilePath, + encoding: str = "utf-8", +) -> Generator[PRRecord, None, None]: + """Produz PRRecords validos e normalizados a partir de um CSV.""" + return ( + apply_schema(row) + for row in filter(is_valid_row, _read_adapted_rows(filepath, encoding)) + ) diff --git a/src/pr_analyzer/io/exporters.py b/src/pr_analyzer/io/exporters.py new file mode 100644 index 0000000..b3f05aa --- /dev/null +++ b/src/pr_analyzer/io/exporters.py @@ -0,0 +1,49 @@ +"""Exportadores de registros de Pull Requests para arquivos serializados.""" + +import csv +import json +from collections.abc import Iterable, Mapping +from itertools import chain +from os import PathLike +from typing import Any + +FilePath = str | PathLike[str] + + +def _serialize_record(record: Any) -> dict[str, Any]: + if isinstance(record, Mapping): + return dict(record) + if hasattr(record, "_asdict"): + return dict(record._asdict()) + msg = "registro deve ser Mapping ou objeto compatível com NamedTuple" + raise TypeError(msg) + + +def serialize_records(records: Iterable[Any]) -> list[dict[str, Any]]: + """Converte mappings ou NamedTuples em dicionários serializáveis.""" + return list(map(_serialize_record, records)) + + +def _fieldnames(records: list[dict[str, Any]]) -> tuple[str, ...]: + return tuple(dict.fromkeys(chain.from_iterable(record.keys() for record in records))) + + +def export_to_csv(records: Iterable[Any], filepath: FilePath) -> None: + """Escreve registros em CSV após serializá-los como dicionários.""" + serialized = serialize_records(records) + + with open(filepath, "w", encoding="utf-8", newline="") as csv_file: + fields = _fieldnames(serialized) + if not fields: + return + writer = csv.DictWriter(csv_file, fieldnames=fields, extrasaction="ignore") + writer.writeheader() + writer.writerows(serialized) + + +def export_to_json(records: Iterable[Any], filepath: FilePath) -> None: + """Escreve registros em JSON após serializá-los como dicionários.""" + serialized = serialize_records(records) + + with open(filepath, "w", encoding="utf-8") as json_file: + json.dump(serialized, json_file, ensure_ascii=False, indent=2) diff --git a/src/pr_analyzer/llm/classifiers.py b/src/pr_analyzer/llm/classifiers.py new file mode 100644 index 0000000..8844806 --- /dev/null +++ b/src/pr_analyzer/llm/classifiers.py @@ -0,0 +1,243 @@ +"""Efeito colateral: classificadores semânticos de PRs via LLM. + +Fase 1 — stubs com interface completa. +Fase 2 — substituir stubs por chamadas LLM reais. +Fase 3 — batch por repositório e enriquecimento lazy com cache. +""" + +import json +import re +from collections.abc import Iterable +from pathlib import Path + +from pr_analyzer.cache.memo import make_enriched_classifier +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.llm.client import LLMClient +from pr_analyzer.transforms.reducers import EnrichedPR + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_CODE_BLOCK = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) + +_CLARITY_EN_TO_PT: dict[str, str] = { + "insufficient": "insuficiente", + "basic": "básica", + "good": "boa", + "excellent": "excelente", +} + + +def _extract_json(text: str) -> dict[str, str]: + """Parse JSON from model response, stripping markdown code blocks if present.""" + raw = text.strip() + match = _CODE_BLOCK.search(raw) + if match: + raw = match.group(1).strip() + return json.loads(raw) # type: ignore[no-any-return] + + +# ── Valores válidos de cada classificação ───────────────────────────────────── + +TIPOS_PROJETO: frozenset[str] = frozenset( + { + "biblioteca", + "aplicação web", + "framework", + "ferramenta", + "outro", + } +) + +NATUREZAS_CONTRIBUICAO: frozenset[str] = frozenset( + { + "bug fix", + "feature", + "refatoração", + "documentação", + "outro", + } +) + +NIVEIS_CLAREZA_DESCRICAO: frozenset[str] = frozenset( + { + "insuficiente", + "básica", + "boa", + "excelente", + } +) + + +# ── Classificadores ─────────────────────────────────────────────────────────── + + +def classificar_tipo_projeto( + nome_repositorio: str, + titulos_amostra: list[str], + cliente: LLMClient, +) -> str: + """Classifica o tipo de projeto de um repositório. + + Args: + nome_repositorio: nome do repositório (ex: "django/django"). + titulos_amostra: amostra de títulos de PRs do repositório. + cliente: cliente LLM para chamada semântica. + + Returns: + Uma string pertencente a TIPOS_PROJETO. + """ + prompt = f"Repositório: {nome_repositorio}. Títulos de PR: {titulos_amostra}.\n" + prompt += 'Responda estritamente em formato JSON: {"tipo_projeto": "..."}. ' + prompt += f"Escolha uma das seguintes opções: {', '.join(TIPOS_PROJETO)}." + + try: + response = cliente.run(prompt) + data = _extract_json(str(response.content)) + result = str(data.get("tipo_projeto", "")).lower() + if result in TIPOS_PROJETO: + return result + except Exception: + pass + + return "outro" + + +def classificar_natureza_contribuicao( + titulo: str, + corpo: str, + cliente: LLMClient, +) -> str: + """Classifica a natureza da contribuição de um PR. + + Args: + titulo: título do PR. + corpo: primeiros 300 caracteres do corpo do PR. + cliente: cliente LLM para chamada semântica. + + Returns: + Uma string pertencente a NATUREZAS_CONTRIBUICAO. + """ + corpo_cortado = corpo[:300] + prompt = f"Título do PR: {titulo}\nCorpo: {corpo_cortado}\n" + prompt += 'Responda estritamente em formato JSON: {"natureza": "..."}. ' + prompt += f"Escolha uma das seguintes opções: {', '.join(NATUREZAS_CONTRIBUICAO)}." + + try: + response = cliente.run(prompt) + data = _extract_json(str(response.content)) + result = str(data.get("natureza", "")).lower() + if result in NATUREZAS_CONTRIBUICAO: + return result + except Exception: + pass + + return "outro" + + +def avaliar_clareza_descricao( + corpo: str, + cliente: LLMClient, +) -> str: + """Avalia a clareza da descrição de um PR. + + Body vazio deve retornar "insuficiente" sem chamar o LLM (Fase 2). + + Args: + corpo: primeiros 500 caracteres do corpo do PR. + cliente: cliente LLM para chamada semântica. + + Returns: + Uma string pertencente a NIVEIS_CLAREZA_DESCRICAO. + """ + if not corpo.strip(): + return "insuficiente" + + corpo_cortado = corpo[:500] + opts = ", ".join(sorted(NIVEIS_CLAREZA_DESCRICAO)) + prompt = f"Avalie a clareza deste corpo de PR:\n{corpo_cortado}\n" + prompt += 'Responda APENAS em JSON: {"clareza": "..."}. ' + prompt += f"Opções válidas: {opts}." + + try: + response = cliente.run(prompt) + data = _extract_json(str(response.content)) + result = str(data.get("clareza", "")).strip().lower() + if result in NIVEIS_CLAREZA_DESCRICAO: + return result + # accept English equivalents (common with Ollama models) + mapped = _CLARITY_EN_TO_PT.get(result) + if mapped: + return mapped + except Exception: + pass + + return "insuficiente" + + +# ── Batch e enriquecimento (Fase 3) ─────────────────────────────────────────── + + +def classify_repos_batch( + groups: dict[str, list[PRRecord]], + client: LLMClient, +) -> dict[str, str]: + """Classifica o tipo de projeto de cada repositório com 1 chamada LLM por repo. + + Recebe o resultado de group_by_repo() e envia os títulos dos PRs de cada + grupo como amostra para o classificador, evitando chamadas redundantes por PR. + + Args: + groups: dicionário repo_name -> lista de PRs do repositório. + client: cliente LLM para chamadas semânticas. + + Returns: + Dicionário repo_name -> tipo_projeto. + """ + + def _classify_repo(item: tuple[str, list[PRRecord]]) -> tuple[str, str]: + repo_name, prs = item + titles = [pr.title for pr in prs] + return (repo_name, classificar_tipo_projeto(repo_name, titles, client)) + + return dict(map(_classify_repo, groups.items())) + + +def enrich_prs( + prs: Iterable[PRRecord], + client: LLMClient, + cache_path: Path | None = None, +) -> Iterable[EnrichedPR]: + """Aplica os 3 classificadores a cada PR via map(), retornando EnrichedPRs lazy. + + Usa make_enriched_classifier para envolver os classificadores com cache + em memória e em disco. A avaliação é lazy: o LLM só é chamado ao consumir + o iterável retornado. + + Args: + prs: iterável de PRRecords a enriquecer. + client: cliente LLM para chamadas semânticas. + cache_path: caminho base para persistência do cache em disco (opcional). + + Returns: + Iterável lazy de EnrichedPR. + """ + + def type_fn(repo: str, title: str) -> str: + return classificar_tipo_projeto(repo, [title], client) + + def nature_fn(title: str, body: str) -> str: + return classificar_natureza_contribuicao(title, body, client) + + def clarity_fn(body: str) -> str: + return avaliar_clareza_descricao(body, client) + + classify = make_enriched_classifier( + type_fn, nature_fn, clarity_fn, cache_path=cache_path + ) + return map(classify, prs) + + +# ── English aliases (consumed by pipeline_bridge and external modules) ──────── +classify_project_type = classificar_tipo_projeto +classify_contribution_nature = classificar_natureza_contribuicao +classify_description_clarity = avaliar_clareza_descricao diff --git a/src/pr_analyzer/llm/client.py b/src/pr_analyzer/llm/client.py new file mode 100644 index 0000000..1cd8942 --- /dev/null +++ b/src/pr_analyzer/llm/client.py @@ -0,0 +1,59 @@ +"""Efeito colateral: configuração do cliente LLM via Agno/Groq.""" + +import os +from typing import Any, Protocol + +from agno.agent import Agent +from agno.models.groq import Groq + + +class LLMClient(Protocol): + """Interface mínima que qualquer cliente LLM deve satisfazer. + + Usando Protocol em vez de Agent diretamente para facilitar mocks + nos testes e desacoplar os classificadores da implementação concreta. + """ + + def run(self, message: str, **kwargs: Any) -> Any: + """Envia uma mensagem ao LLM e retorna a resposta.""" + ... + + +def create_groq_client() -> LLMClient: + """Cria e retorna agente Agno configurado com Groq. + + Lê GROQ_API_KEY e LLM_MODEL do ambiente. Carregue o .env antes + de chamar esta função (ex: via python-dotenv na inicialização da UI). + + Raises: + KeyError: se GROQ_API_KEY não estiver definido no ambiente. + """ + api_key = os.environ["GROQ_API_KEY"] + model_id = os.environ.get("LLM_MODEL", "llama3-8b-8192") + return Agent(model=Groq(id=model_id, api_key=api_key)) # type: ignore[no-any-return] + + +def create_ollama_client(model: str = "llama3") -> LLMClient: + """Cria agente Agno apontando para Ollama (host configurável via OLLAMA_HOST). + + Dentro do Docker no Linux, defina OLLAMA_HOST=http://host.docker.internal:11434. + Localmente, o padrão http://localhost:11434 já funciona. + """ + from agno.models.ollama import Ollama # import tardio — dependência opcional + + host = os.environ.get("OLLAMA_HOST", "http://localhost:11434") + return Agent(model=Ollama(id=model, host=host)) # type: ignore[no-any-return] + + +def create_llm_client() -> LLMClient: + """Retorna cliente Groq ou Ollama com base em LLM_BACKEND no ambiente. + + LLM_BACKEND=groq (padrão) → Groq API, requer GROQ_API_KEY + LLM_BACKEND=ollama → Ollama local, requer Ollama rodando + LLM_MODEL sobrescreve o modelo padrão de cada backend. + """ + backend = os.environ.get("LLM_BACKEND", "groq").lower() + model = os.environ.get("LLM_MODEL", "") + if backend == "ollama": + return create_ollama_client(model or "llama3") + return create_groq_client() diff --git a/src/pr_analyzer/pipeline/builder.py b/src/pr_analyzer/pipeline/builder.py new file mode 100644 index 0000000..734161a --- /dev/null +++ b/src/pr_analyzer/pipeline/builder.py @@ -0,0 +1,85 @@ +import os +from collections.abc import Callable, Iterable +from functools import reduce +from typing import Any, TypeVar + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.transforms.filters import by_state, with_min_size +from pr_analyzer.transforms.mappers import PRStats, compute_stats +from pr_analyzer.transforms.reducers import EnrichedPR + +__all__: tuple[str, ...] = ( + "EnrichedPR", + "compose", + "pipe", + "enrich_pipeline", + "stats_pipeline", + "build_pipeline", + "pipeline_from_env", +) + +T = TypeVar("T") + + +def compose(*fns: Callable[..., Any]) -> Callable[..., Any]: + """compose(f, g, h)(x) == h(g(f(x))). Sem args retorna identidade.""" + if not fns: + return lambda x: x + return reduce(lambda f, g: lambda x: g(f(x)), fns) + + +def pipe(value: T, *fns: Callable[..., Any]) -> T: + """pipe(x, f, g, h) == h(g(f(x))). Sem fns retorna value.""" + return reduce(lambda acc, f: f(acc), fns, value) + + +def enrich_pipeline( + prs: Iterable[PRRecord], + classify_fn: Callable[[PRRecord], EnrichedPR], +) -> Iterable[EnrichedPR]: + """Aplica classify_fn sobre cada PR de forma lazy, retornando EnrichedPRs.""" + return map(classify_fn, prs) + + +def stats_pipeline(prs: Iterable[PRRecord]) -> Iterable[PRStats]: + """Mapeia compute_stats sobre cada PR de forma lazy.""" + return map(compute_stats, prs) + + +def build_pipeline( + source: Iterable[Any], + filters: tuple[Callable[..., Any], ...] = (), + mappers: tuple[Callable[..., Any], ...] = (), +) -> Iterable[Any]: + """Pipeline lazy: aplica filters com filter() e mappers com map() sobre source.""" + filtered = reduce(lambda s, f: filter(f, s), filters, source) + return reduce(lambda s, m: map(m, s), mappers, filtered) + + +def pipeline_from_env( + source: Iterable[PRRecord], + classify_fn: Callable[[PRRecord], EnrichedPR] | None = None, +) -> Iterable[Any]: + """Pipeline lazy configurado por variáveis de ambiente. + + FILTER_STATE: filtra PRs pelo estado (ex: "merged", "open"). Padrão: sem filtro. + MIN_CHANGES: mínimo de alterações (additions + deletions). Padrão: 0. + ENABLE_LLM: se "true" e classify_fn for fornecido, aplica enriquecimento LLM. + """ + state = os.environ.get("FILTER_STATE", "").strip().lower() + min_ch = int(os.environ.get("MIN_CHANGES", "0") or "0") + enable_llm = os.environ.get("ENABLE_LLM", "false").lower() == "true" + + state_filter: tuple[Callable[..., Any], ...] = (by_state(state),) if state else () + size_filter: tuple[Callable[..., Any], ...] = ( + (with_min_size(min_ch),) if min_ch > 0 else () + ) + llm_mapper: tuple[Callable[..., Any], ...] = ( + (classify_fn,) if enable_llm and classify_fn is not None else () + ) + + return build_pipeline( + source, + filters=state_filter + size_filter, + mappers=llm_mapper, + ) diff --git a/src/pr_analyzer/transforms/__init__.py b/src/pr_analyzer/transforms/__init__.py index d318af6..4eb9a2c 100644 --- a/src/pr_analyzer/transforms/__init__.py +++ b/src/pr_analyzer/transforms/__init__.py @@ -1 +1,42 @@ -"""Funções puras de transformação: filters, mappers e reducers.""" +"""Funções de transformação puras: filtros, mapeadores e redutores.""" + +from pr_analyzer.transforms.filters import ( + by_date_range, + by_language, + by_state, + combine_filters, + with_min_size, + with_non_empty_body, +) +from pr_analyzer.transforms.mappers import PRStats, compute_stats +from pr_analyzer.transforms.reducers import ( + EnrichedPR, + accumulate_stats, + aggregate_stats, + count_by_contribution_nature, + count_by_description_clarity, + count_by_field, + count_by_language, + count_by_project_type, + group_by_repo, +) + +__all__ = ( + "by_state", + "by_language", + "by_date_range", + "with_non_empty_body", + "with_min_size", + "combine_filters", + "PRStats", + "compute_stats", + "count_by_language", + "count_by_field", + "count_by_project_type", + "count_by_contribution_nature", + "count_by_description_clarity", + "group_by_repo", + "EnrichedPR", + "accumulate_stats", + "aggregate_stats", +) diff --git a/src/pr_analyzer/transforms/filters.py b/src/pr_analyzer/transforms/filters.py new file mode 100644 index 0000000..58b00cc --- /dev/null +++ b/src/pr_analyzer/transforms/filters.py @@ -0,0 +1,123 @@ +from collections.abc import Callable +from typing import Any + + +def by_state(state: str) -> Callable[[Any], bool]: + """ + Retorna um predicado que verifica se o estado de um PR corresponde ao estado fornecido. + A comparação não diferencia maiúsculas de minúsculas. + + Args: + state (str): O estado do Pull Request para filtrar. + + Returns: + Callable[[Any], bool]: Uma função predicado que retorna True se o estado do PR corresponder. + """ + target_state = state.lower() + + def predicate(pr: Any) -> bool: + if not hasattr(pr, "state") or pr.state is None: + return False + return str(pr.state).lower() == target_state + + return predicate + + +def by_language(language: str) -> Callable[[Any], bool]: + """ + Retorna um predicado que verifica se a linguagem de um PR corresponde à linguagem fornecida. + A comparação não diferencia maiúsculas de minúsculas. + + Args: + language (str): A linguagem de programação para filtrar. + + Returns: + Callable[[Any], bool]: Uma função predicado que retorna True se a linguagem do PR corresponder. + """ + target_language = language.lower() + + def predicate(pr: Any) -> bool: + if not hasattr(pr, "language") or pr.language is None: + return False + return str(pr.language).lower() == target_language + + return predicate + + +def by_date_range(start: str, end: str) -> Callable[[Any], bool]: + """ + Retorna um predicado que verifica se a data de criação de um PR está dentro + do intervalo fornecido (inclusivo). Espera strings no formato ISO 8601. + + Args: + start (str): A data inicial do intervalo. + end (str): A data final do intervalo. + + Returns: + Callable[[Any], bool]: Uma função predicado que retorna True se a data de criação do PR estiver no intervalo. + """ + + def predicate(pr: Any) -> bool: + if not hasattr(pr, "created_at") or pr.created_at is None: + return False + pr_date = str(pr.created_at)[:10] + return start <= pr_date <= end + + return predicate + + +def with_non_empty_body() -> Callable[[Any], bool]: + """ + Retorna um predicado que verifica se um PR possui um corpo (descrição) não vazio. + + Returns: + Callable[[Any], bool]: Uma função predicado que retorna True se o corpo do PR não for vazio. + """ + + def predicate(pr: Any) -> bool: + if not hasattr(pr, "body") or pr.body is None: + return False + return str(pr.body).strip() != "" + + return predicate + + +def with_min_size(min_changes: int) -> Callable[[Any], bool]: + """ + Retorna um predicado que verifica se o tamanho total (adições + exclusões) + de um PR é maior ou igual a min_changes. + + Args: + min_changes (int): O número mínimo de mudanças (adições e deleções combinadas). + + Returns: + Callable[[Any], bool]: Uma função predicado que retorna True se o PR possuir tamanho maior ou igual ao mínimo. + """ + + def predicate(pr: Any) -> bool: + additions = getattr(pr, "additions", 0) or 0 + deletions = getattr(pr, "deletions", 0) or 0 + try: + return (int(additions) + int(deletions)) >= min_changes + except (ValueError, TypeError): + return False + + return predicate + + +def combine_filters(*predicates: Callable[[Any], bool]) -> Callable[[Any], bool]: + """ + Combina múltiplos predicados utilizando o operador lógico AND (all). + Se nenhum predicado for fornecido, retorna True para qualquer PR. + + Args: + *predicates (Callable[[Any], bool]): Um número variável de funções predicado. + + Returns: + Callable[[Any], bool]: Uma função predicado combinada que retorna True apenas se todos os predicados retornarem True. + """ + + def predicate(pr: Any) -> bool: + return all(p(pr) for p in predicates) + + return predicate diff --git a/src/pr_analyzer/transforms/mappers.py b/src/pr_analyzer/transforms/mappers.py new file mode 100644 index 0000000..371229e --- /dev/null +++ b/src/pr_analyzer/transforms/mappers.py @@ -0,0 +1,39 @@ +"""Funções de transformação puras para mapear registros de PRs em metadados e estatísticas.""" + +from typing import NamedTuple + +from pr_analyzer.io.csv_reader import PRRecord + + +class PRStats(NamedTuple): + """Estatísticas de um único registro de Pull Request.""" + + body_char_count: int + body_word_count: int + total_changes: int + is_merged: bool + + +def compute_stats(pr: PRRecord) -> PRStats: + """ + Calcula as estatísticas para um determinado registro de PR. + + Args: + pr (PRRecord): O registro do Pull Request para o qual calcular as estatísticas. + + Returns: + PRStats: Uma tupla nomeada PRStats contendo a contagem de caracteres, contagem de palavras, + total de alterações (adições + exclusões) e um booleano indicando se o PR foi mesclado. + """ + body_char_count = len(pr.body) + body_word_count = len(pr.body.split()) + + total_changes = (pr.additions or 0) + (pr.deletions or 0) + is_merged = bool(pr.merged_at) + + return PRStats( + body_char_count=body_char_count, + body_word_count=body_word_count, + total_changes=total_changes, + is_merged=is_merged, + ) diff --git a/src/pr_analyzer/transforms/reducers.py b/src/pr_analyzer/transforms/reducers.py new file mode 100644 index 0000000..d14fbcb --- /dev/null +++ b/src/pr_analyzer/transforms/reducers.py @@ -0,0 +1,172 @@ +"""Funções de transformação puras para agregar dados de PR utilizando reduções.""" + +from collections.abc import Callable, Iterable +from functools import reduce +from typing import Any, NamedTuple + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.transforms.mappers import PRStats + + +class EnrichedPR(NamedTuple): + """PRRecord enriquecido com classificações semânticas via LLM.""" + + pr: PRRecord + project_type: str + contribution_nature: str + description_clarity: str + + +__all__: tuple[str, ...] = ( + "EnrichedPR", + "count_by_field", + "count_by_language", + "count_by_project_type", + "count_by_contribution_nature", + "count_by_description_clarity", + "group_by_repo", + "accumulate_stats", + "aggregate_stats", +) + + +def count_by_field(field: str) -> Callable[[Iterable[Any]], dict[str, int]]: + """ + Cria uma função de redução para contar as ocorrências de um campo específico. + + Args: + field (str): O nome do atributo ou campo pelo qual agrupar e contar. + + Returns: + Callable[[Iterable[Any]], dict[str, int]]: Uma função que recebe um iterável de itens e + retorna um dicionário mapeando os valores do campo para a contagem de ocorrências. + """ + return lambda items: reduce( + lambda acc, item: acc + | {getattr(item, field): acc.get(getattr(item, field), 0) + 1}, + items, + {}, + ) + + +def count_by_language(prs: Iterable[PRRecord]) -> dict[str, int]: + """ + Conta o número de PRs por linguagem utilizando uma redução pura. + + Args: + prs (Iterable[PRRecord]): Um iterável de registros de Pull Requests. + + Returns: + dict[str, int]: Um dicionário mapeando nomes das linguagens para as suas contagens. + """ + return count_by_field("language")(prs) + + +def count_by_project_type(enriched_prs: Iterable[EnrichedPR]) -> dict[str, int]: + """ + Conta o número de PRs por tipo de projeto. + + Args: + enriched_prs (Iterable[EnrichedPR]): Um iterável de Pull Requests enriquecidos. + + Returns: + dict[str, int]: Um dicionário mapeando os tipos de projeto para as suas contagens. + """ + return count_by_field("project_type")(enriched_prs) + + +def count_by_contribution_nature(enriched_prs: Iterable[EnrichedPR]) -> dict[str, int]: + """ + Conta o número de PRs por natureza de contribuição. + + Args: + enriched_prs (Iterable[EnrichedPR]): Um iterável de Pull Requests enriquecidos. + + Returns: + dict[str, int]: Um dicionário mapeando as naturezas de contribuição para as suas contagens. + """ + return count_by_field("contribution_nature")(enriched_prs) + + +def count_by_description_clarity(enriched_prs: Iterable[EnrichedPR]) -> dict[str, int]: + """ + Conta o número de PRs por nível de clareza da descrição. + + Args: + enriched_prs (Iterable[EnrichedPR]): Um iterável de Pull Requests enriquecidos. + + Returns: + dict[str, int]: Um dicionário mapeando os níveis de clareza para as suas contagens. + """ + return count_by_field("description_clarity")(enriched_prs) + + +def group_by_repo(prs: Iterable[PRRecord]) -> dict[str, list[PRRecord]]: + """ + Agrupa PRs pelo nome do repositório utilizando uma redução pura. + + Args: + prs (Iterable[PRRecord]): Um iterável de registros de Pull Requests. + + Returns: + dict[str, list[PRRecord]]: Um dicionário mapeando os nomes dos repositórios para listas de PRRecords. + """ + return reduce( + lambda acc, pr: acc | {pr.repo_name: [*acc.get(pr.repo_name, []), pr]}, + prs, + {}, + ) + + +def accumulate_stats(stats: Iterable[PRStats]) -> dict[str, int]: + """ + Acumula os totais e a contagem para uma coleção de PRStats em uma única passagem. + + Args: + stats (Iterable[PRStats]): Um iterável de estatísticas de Pull Requests. + + Returns: + dict[str, int]: Um dicionário contendo totais para caracteres, palavras, + alterações, mesclagens e a contagem de itens. + """ + return reduce( + lambda acc, stat: { + "chars": stat.body_char_count + acc["chars"], + "words": stat.body_word_count + acc["words"], + "changes": stat.total_changes + acc["changes"], + "merges": int(stat.is_merged) + acc["merges"], + "count": acc["count"] + 1, + }, + stats, + {"chars": 0, "words": 0, "changes": 0, "merges": 0, "count": 0}, + ) + + +def aggregate_stats(stats: Iterable[PRStats]) -> dict[str, float]: + """ + Calcula as estatísticas médias para uma coleção de PRStats. + + Args: + stats (Iterable[PRStats]): Um iterável de estatísticas de Pull Requests. + + Returns: + dict[str, float]: Um dicionário com as médias de caracteres (avg_chars), + palavras (avg_words), alterações (avg_changes) e a taxa de mesclagem (merge_rate). + """ + acc = accumulate_stats(stats) + count = acc["count"] + + if count == 0: + return { + "avg_chars": 0.0, + "avg_words": 0.0, + "avg_changes": 0.0, + "merge_rate": 0.0, + } + + return { + "avg_chars": acc["chars"] / count, + "avg_words": acc["words"] / count, + "avg_changes": acc["changes"] / count, + "merge_rate": acc["merges"] / count, + } diff --git a/src/pr_analyzer/ui/.streamlit/config.toml b/src/pr_analyzer/ui/.streamlit/config.toml new file mode 100644 index 0000000..20cc50b --- /dev/null +++ b/src/pr_analyzer/ui/.streamlit/config.toml @@ -0,0 +1,23 @@ +[server] +headless = true +runOnSave = true +fileWatcherType = "auto" +maxUploadSize = 10240 + +[client] +toolbarMode = "minimal" +showErrorDetails = false + +[ui] +hideTopBar = true + +[theme] +base = "dark" +backgroundColor = "#09090b" +secondaryBackgroundColor = "#18181b" +textColor = "#f4f4f5" +primaryColor = "#6366f1" +font = "sans serif" + +[logger] +level = "error" diff --git a/src/pr_analyzer/ui/app.py b/src/pr_analyzer/ui/app.py index a69609f..05d39b8 100644 --- a/src/pr_analyzer/ui/app.py +++ b/src/pr_analyzer/ui/app.py @@ -1,4 +1,159 @@ +""" +app.py — GitAnalyzer entry point. + +Responsibilities (only): + 1. Page configuration + 2. CSS injection + 3. Session-state bootstrap (including the raw_prs tuple from the pipeline) + 4. Sidebar rendering -> filter values + LLM toggle + 5. Filtering the active DataFrame (and optionally enriching via dev3+dev4) + 6. Routing to the correct main-area view (empty state OR tabs) + +Business logic, UI components, and data transforms live in their respective +modules under components/ and utils/. Functional-pipeline integration lives +in utils.pipeline_bridge (the seam between dev5 and dev1-dev4). +""" + +import os + import streamlit as st -st.title("GitHub PR Analyzer") -st.info("Em construção — Sprint 1 em andamento.") +st.set_page_config( + page_title="GitAnalyzer", + page_icon="🧬", + layout="wide", +) + +from components.sidebar import render_sidebar # noqa: E402 +from components.tabs import ( # noqa: E402 + render_tab_dashboard, + render_tab_explorer, + render_tab_export, +) +from utils.constants import APP_NAME, APP_VERSION # noqa: E402 +from utils.data import apply_filters, get_mock_data # noqa: E402 +from utils.pipeline_bridge import ( # noqa: E402 + enrich_prs, + enriched_to_dataframe, +) +from utils.styles import inject_css # noqa: E402 + +from pr_analyzer.llm.client import create_llm_client # noqa: E402 + +inject_css() + +if "file_loaded" not in st.session_state: + st.session_state.file_loaded = False +if "fname" not in st.session_state: + st.session_state.fname = "" +if "df" not in st.session_state: + st.session_state.df = get_mock_data() +if "llm_backend" not in st.session_state: + st.session_state.llm_backend = os.environ.get("LLM_BACKEND", "groq") +if "ollama_model" not in st.session_state: + st.session_state.ollama_model = os.environ.get("LLM_MODEL", "llama3") +if "raw_prs" not in st.session_state: + st.session_state.raw_prs = None +if "llm_cache_stats" not in st.session_state: + st.session_state.llm_cache_stats = None +if "llm_enriched" not in st.session_state: + st.session_state.llm_enriched = False + +sel_lang, sel_nature, cleaning, llm_tag, metrics = render_sidebar() + + +def _maybe_enrich() -> None: + """Classifica PRs com o LLM configurado. Roda apenas uma vez por dataset carregado.""" + if not llm_tag: + st.session_state.llm_enriched = False + return + if st.session_state.raw_prs is None: + st.session_state.llm_enriched = False + st.warning( + "Classificação LLM requer um CSV no formato PRRecord " + "(colunas: `pr_id`, `repo_name`, `body`…). " + "O dataset atual usa dados simulados — faça upload de um CSV compatível.", + icon="⚠️", + ) + return + if st.session_state.llm_enriched: + return + + prs = st.session_state.raw_prs + n = len(prs) + backend = st.session_state.get("llm_backend", "groq") + model = st.session_state.get("ollama_model", os.environ.get("LLM_MODEL", "llama3")) + + os.environ["LLM_BACKEND"] = backend + os.environ["LLM_MODEL"] = model + + try: + client = create_llm_client() + except Exception as exc: + st.error(f"Erro ao conectar ao {backend.upper()}: {exc}") + return + + enriched_list = [] + + with st.status( + f"Classificando {n} PR{'s' if n != 1 else ''} com {backend.upper()}…", + expanded=True, + ) as status: + st.caption(f"Modelo: `{model}`") + bar = st.progress(0.0) + for i, ep in enumerate(enrich_prs(prs, client=client), 1): + enriched_list.append(ep) + bar.progress(i / n) + status.update( + label=f"✓ {n} PRs classificados com {backend.upper()}", + state="complete", + expanded=False, + ) + + st.session_state.df = enriched_to_dataframe(enriched_list) + st.session_state.llm_cache_stats = {"cache_hits": 0, "calls_made": n, "total": n} + st.session_state.llm_enriched = True + + +_maybe_enrich() + +df = apply_filters(st.session_state.df, sel_lang, sel_nature) + +if not st.session_state.file_loaded: + st.markdown( + """ +
+
📊
+
Nenhum dado processado
+
Conecte um dataset para iniciar a análise.
+
+ """, + unsafe_allow_html=True, + ) + _, btn_col, _ = st.columns([2, 1, 2]) + with btn_col: + if st.button( + "⚡ Utilizar Dados Demo", use_container_width=True, key="demo_cta" + ): + st.session_state.file_loaded = True + st.session_state.fname = "gh_dataset_2026.csv" + st.rerun() + +else: + tab_dash, tab_explore, tab_export = st.tabs(["DASH", "EXPLORAR", "EXPORTAR"]) + + with tab_dash: + render_tab_dashboard(df, metrics_active=metrics) + + with tab_explore: + render_tab_explorer(df) + + with tab_export: + render_tab_export(df) + +st.markdown( + f"

" + f"{APP_NAME} V{APP_VERSION} — PIPELINE FUNCIONAL

", + unsafe_allow_html=True, +) diff --git a/src/pr_analyzer/ui/components/charts.py b/src/pr_analyzer/ui/components/charts.py new file mode 100644 index 0000000..3e6dc43 --- /dev/null +++ b/src/pr_analyzer/ui/components/charts.py @@ -0,0 +1,267 @@ +""" +components/charts.py — Plotly chart builders for the dashboard tab. + +Distribution charts (TASK-38) consume `dict[str, int]` directly, matching +the API of dev2's `count_by_*` reducers (TASK-31/32). This keeps the UI +layer decoupled from pandas and lets the same chart functions be reused +when fed either a DataFrame round-trip or the raw functional pipeline. + +Each function returns nothing — Streamlit-side rendering happens inline so +this module stays the only place that touches plotly + st.plotly_chart. +""" + +from __future__ import annotations + +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import streamlit as st +from utils.constants import ( + CLARITY_COLOR, + CLARITY_ORDER, + NATURE_COLOR, + PLOT_BASE, +) + +_NO_DATA_MSG = "Sem dados para exibir." + + +def _chart_cfg() -> dict[str, bool]: + return {"displayModeBar": False} + + +def _gauge_opts() -> dict[str, object]: + return { + "number": { + "suffix": "%", + "font": {"size": 32, "color": "#f4f4f5", "family": "Inter"}, + }, + "gauge": { + "axis": { + "range": [0, 100], + "tickcolor": "#52525b", + "tickfont": {"size": 9}, + }, + "bar": {"color": "#6366f1", "thickness": 0.25}, + "bgcolor": "#27272a", + "steps": [ + {"range": [0, 40], "color": "rgba(248,113,113,.15)"}, + {"range": [40, 75], "color": "rgba(251,191,36,.10)"}, + {"range": [75, 100], "color": "rgba(52,211,153,.10)"}, + ], + "threshold": { + "line": {"color": "#818cf8", "width": 2}, + "thickness": 0.75, + "value": 80, + }, + }, + } + + +_DEFAULT_PALETTE: tuple[str, ...] = ( + "#818cf8", + "#34d399", + "#fbbf24", + "#f87171", + "#a78bfa", + "#60a5fa", + "#fb7185", + "#22d3ee", +) + + +# ── Distribution charts (TASK-38) ───────────────────────────────────────────── + + +def render_distribution_bar( + counts: dict[str, int], + title: str, + accent_gradient: str = "linear-gradient(180deg,#818cf8,#4f46e5)", + color_map: dict[str, str] | None = None, +) -> None: + """Generic vertical bar chart driven by `dict[str, int]` (TASK-38).""" + _panel_header(title, accent_gradient) + + if not counts: + st.info(_NO_DATA_MSG) + return + + labels = list(counts.keys()) + values = list(counts.values()) + colors = _resolve_colors(labels, color_map) + + fig = go.Figure( + go.Bar( + x=labels, + y=values, + marker_color=colors, + marker_line_width=0, + hovertemplate="%{x}
%{y} PRs", + ) + ) + fig.update_layout( + **PLOT_BASE, + showlegend=False, + bargap=0.35, + xaxis=_axis_style(grid=False), + yaxis=_axis_style(grid=True), + ) + st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) + + +def render_distribution_donut( + counts: dict[str, int], + title: str, + accent_gradient: str = "linear-gradient(180deg,#34d399,#059669)", + color_map: dict[str, str] | None = None, +) -> None: + """Generic donut chart driven by `dict[str, int]` (TASK-38).""" + _panel_header(title, accent_gradient) + + if not counts: + st.info(_NO_DATA_MSG) + return + + labels = list(counts.keys()) + values = list(counts.values()) + colors = _resolve_colors(labels, color_map) + + fig = go.Figure( + go.Pie( + labels=labels, + values=values, + hole=0.62, + marker={"colors": colors, "line": {"color": "#09090b", "width": 2}}, + hovertemplate="%{label}
%{value} PRs (%{percent})", + textinfo="none", + ) + ) + fig.update_layout(**PLOT_BASE) + st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) + + +# ── Pre-bound wrappers (named after dev2's count_by_* reducers) ────────────── + + +def render_lang_distribution(counts: dict[str, int]) -> None: + """Distribution by programming language.""" + render_distribution_donut(counts, "🌐 Distribuição por Linguagem") + + +def render_project_type_distribution(counts: dict[str, int]) -> None: + """Distribution by classified project type (library / web app / ...).""" + render_distribution_donut( + counts, + "🏗 Distribuição por Tipo de Projeto", + accent_gradient="linear-gradient(180deg,#a78bfa,#7c3aed)", + ) + + +def render_nature_distribution(counts: dict[str, int]) -> None: + """Distribution by contribution nature (bug fix / feature / ...).""" + render_distribution_bar( + counts, + "📊 Distribuição por Natureza da Contribuição", + color_map=NATURE_COLOR, + ) + + +def render_clarity_distribution(counts: dict[str, int]) -> None: + """Distribution by description clarity level.""" + render_distribution_bar( + counts, + "🎯 Distribuição por Clareza da Descrição", + accent_gradient="linear-gradient(180deg,#fbbf24,#f59e0b)", + color_map=CLARITY_COLOR, + ) + + +# ── Auxiliary charts kept from sprint-1 (scatter + gauge) ──────────────────── + + +def render_scatter_chart(df: pd.DataFrame) -> None: + """Scatter: commit size vs clarity level.""" + _panel_header("📄 Correlação: Qualidade vs Escopo") + + if not {"size", "clarity", "repo"}.issubset(df.columns) or len(df) == 0: + st.info(_NO_DATA_MSG) + return + + fig = px.scatter( + df, + x="size", + y="clarity", + color="clarity", + hover_data=["repo", "lang"], + color_discrete_map=CLARITY_COLOR, + category_orders={"clarity": CLARITY_ORDER}, + labels={"size": "", "clarity": ""}, + ) + fig.update_layout( + **PLOT_BASE, + showlegend=False, + xaxis=_axis_style(grid=False, suffix=" chars"), + yaxis=_axis_style(grid=True), + ) + fig.update_traces(marker={"size": 13, "opacity": 0.8, "line": {"width": 0}}) + st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) + + +def render_clarity_gauge(df: pd.DataFrame) -> None: + """Gauge: average clarity score (0-100).""" + _panel_header( + "🎯 Score Médio de Clareza", "linear-gradient(180deg,#fbbf24,#f59e0b)" + ) + + if "clarity" not in df.columns or len(df) == 0: + st.info(_NO_DATA_MSG) + return + + order = {"Excellent": 100, "Good": 75, "Basic": 40, "Insufficient": 10} + avg = df["clarity"].map(order).mean() + score = round(avg) if not pd.isna(avg) else 0 + + fig = go.Figure(go.Indicator(mode="gauge+number", value=score, **_gauge_opts())) + fig.update_layout(**{**PLOT_BASE, "height": 220}) + st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) + + +# ── Private helpers ────────────────────────────────────────────────────────── + + +def _panel_header( + title: str, + accent_gradient: str = "linear-gradient(180deg,#818cf8,#4f46e5)", +) -> None: + st.markdown( + f""" +
+
+
{title}
+
+ """, + unsafe_allow_html=True, + ) + + +def _axis_style(grid: bool, suffix: str = "") -> dict[str, object]: + style: dict[str, object] = { + "showgrid": grid, + "zeroline": False, + "tickfont": {"size": 10, "color": "#52525b"}, + } + if grid: + style["gridcolor"] = "#27272a" + style["gridwidth"] = 0.5 + if suffix: + style["ticksuffix"] = suffix + return style + + +def _resolve_colors(labels: list[str], color_map: dict[str, str] | None) -> list[str]: + if not color_map: + return [_DEFAULT_PALETTE[i % len(_DEFAULT_PALETTE)] for i in range(len(labels))] + return [ + color_map.get(label, _DEFAULT_PALETTE[i % len(_DEFAULT_PALETTE)]) + for i, label in enumerate(labels) + ] diff --git a/src/pr_analyzer/ui/components/kpis.py b/src/pr_analyzer/ui/components/kpis.py new file mode 100644 index 0000000..bf2aba9 --- /dev/null +++ b/src/pr_analyzer/ui/components/kpis.py @@ -0,0 +1,51 @@ +""" +components/kpis.py — KPI row rendered at the top of the dashboard tab. +""" + +from __future__ import annotations + +import pandas as pd +import streamlit as st + + +def render_kpis(df: pd.DataFrame, metrics_active: bool) -> None: + """Render four KPI metric cards in a horizontal row.""" + k1, k2, k3, k4 = st.columns(4, gap="small") + + k1.metric("● PRs Processados", len(df)) + k2.metric("● Clareza (LLM)", _compute_clarity_grade(df)) + k3.metric("● Volatilidade", _compute_volatility(df)) + k4.metric("● Cache Agno", "Ativo" if metrics_active else "Inativo") + + +# ── Pure helpers ────────────────────────────────────────────────────────────── + + +def _compute_clarity_grade(df: pd.DataFrame) -> str: + if "clarity" not in df.columns or len(df) == 0: + return "—" + order = {"Excellent": 4, "Good": 3, "Basic": 2, "Insufficient": 1} + avg = df["clarity"].map(order).mean() + if avg >= 3.5: + return "Nível A" + if avg >= 2.5: + return "Nível B+" + if avg >= 1.5: + return "Nível C" + return "Nível D" + + +def _compute_volatility(df: pd.DataFrame) -> str: + if "size" not in df.columns or len(df) == 0: + return "—" + mean = df["size"].mean() + std = df["size"].std() + # std is NaN for a single row; mean can be NaN if all values are null + if pd.isna(mean) or pd.isna(std) or mean == 0: + return "—" + cv = std / mean + if cv < 0.4: + return "Baixa" + if cv < 0.8: + return "Média" + return "Alta" diff --git a/src/pr_analyzer/ui/components/sidebar.py b/src/pr_analyzer/ui/components/sidebar.py new file mode 100644 index 0000000..0acf6b8 --- /dev/null +++ b/src/pr_analyzer/ui/components/sidebar.py @@ -0,0 +1,333 @@ +""" +components/sidebar.py — Sidebar with branding, data source, LLM backend, pipeline +toggles, and filters. Returns filter selections so app.py stays decoupled from +widget state. + +The "Classificação LLM" toggle controls whether enrich_prs is applied to +PRRecords coming from the functional pipeline. The result is surfaced back +through st.session_state.llm_cache_stats so the explorer tab can show the +"resultados do cache" indicator. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +import pandas as pd +import streamlit as st +from utils.data import ( + check_ollama, + discover_datasets, + get_filter_options, + get_mock_data, + load_archive_sample, + load_dataframe, +) +from utils.pipeline_bridge import ( + load_uploaded, +) + + +def render_sidebar() -> tuple[str, str, bool, bool, bool]: + """ + Render the full sidebar and return: + (sel_lang, sel_nature, cleaning, llm_tag, metrics) + Handles file upload, local dataset selection, and LLM backend state internally. + """ + with st.sidebar: + _render_brand() + _render_data_section() + st.divider() + _render_llm_backend() + st.divider() + cleaning, llm_tag, metrics = _render_pipeline() + st.divider() + sel_lang, sel_nature = _render_filters() + + return sel_lang, sel_nature, cleaning, llm_tag, metrics + + +def _render_brand() -> None: + st.markdown( + """ +
+
+ 🔧 +
+ + GitAnalyzer + +
+ """, + unsafe_allow_html=True, + ) + + +def _render_data_section() -> None: + st.markdown("### 🗄 DADOS DE ENTRADA") + + uploaded = st.file_uploader( + "CSV / JSON", + type=["csv", "json"], + label_visibility="collapsed", + ) + if uploaded is not None: + _handle_upload(uploaded) + + _render_local_datasets() + _render_file_status() + + +def _render_file_status() -> None: + if st.session_state.file_loaded: + fname = st.session_state.fname or "dataset" + st.markdown( + f""" +
+
+
+
{fname}
+
Sincronizado
+
+
+ """, + unsafe_allow_html=True, + ) + if st.button("REMOVER FONTE", key="rm", use_container_width=True): + st.session_state.df = get_mock_data() + st.session_state.file_loaded = False + st.session_state.fname = "" + st.session_state.raw_prs = None + st.session_state.llm_cache_stats = None + st.session_state.llm_enriched = False + st.rerun() + else: + st.markdown( + "

NENHUM DATASET ATIVO

", + unsafe_allow_html=True, + ) + + +def _handle_upload(uploaded: object) -> None: + """Route uploaded CSVs through the functional pipeline when possible. + + The Streamlit UploadedFile cursor may be at an arbitrary position after + widget rendering, so we always seek(0) and read into a fresh BytesIO + before passing to any parser — avoiding empty-read errors. + """ + from io import BytesIO + + filename = getattr(uploaded, "name", "uploaded.csv") + try: + if hasattr(uploaded, "seek"): + uploaded.seek(0) + raw_bytes: bytes = uploaded.read() if hasattr(uploaded, "read") else b"" + buf = BytesIO(raw_bytes) + if filename.endswith(".csv"): + df, prs = load_uploaded(buf, filename) + st.session_state.df = df + st.session_state.raw_prs = prs + else: + st.session_state.df = load_dataframe(buf, filename) + st.session_state.raw_prs = None + st.session_state.file_loaded = True + st.session_state.fname = filename + st.session_state.llm_cache_stats = None + st.session_state.llm_enriched = False + except Exception as exc: + st.error(f'Não foi possível carregar "{filename}": {exc}') + + +def _render_local_datasets() -> None: + datasets = discover_datasets("data") + if not datasets: + return + + with st.expander("DATASETS LOCAIS", expanded=True): + labels: list[str] = ["— selecionar —", *(d["label"] for d in datasets)] + choice: str = st.selectbox( + "Dataset local", + labels, + key="local_ds_select", + label_visibility="collapsed", + ) + if choice != "— selecionar —": + selected = next(d for d in datasets if d["label"] == choice) + if selected.get("oversized"): + st.warning( + f"Arquivo excede o limite configurado " + f"({os.environ.get('MAX_DATASET_SIZE_GB', '10')} GB). " + "Ajuste MAX_DATASET_SIZE_GB no .env para carregar." + ) + elif st.button("Carregar", key="load_local_btn", use_container_width=True): + _load_local(selected) + + +def _load_local(dataset: dict[str, Any]) -> None: + fmt: str = dataset["format"] + path: str = dataset["path"] + + raw_prs = None + if fmt == "archive": + lang = str(dataset.get("lang", "")) + with st.spinner(f"Amostrando {lang} (2 000 registros)…"): + df = load_archive_sample(path, lang) + elif fmt == "csv": + import io as _io + + with open(path, "rb") as f: + raw = f.read() + df, raw_prs = load_uploaded(_io.BytesIO(raw), path.split("/")[-1]) + if raw_prs is None: + df = pd.read_csv(_io.BytesIO(raw)) + else: + with open(path, encoding="utf-8") as jf: + df = pd.DataFrame(json.load(jf)) + + st.session_state.df = df + st.session_state.raw_prs = raw_prs + st.session_state.file_loaded = True + st.session_state.fname = dataset["label"] + st.session_state.llm_enriched = False + st.session_state.llm_cache_stats = None + st.rerun() + + +def _render_llm_backend() -> None: + st.markdown("### 🤖 BACKEND LLM") + + current = st.session_state.get("llm_backend", "groq") + backend: str = st.radio( + "Backend", + ["groq", "ollama"], + format_func=lambda x: "Groq (API)" if x == "groq" else "Ollama (local)", + index=0 if current == "groq" else 1, + horizontal=True, + label_visibility="collapsed", + key="llm_backend_radio", + ) + st.session_state.llm_backend = backend + + if backend == "groq": + _render_groq_panel() + else: + _render_ollama_panel() + + +def _render_groq_panel() -> None: + key = os.environ.get("GROQ_API_KEY", "") + if key and not key.startswith("gsk_your"): + st.markdown( + "

" + "✓ API key configurada

", + unsafe_allow_html=True, + ) + else: + st.warning("Configure GROQ_API_KEY no .env") + + +def _render_ollama_panel() -> None: + host = os.environ.get("OLLAMA_HOST", "http://localhost:11434") + st.caption(f"Host: `{host}`") + + should_check = st.session_state.get("ollama_checked") is None + if st.button("↺ Verificar conexão", key="ollama_verify") or should_check: + running, models = check_ollama(host) + st.session_state.ollama_running = running + st.session_state.ollama_models = models + st.session_state.ollama_checked = True + + if st.session_state.get("ollama_running"): + st.markdown( + "

" + "✓ Conectado

", + unsafe_allow_html=True, + ) + cached_models: list[str] = st.session_state.get("ollama_models", []) + if cached_models: + chosen: str = st.selectbox( + "Modelo", cached_models, key="ollama_model_select" + ) + st.session_state.ollama_model = chosen + else: + st.caption("Nenhum modelo instalado.") + st.code("ollama pull llama3", language="bash") + else: + st.error("Ollama não encontrado") + _render_ollama_setup(host) + + +def _render_ollama_setup(host: str) -> None: + st.markdown( + "

" + "Como configurar (Docker):

", + unsafe_allow_html=True, + ) + st.markdown("1. Instale o Ollama em **ollama.com**") + st.markdown("2. Baixe um modelo:") + st.code("ollama pull llama3", language="bash") + st.markdown("3. Faça o Ollama escutar em todas as interfaces:") + st.code( + "sudo tee /etc/systemd/system/ollama.service.d/override.conf <<'EOF'\n" + "[Service]\n" + 'Environment="OLLAMA_HOST=0.0.0.0"\n' + "EOF\n" + "sudo systemctl daemon-reload && sudo systemctl restart ollama", + language="bash", + ) + st.markdown("4. Configure o `.env` do projeto:") + st.code( + "LLM_BACKEND=ollama\n" + "OLLAMA_HOST=http://host.docker.internal:11434\n" + "LLM_MODEL=llama3", + language="bash", + ) + st.info( + "O `host.docker.internal` é resolvido automaticamente pelo Docker " + "via `extra_hosts` no `docker-compose.yml`. Certifique-se de que o " + "Ollama está escutando em `0.0.0.0` (passo 3) antes de iniciar o container." + ) + if "localhost" in host and "host.docker.internal" not in host: + st.warning( + "Host atual aponta para `localhost`, que dentro do container " + "não alcança o Ollama do host. Atualize `OLLAMA_HOST` no `.env`." + ) + + +def _render_pipeline() -> tuple[bool, bool, bool]: + st.markdown("### ⚙ PIPELINE") + cleaning = st.toggle("Sanitização Funcional", value=True, key="cleaning") + llm_tag = st.toggle("Ativar Classificação LLM", value=False, key="llm") + metrics = st.toggle("Geração de Métricas", value=False, key="metrics") + _render_cache_indicator() + return cleaning, llm_tag, metrics + + +def _render_cache_indicator() -> None: + """Surface cache-hit info coming back from the last enrichment run.""" + cache = st.session_state.get("llm_cache_stats") or {} + total = int(cache.get("total", 0)) + if total == 0: + return + hits = int(cache.get("cache_hits", 0)) + misses = int(cache.get("calls_made", 0)) + badge_color = "#34d399" if hits else "#52525b" + st.markdown( + f"
" + f"● Cache LLM:" + f" {hits} hits · {misses} chamadas reais
", + unsafe_allow_html=True, + ) + + +def _render_filters() -> tuple[str, str]: + st.markdown("### 🔍 REFINAR VISÃO") + langs, natures = get_filter_options(st.session_state.df) + sel_lang: str = st.selectbox("LINGUAGEM", langs) + sel_nature: str = st.selectbox("NATUREZA", natures) + return sel_lang, sel_nature diff --git a/src/pr_analyzer/ui/components/tabs.py b/src/pr_analyzer/ui/components/tabs.py new file mode 100644 index 0000000..d9fc3ae --- /dev/null +++ b/src/pr_analyzer/ui/components/tabs.py @@ -0,0 +1,230 @@ +""" +components/tabs.py — Three tab content renderers: dashboard, explorer, export. + +Dashboard tab is now structured around TASK-38: four distribution charts +(language, project type, contribution nature, description clarity) plus the +sprint-1 scatter and gauge as auxiliary correlation views. + +Export tab is structured around TASK-48 prep: it routes CSV/JSON downloads +through `utils.exports`, which will switch to dev1's `io.exporters` once +they land. +""" + +from __future__ import annotations + +import pandas as pd +import streamlit as st +from components.charts import ( + render_clarity_distribution, + render_clarity_gauge, + render_lang_distribution, + render_nature_distribution, + render_project_type_distribution, + render_scatter_chart, +) +from components.kpis import render_kpis +from streamlit_extras.metric_cards import style_metric_cards +from utils.constants import COL_RENAMES, PREFERRED_COLS +from utils.data import build_report_markdown +from utils.exports import ( + export_dataframe_csv, + export_dataframe_json, +) +from utils.pipeline_bridge import ( + distributions_from_dataframe, +) + +# ══════════════════════════════════════════════════════════════════════════════ +# TAB 1 — DASHBOARD +# ══════════════════════════════════════════════════════════════════════════════ + + +def render_tab_dashboard(df: pd.DataFrame, metrics_active: bool) -> None: + render_kpis(df, metrics_active) + + style_metric_cards( + background_color="#18181b", + border_left_color="#6366f1", + border_color="#27272a", + box_shadow=False, + ) + + distributions = distributions_from_dataframe(df) + + st.markdown("
", unsafe_allow_html=True) + _render_distribution_row_top(distributions) + st.markdown("
", unsafe_allow_html=True) + _render_distribution_row_bottom(distributions) + st.markdown("
", unsafe_allow_html=True) + _render_correlation_row(df) + + +def _render_distribution_row_top(distributions: dict[str, dict[str, int]]) -> None: + col_lang, col_type = st.columns(2, gap="large") + with col_lang: + render_lang_distribution(distributions["language"]) + with col_type: + render_project_type_distribution(distributions["project_type"]) + + +def _render_distribution_row_bottom( + distributions: dict[str, dict[str, int]], +) -> None: + col_nature, col_clarity = st.columns(2, gap="large") + with col_nature: + render_nature_distribution(distributions["contribution_nature"]) + with col_clarity: + render_clarity_distribution(distributions["description_clarity"]) + + +def _render_correlation_row(df: pd.DataFrame) -> None: + col_sc, col_gauge = st.columns(2, gap="large") + with col_sc: + render_scatter_chart(df) + with col_gauge: + render_clarity_gauge(df) + + +# ══════════════════════════════════════════════════════════════════════════════ +# TAB 2 — EXPLORER +# ══════════════════════════════════════════════════════════════════════════════ + + +def render_tab_explorer(df: pd.DataFrame) -> None: + cache_note = _cache_status_note() + st.markdown( + f"

" + f"▸ Explorador de Registros — {len(df)} resultado(s){cache_note}

", + unsafe_allow_html=True, + ) + + if len(df) == 0: + st.info("Nenhum registro corresponde aos filtros.") + return + + cols = [c for c in PREFERRED_COLS if c in df.columns] + extra = [c for c in df.columns if c not in cols] + disp = df[cols + extra].rename(columns=COL_RENAMES) + + st.dataframe( + disp, + use_container_width=True, + hide_index=True, + height=500, + column_config={ + "Tamanho": st.column_config.ProgressColumn( + "Tamanho", + format="%d chars", + min_value=0, + max_value=1000, + ), + "Natureza (ML)": st.column_config.TextColumn("Natureza (ML)"), + }, + ) + + +def _cache_status_note() -> str: + """Render the 'Resultados do cache' indicator next to the explorer header.""" + cache = st.session_state.get("llm_cache_stats") + if not cache or cache.get("total", 0) == 0: + return "" + hits = int(cache.get("cache_hits", 0)) + total = int(cache.get("total", 0)) + if hits == 0: + return "" + return f" · {hits}/{total} resultados do cache" + + +# ══════════════════════════════════════════════════════════════════════════════ +# TAB 3 — EXPORT +# ══════════════════════════════════════════════════════════════════════════════ + + +def render_tab_export(df: pd.DataFrame) -> None: + report_md = build_report_markdown(df) + ec1, ec2, ec3 = st.columns(3, gap="large") + + _export_card( + col=ec1, + icon="📊", + bg="rgba(16,185,129,.15)", + title="Dataset Estruturado", + desc="Exportar todos os PRs classificados para CSV.", + btn_label="Baixar .CSV", + btn_data=export_dataframe_csv(df), + btn_file="pr_dataset.csv", + btn_mime="text/csv", + ) + _export_card( + col=ec2, + icon="🔗", + bg="rgba(99,102,241,.15)", + title="Schema de Grafos", + desc="Representação JSON para Neo4j ou similares.", + btn_label="Baixar .JSON", + btn_data=export_dataframe_json(df), + btn_file="pr_graph_schema.json", + btn_mime="application/json", + ) + _export_card( + col=ec3, + icon="📄", + bg="rgba(255,255,255,.06)", + title="Relatório Executivo", + desc="Sumário com os KPIs e estatísticas da sessão.", + btn_label="Baixar .MD", + btn_data=report_md.encode(), + btn_file="relatorio.md", + btn_mime="text/markdown", + ) + + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + + +# ── Private helper ──────────────────────────────────────────────────────────── + + +def _export_card( + col: st.delta_generator.DeltaGenerator, + icon: str, + bg: str, + title: str, + desc: str, + btn_label: str, + btn_data: bytes, + btn_file: str, + btn_mime: str, +) -> None: + with col: + st.markdown( + f'
' + f'
{icon}
' + f'
{title}
' + f'
{desc}
' + f"
", + unsafe_allow_html=True, + ) + st.download_button( + f"⬇ {btn_label}", + data=btn_data, + file_name=btn_file, + mime=btn_mime, + use_container_width=True, + ) diff --git a/src/pr_analyzer/ui/utils/constants.py b/src/pr_analyzer/ui/utils/constants.py new file mode 100644 index 0000000..e2afe99 --- /dev/null +++ b/src/pr_analyzer/ui/utils/constants.py @@ -0,0 +1,60 @@ +""" +constants.py — Design tokens, colour maps, and chart defaults. +Pure values; no imports from Streamlit or pandas. +""" + +from typing import Any + +# ── Colour maps ─────────────────────────────────────────────────────────────── +NATURE_COLOR: dict[str, str] = { + "Bug Fix": "#818cf8", + "Feature": "#34d399", + "Refactor": "#fbbf24", + "Documentation": "#a78bfa", +} + +CLARITY_COLOR: dict[str, str] = { + "Excellent": "#34d399", + "Good": "#818cf8", + "Basic": "#fbbf24", + "Insufficient": "#f87171", +} + +CLARITY_ORDER: list[str] = ["Excellent", "Good", "Basic", "Insufficient"] + +# ── Plotly base layout (shared across all charts) ──────────────────────────── +PLOT_BASE: dict[str, Any] = { + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(0,0,0,0)", + "font": {"family": "Inter, sans-serif", "color": "#52525b", "size": 10}, + "margin": {"l": 4, "r": 4, "t": 8, "b": 4}, + "height": 280, +} + +# ── KPI metadata ───────────────────────────────────────────────────────────── +KPI_COLORS: list[str] = ["#818cf8", "#34d399", "#fbbf24", "#a1a1aa"] + +# ── Column display preferences ─────────────────────────────────────────────── +PREFERRED_COLS: list[str] = [ + "date", + "repo", + "lang", + "type", + "nature", + "clarity", + "size", +] + +COL_RENAMES: dict[str, str] = { + "date": "Data", + "repo": "Repositório", + "lang": "Linguagem", + "type": "Tipo", + "nature": "Natureza (ML)", + "clarity": "Status LLM", + "size": "Tamanho", +} + +# ── App metadata ───────────────────────────────────────────────────────────── +APP_VERSION = "2.0" +APP_NAME = "GitAnalyzer" diff --git a/src/pr_analyzer/ui/utils/data.py b/src/pr_analyzer/ui/utils/data.py new file mode 100644 index 0000000..25fdf76 --- /dev/null +++ b/src/pr_analyzer/ui/utils/data.py @@ -0,0 +1,365 @@ +""" +data.py — Pure data helpers (no Streamlit, no global state). +All functions are referentially transparent given the same inputs. +""" + +from __future__ import annotations + +import json +import os +import urllib.request +from io import BytesIO +from pathlib import Path +from typing import Any + +import pandas as pd +import streamlit as st + +MAX_DATASET_SIZE_GB: float = float(os.environ.get("MAX_DATASET_SIZE_GB", "10")) + + +@st.cache_data(show_spinner=False) +def get_mock_data() -> pd.DataFrame: + """Return a small but representative demo DataFrame.""" + rows: list[dict[str, Any]] = [ + { + "id": 1, + "lang": "Python", + "type": "Library", + "nature": "Bug Fix", + "clarity": "Excellent", + "size": 450, + "repo": "pandas", + "date": "2024-03-01", + }, + { + "id": 2, + "lang": "JavaScript", + "type": "Framework", + "nature": "Feature", + "clarity": "Basic", + "size": 120, + "repo": "next.js", + "date": "2024-03-02", + }, + { + "id": 3, + "lang": "Go", + "type": "CLI Tool", + "nature": "Refactor", + "clarity": "Good", + "size": 300, + "repo": "terraform", + "date": "2024-03-02", + }, + { + "id": 4, + "lang": "Python", + "type": "Library", + "nature": "Feature", + "clarity": "Good", + "size": 800, + "repo": "scikit-learn", + "date": "2024-03-03", + }, + { + "id": 5, + "lang": "TypeScript", + "type": "Web App", + "nature": "Documentation", + "clarity": "Insufficient", + "size": 50, + "repo": "vscode", + "date": "2024-03-04", + }, + { + "id": 6, + "lang": "Java", + "type": "Framework", + "nature": "Bug Fix", + "clarity": "Excellent", + "size": 600, + "repo": "spring", + "date": "2024-03-05", + }, + ] + return pd.DataFrame(rows) + + +# ─── Loading from uploaded files ───────────────────────────────────────────── + + +def _is_mined_comments(data: Any) -> bool: + """Return True when data matches the mined-comments archive shape. + + Expected shape: { "owner/repo": [ {comment_dict}, ... ], ... } + """ + if not isinstance(data, dict): + return False + sample = next(iter(data.values()), None) + return isinstance(sample, list) + + +def _mined_comments_to_dataframe(data: dict[str, Any]) -> pd.DataFrame: + """Flatten a mined-comments dict into a UI-compatible DataFrame. + + Re-uses the same _comment_row / heuristics that load_archive_sample uses, + so the display columns are identical whether the file came from upload or + from the local dataset picker. + """ + rows: list[dict[str, Any]] = [] + for repo_full, comments in data.items(): + if not isinstance(comments, list): + continue + for c in comments: + file_path = str(c.get("path", "")) + body = str(c.get("body", "")) + lang_fallback = repo_full.split("/")[-1] if "/" in repo_full else repo_full + rows.append( + _comment_row( + int(c.get("id", len(rows))), + repo_full, + file_path, + body, + lang_fallback, + ) + ) + if not rows: + return pd.DataFrame() + return pd.DataFrame(rows) + + +def load_dataframe(file: BytesIO, filename: str) -> pd.DataFrame: + """Parse an uploaded file into a DataFrame. + + Handles three JSON shapes: + - mined-comments archive: { "owner/repo": [{comment}, ...] } + - list of records: [ {row}, ... ] + - flat dict: { col: [values] } + + Raises ValueError with a descriptive message on failure. + """ + try: + if filename.endswith(".json"): + data = json.load(file) + if _is_mined_comments(data): + return _mined_comments_to_dataframe(data) + return pd.DataFrame(data) + return pd.read_csv(file) + except Exception as exc: + raise ValueError(f"Não foi possível ler '{filename}': {exc}") from exc + + +# ─── Filtering (pure transform) ────────────────────────────────────────────── + + +def apply_filters( + df: pd.DataFrame, + lang: str, + nature: str, +) -> pd.DataFrame: + """Return a filtered copy of *df* without mutating the original.""" + result = df.copy() + if lang != "Todas" and "lang" in result.columns: + result = result[result["lang"] == lang] + if nature != "Todas" and "nature" in result.columns: + result = result[result["nature"] == nature] + return result + + +# ─── Export helpers ─────────────────────────────────────────────────────────── + + +def build_report_markdown(df: pd.DataFrame) -> str: + """Generate a plain-text executive report from *df*.""" + top_lang = df["lang"].mode()[0] if len(df) and "lang" in df.columns else "—" + nat_lines = "" + if "nature" in df.columns and len(df): + nat_lines = "\n".join( + f"- {n}: {c}" for n, c in df["nature"].value_counts().items() + ) + return ( + f"# GitAnalyzer — Relatório Executivo\n\n" + f"**PRs Processados:** {len(df)}\n" + f"**Linguagem Top:** {top_lang}\n\n" + f"## Distribuição por Natureza\n{nat_lines}" + ) + + +# ─── Local dataset discovery ───────────────────────────────────────────────── + +_EXT_TO_LANG: dict[str, str] = { + ".java": "Java", + ".py": "Python", + ".js": "JavaScript", + ".ts": "TypeScript", + ".go": "Go", + ".rb": "Ruby", + ".rs": "Rust", +} + + +def discover_datasets( + data_dir: str, + max_size_gb: float = MAX_DATASET_SIZE_GB, +) -> list[dict[str, Any]]: + """Return available datasets in data_dir (top-level files + archive subdirs). + + Files larger than max_size_gb are listed but flagged as oversized so the UI + can warn the user instead of silently skipping them. + """ + base = Path(data_dir) + if not base.is_dir(): + return [] + + results: list[dict[str, Any]] = [] + max_bytes = max_size_gb * 1024**3 + + for entry in sorted(base.iterdir()): + if entry.is_file() and entry.suffix in (".csv", ".json"): + size_bytes = entry.stat().st_size + mb = size_bytes / 1024**2 + oversized = size_bytes > max_bytes + label = f"{entry.name} ({mb:.1f} MB)" + if oversized: + label += f" ⚠ >{max_size_gb:.0f} GB" + results.append( + { + "label": label, + "path": str(entry), + "format": entry.suffix.lstrip("."), + "lang": None, + "oversized": oversized, + } + ) + + archive = base / "archive" + if archive.is_dir(): + for sub in sorted(archive.iterdir()): + inner = sub / sub.name + if sub.is_dir() and inner.is_file(): + size_bytes = inner.stat().st_size + gb = size_bytes / 1024**3 + oversized = size_bytes > max_bytes + lang = sub.name.replace("mined-comments-25stars-25prs-", "").replace( + ".json", "" + ) + label = f"{lang} ({gb:.1f} GB · amostra)" + if oversized: + label += f" ⚠ >{max_size_gb:.0f} GB" + results.append( + { + "label": label, + "path": str(inner), + "format": "archive", + "lang": lang, + "oversized": oversized, + } + ) + + return results + + +def check_ollama(host: str) -> tuple[bool, list[str]]: + """Return (is_running, model_names) for the Ollama instance at host.""" + try: + with urllib.request.urlopen(f"{host}/api/tags", timeout=2) as resp: + data: dict[str, Any] = json.loads(resp.read().decode()) + return True, [str(m["name"]) for m in data.get("models", [])] + except Exception: + return False, [] + + +@st.cache_data(show_spinner=False) +def load_archive_sample( + path: str, + lang: str, + max_records: int = 2000, +) -> pd.DataFrame: + """Stream first max_records review comments from a mined-comments JSON archive.""" + try: + import ijson + except ImportError as exc: + raise RuntimeError("Instale ijson: pip install ijson") from exc + + rows: list[dict[str, Any]] = [] + with open(path, "rb") as f: + for repo, comments in ijson.kvitems(f, ""): + for c in comments: + body = str(c.get("body", "")) + rows.append( + _comment_row( + int(c.get("id", len(rows))), + str(repo), + str(c.get("path", "")), + body, + lang, + ) + ) + if len(rows) >= max_records: + return pd.DataFrame(rows) + return pd.DataFrame(rows) + + +def _comment_row( + cid: int, + repo: str, + file_path: str, + body: str, + fallback: str, +) -> dict[str, Any]: + """Convert a mined-comment dict to a UI-compatible row.""" + ext = os.path.splitext(file_path)[1].lower() + return { + "id": cid, + "repo": repo.split("/")[-1], + "lang": _EXT_TO_LANG.get(ext, fallback), + "type": "Code Review", + "nature": _nature_heuristic(body), + "clarity": _clarity_heuristic(body), + "size": len(body), + "date": "", + "comment": body[:200], + } + + +def _nature_heuristic(body: str) -> str: + """Classify comment nature by keyword heuristic.""" + lower = body.lower() + if any(w in lower for w in ("bug", "fix", "error", "crash", "issue")): + return "Bug Fix" + if any(w in lower for w in ("add", "new", "implement", "feature", "support")): + return "Feature" + if any(w in lower for w in ("refactor", "clean", "simplif", "extract")): + return "Refactor" + return "Documentation" + + +def _clarity_heuristic(body: str) -> str: + """Estimate comment clarity from body length.""" + n = len(body) + if n >= 100: + return "Excellent" + if n >= 50: + return "Good" + if n >= 20: + return "Basic" + return "Insufficient" + + +# ─── Filtering (pure transform) ────────────────────────────────────────────── + + +def get_filter_options(df: pd.DataFrame) -> tuple[list[str], list[str]]: + """Return (lang_options, nature_options) including a 'Todas' sentinel.""" + langs = ( + ["Todas", *sorted(df["lang"].dropna().unique().tolist())] + if "lang" in df.columns + else ["Todas"] + ) + natures = ( + ["Todas", *sorted(df["nature"].dropna().unique().tolist())] + if "nature" in df.columns + else ["Todas"] + ) + return langs, natures \ No newline at end of file diff --git a/src/pr_analyzer/ui/utils/distributions.py b/src/pr_analyzer/ui/utils/distributions.py new file mode 100644 index 0000000..f05b0cf --- /dev/null +++ b/src/pr_analyzer/ui/utils/distributions.py @@ -0,0 +1,66 @@ +""" +distributions.py — Local fallback for the count_by_* reducers expected +from `pr_analyzer.transforms.reducers` (dev2, TASK-31/32). + +Mirrors the dev2 API exactly so the UI can render distribution charts today +and seamlessly switch to dev2's implementation once it lands — the bridge +module imports from `transforms.reducers` first and falls back here. + +These helpers are pure (no I/O, no global state) even though they live in +the UI tree, which keeps the contract identical to the future pure module. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from functools import reduce +from typing import Any + + +def count_by_field(field: str) -> Callable[[Iterable[Any]], dict[str, int]]: + """HOF: returns a reducer that counts occurrences of `field` on each item. + + Matches dev2 TASK-31 signature. Works on either a NamedTuple (PRRecord-like) + or a Mapping — the bridge tolerates both representations. + """ + + def _value(item: Any) -> str: + if hasattr(item, field): + raw = getattr(item, field) + elif isinstance(item, dict): + raw = item.get(field, "") + else: + raw = "" + return str(raw).strip() or "—" + + def _reducer(items: Iterable[Any]) -> dict[str, int]: + return reduce( + lambda acc, item: acc | {_value(item): acc.get(_value(item), 0) + 1}, + items, + {}, + ) + + return _reducer + + +count_by_language: Callable[[Iterable[Any]], dict[str, int]] = count_by_field( + "language" +) +count_by_project_type: Callable[[Iterable[Any]], dict[str, int]] = count_by_field( + "project_type" +) +count_by_contribution_nature: Callable[[Iterable[Any]], dict[str, int]] = ( + count_by_field("contribution_nature") +) +count_by_description_clarity: Callable[[Iterable[Any]], dict[str, int]] = ( + count_by_field("description_clarity") +) + + +def count_from_dataframe_column(values: Iterable[Any]) -> dict[str, int]: + """Reducer over a plain iterable of pre-extracted column values.""" + return reduce( + lambda acc, v: acc | {str(v): acc.get(str(v), 0) + 1}, + (v for v in values if v is not None and str(v).strip() != ""), + {}, + ) diff --git a/src/pr_analyzer/ui/utils/exports.py b/src/pr_analyzer/ui/utils/exports.py new file mode 100644 index 0000000..1ae3c16 --- /dev/null +++ b/src/pr_analyzer/ui/utils/exports.py @@ -0,0 +1,27 @@ +""" +exports.py — UI-side wrapper around dev1's exporters (TASK-48 prep). + +Today this module wraps `pandas.DataFrame.to_csv` / `to_json` so the export +tab works end-to-end. When dev1 ships `pr_analyzer.io.exporters` with +`export_to_csv` / `export_to_json` (their TASK-13/14 deliverable referenced +by fase-4 TASK-48), this file becomes a one-line passthrough. + +Side-effect module — exporters serialize bytes for `st.download_button` to +hand over to the user's browser. +""" + +from __future__ import annotations + +import pandas as pd + + +def export_dataframe_csv(df: pd.DataFrame) -> bytes: + """Serialize a DataFrame to CSV bytes.""" + csv: str = df.to_csv(index=False) or "" + return csv.encode("utf-8") + + +def export_dataframe_json(df: pd.DataFrame) -> bytes: + """Serialize a DataFrame to JSON bytes.""" + json: str = df.to_json(orient="records", indent=2) or "" + return json.encode("utf-8") diff --git a/src/pr_analyzer/ui/utils/pipeline_bridge.py b/src/pr_analyzer/ui/utils/pipeline_bridge.py new file mode 100644 index 0000000..b326ff0 --- /dev/null +++ b/src/pr_analyzer/ui/utils/pipeline_bridge.py @@ -0,0 +1,349 @@ +""" +pipeline_bridge.py — Bridge between the functional pipeline (PRRecord + +transforms + llm + cache) and the UI's pandas-based view layer. + +This module is the integration seam for dev5: it imports from the modules +owned by dev1 (`io/`), dev2 (`transforms/`), dev3 (`llm/`), and dev4 +(`cache/`, `pipeline/`). Where a downstream module is still in progress +(e.g. dev2's reducers, dev3's `enrich_prs`), we fall back to local helpers +with the same shape so the UI is functional today and swaps cleanly when +the real implementations land. + +Side-effect module — UI tree is allowed to do I/O and wrap effects. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from io import BytesIO, StringIO +from pathlib import Path +from typing import Any + +import pandas as pd +from utils.distributions import ( + count_by_contribution_nature as _local_nature, +) +from utils.distributions import ( + count_by_description_clarity as _local_clarity, +) +from utils.distributions import ( + count_by_language as _local_language, +) +from utils.distributions import ( + count_by_project_type as _local_project_type, +) + +from pr_analyzer.io import PRRecord, apply_schema, detect_schema, schema_adapter +from pr_analyzer.llm.classifiers import enrich_prs as backend_enrich_prs +from pr_analyzer.llm.client import LLMClient, create_llm_client +from pr_analyzer.pipeline.builder import build_pipeline +from pr_analyzer.transforms import ( + by_language, + by_state, + combine_filters, + with_min_size, + with_non_empty_body, +) +from pr_analyzer.transforms.reducers import EnrichedPR + +# ── Dev2 reducers (when available) ─────────────────────────────────────────── +# When dev2 ships transforms.reducers (TASK-31/32) this import block resolves +# to their implementation; until then we use the local fallback that mirrors +# the same shape (Iterable[Any] -> dict[str, int]). + +try: # pragma: no cover - exercised after dev2 merge + from pr_analyzer.transforms.reducers import ( + count_by_contribution_nature, + count_by_description_clarity, + count_by_language, + count_by_project_type, + ) +except ImportError: + count_by_language = _local_language + count_by_project_type = _local_project_type + count_by_contribution_nature = _local_nature + count_by_description_clarity = _local_clarity + + +# ── Dev3 enrich_prs (when available) ────────────────────────────────────────- +# When dev3 ships `enrich_prs` (TASK-35) the bridge delegates to it directly. +# Until then we apply the three classifier stubs via map() locally. + + +# ── Type aliases ────────────────────────────────────────────────────────────── + +ClassifierFn = Callable[..., str] + + +# ── CSV ingestion via dev1 ──────────────────────────────────────────────────── + + +def parse_csv_bytes(raw: bytes) -> tuple[PRRecord, ...]: + """Convert raw CSV bytes (uploaded file) into an immutable tuple of PRRecords. + + Detects the CSV schema (canonical or github_export) and adapts column names + before calling apply_schema, so fields like `description`→`body` are mapped. + """ + import csv + + text = raw.decode("utf-8", errors="replace") + reader = csv.DictReader(StringIO(text)) + schema = detect_schema(reader.fieldnames or []) + adapt = schema_adapter(schema) if schema != "unknown" else lambda r: dict(r) + return tuple(apply_schema(adapt(row)) for row in reader) + + +def looks_like_pr_record_csv(header_row: Iterable[str]) -> bool: + """Heuristic: PRRecord CSVs carry the `pr_id` column. + + The Streamlit demo dataset (gh_dataset_2026.csv) uses a flat display + schema; only the Kaggle-style dataset is worth piping through the + functional pipeline. + """ + header = frozenset(h.strip().lower() for h in header_row) + return "pr_id" in header or "repo_name" in header + + +# ── Filtering via dev2 ──────────────────────────────────────────────────────── + + +def make_filter_chain( + state: str | None = None, + language: str | None = None, + min_size: int | None = None, + require_body: bool = False, +) -> Callable[[Any], bool]: + """Compose dev2's filter predicates into a single AND-combined predicate.""" + predicates: tuple[Callable[[Any], bool], ...] = () + if state and state.lower() != "todas": + predicates = (*predicates, by_state(state)) + if language and language.lower() != "todas": + predicates = (*predicates, by_language(language)) + if min_size is not None and min_size > 0: + predicates = (*predicates, with_min_size(min_size)) + if require_body: + predicates = (*predicates, with_non_empty_body()) + return combine_filters(*predicates) + + +def filter_prs( + prs: Iterable[PRRecord], + predicate: Callable[[Any], bool], +) -> Iterable[PRRecord]: + """Lazy filter via dev4's `build_pipeline`.""" + return build_pipeline(prs, filters=(predicate,), mappers=()) + + +# ── LLM enrichment via dev3 + dev4 cache ────────────────────────────────────── + + +def enrich_prs( + prs: Iterable[PRRecord], + client: LLMClient | None = None, + cache_path: Path | None = None, +) -> Iterable[EnrichedPR]: + """Delegate enrichment to the real backend implementation. + + Creates the configured LLM client from .env when no client is provided and + uses the disk-backed cache implemented by make_enriched_classifier. + """ + llm_client = client or create_llm_client() + return backend_enrich_prs( + prs=prs, + client=llm_client, + cache_path=cache_path, + ) + + +# ── DataFrame adapters ──────────────────────────────────────────────────────── + + +_DISPLAY_COLUMNS: tuple[str, ...] = ( + "id", + "repo", + "lang", + "type", + "nature", + "clarity", + "size", + "state", + "title", +) + + +def enriched_to_dataframe(items: Iterable[Any]) -> pd.DataFrame: + """Materialize enriched PRs into a DataFrame using the UI's display columns.""" + rows = [ + { + "id": e.pr.pr_id, + "repo": e.pr.repo_name, + "lang": e.pr.language.title() if e.pr.language else "—", + "type": e.project_type.title() if e.project_type else "—", + "nature": e.contribution_nature.title() if e.contribution_nature else "—", + "clarity": _capitalize_clarity(e.description_clarity), + "size": (e.pr.additions or 0) + (e.pr.deletions or 0), + "state": e.pr.state.title() if e.pr.state else "—", + "title": e.pr.title, + } + for e in items + ] + if not rows: + return pd.DataFrame(columns=list(_DISPLAY_COLUMNS)) + return pd.DataFrame(rows) + + +def prs_to_dataframe(prs: Iterable[PRRecord]) -> pd.DataFrame: + """Materialize raw PRRecords (un-enriched) into a DataFrame. + + Classification columns are left empty so the UI can highlight that LLM + classification is disabled. + """ + rows = [ + { + "id": pr.pr_id, + "repo": pr.repo_name, + "lang": pr.language.title() if pr.language else "—", + "type": "—", + "nature": "—", + "clarity": "—", + "size": (pr.additions or 0) + (pr.deletions or 0), + "state": pr.state.title() if pr.state else "—", + "title": pr.title, + } + for pr in prs + ] + if not rows: + return pd.DataFrame(columns=list(_DISPLAY_COLUMNS)) + return pd.DataFrame(rows) + + +def dataframe_to_prs(df: pd.DataFrame) -> tuple[PRRecord, ...]: + """Best-effort conversion from a displayed DataFrame back to PRRecords. + + Supports canonical PR schemas and the mined-comments UI shape. Returns an + empty tuple when the frame cannot be mapped safely. + """ + if df is None or len(df) == 0: + return () + + columns = {str(c) for c in df.columns} + canonical = {"pr_id", "repo_name", "language", "title", "state"} + mined_comments = {"id", "repo", "lang", "comment"} + + if not (canonical.issubset(columns) or mined_comments.issubset(columns)): + return () + + def _first(row: Mapping[str, Any], *names: str, default: Any = "") -> Any: + for name in names: + value = row.get(name, None) + if value not in (None, ""): + return value + return default + + records: list[PRRecord] = [] + for _, raw_row in df.iterrows(): + row = raw_row.to_dict() + title = _first(row, "title", "comment", default="") + body = _first(row, "body", "comment", default=title) + records.append( + apply_schema( + { + "pr_id": _first(row, "pr_id", "id", default=""), + "repo_name": _first(row, "repo_name", "repo", default=""), + "language": _first(row, "language", "lang", default=""), + "title": title, + "body": body, + "state": _first(row, "state", default="open"), + "created_at": _first(row, "created_at", "date", default=""), + "merged_at": _first(row, "merged_at", default=""), + "additions": _first(row, "additions", "size", default=""), + "deletions": _first(row, "deletions", default=0), + "changed_files": _first(row, "changed_files", default=1), + } + ) + ) + + return tuple(records) + + +def _capitalize_clarity(value: str) -> str: + mapping = { + "insuficiente": "Insufficient", + "básica": "Basic", + "boa": "Good", + "excelente": "Excellent", + } + return mapping.get(value.lower(), value.title() if value else "—") + + +# ── Distribution helpers (consumed by chart components) ────────────────────── + + +def distributions_from_dataframe(df: pd.DataFrame) -> dict[str, dict[str, int]]: + """Compute the 4 distribution dicts driving TASK-38's dashboard charts. + + Reads the DataFrame columns the UI uses (lang/type/nature/clarity) and + delegates counting to dev2's reducers when available (or the local + fallback otherwise). Returns an empty dict per missing column so charts + can render an empty state gracefully. + """ + return { + "language": _column_counts(df, "lang"), + "project_type": _column_counts(df, "type"), + "contribution_nature": _column_counts(df, "nature"), + "description_clarity": _column_counts(df, "clarity"), + } + + +def _column_counts(df: pd.DataFrame, column: str) -> dict[str, int]: + if column not in df.columns or len(df) == 0: + return {} + from utils.distributions import ( + count_from_dataframe_column, + ) + + result: dict[str, int] = count_from_dataframe_column(df[column].dropna().tolist()) + return result + + +def distributions_from_records(items: Iterable[Any]) -> dict[str, dict[str, int]]: + """Apply dev2's reducers directly to a tuple of (Enriched)PR records. + + Used when the UI is fed by the functional pipeline rather than a + DataFrame round-trip. + """ + materialized = tuple(items) + return { + "language": count_by_language(materialized), + "project_type": count_by_project_type(materialized), + "contribution_nature": count_by_contribution_nature(materialized), + "description_clarity": count_by_description_clarity(materialized), + } + + +# ── Uploaded-file routing ───────────────────────────────────────────────────── + + +def load_uploaded( + file: BytesIO, + filename: str, +) -> tuple[pd.DataFrame, tuple[PRRecord, ...] | None]: + """Return (display_df, raw_prs_or_None). + + If the uploaded CSV matches the PRRecord schema, parse it via dev1 and + keep the immutable tuple alongside the DataFrame so downstream steps + (LLM enrichment, pure filters) can operate on it. Otherwise treat it as + a flat display CSV (the demo dataset shape) and return only the + DataFrame when it cannot be mapped back to PRRecord. + """ + raw = file.read() + text = raw.decode("utf-8", errors="replace") + header = text.splitlines()[0].split(",") if text else [] + + if looks_like_pr_record_csv(header): + prs = parse_csv_bytes(raw) + return prs_to_dataframe(prs), prs + + df = pd.read_csv(StringIO(text)) + prs = dataframe_to_prs(df) + return df, prs or None diff --git a/src/pr_analyzer/ui/utils/styles.py b/src/pr_analyzer/ui/utils/styles.py new file mode 100644 index 0000000..47959d4 --- /dev/null +++ b/src/pr_analyzer/ui/utils/styles.py @@ -0,0 +1,288 @@ +""" +styles.py — All custom CSS for GitAnalyzer, injected once via inject_css(). +Keeping CSS here avoids cluttering app.py and makes theming changes trivial. +""" + +import streamlit as st + +_CSS = """ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap'); + +html, body, [data-testid="stAppViewContainer"] { + font-family: 'Inter', sans-serif; + background-color: #09090b; +} + +/* ── sidebar ────────────────────────────────────────────────────────────── */ +[data-testid="stSidebar"] { + background-color: #18181b !important; + border-right: 1px solid #27272a !important; +} +[data-testid="stSidebar"] > div:first-child { padding-top: 1.5rem !important; } + +[data-testid="stSidebar"] h3 { + font-size: 10px !important; + font-weight: 900 !important; + text-transform: uppercase; + letter-spacing: 2px; + color: #52525b !important; + margin: 1.25rem 0 0.6rem !important; +} + +/* ── metric cards ───────────────────────────────────────────────────────── */ +[data-testid="stMetric"] { + background: #18181b; + border: 1px solid #27272a; + border-radius: 18px; + padding: 1.25rem 1.5rem !important; + transition: border-color .2s, box-shadow .2s; +} +[data-testid="stMetric"]:hover { + border-color: rgba(99,102,241,.4); + box-shadow: 0 0 0 1px rgba(99,102,241,.15); +} +[data-testid="stMetricLabel"] { + font-size: 9px !important; + font-weight: 900 !important; + text-transform: uppercase; + letter-spacing: 0.15em; + color: #52525b !important; +} +[data-testid="stMetricValue"] { + font-size: 1.75rem !important; + font-weight: 900 !important; + letter-spacing: -1px !important; + font-family: 'JetBrains Mono', monospace !important; +} +[data-testid="stMetricDelta"] { display: none !important; } + +/* KPI colours by position */ +[data-testid="stHorizontalBlock"] [data-testid="stMetric"]:nth-child(1) [data-testid="stMetricValue"] { color: #818cf8 !important; } +[data-testid="stHorizontalBlock"] [data-testid="stMetric"]:nth-child(2) [data-testid="stMetricValue"] { color: #34d399 !important; } +[data-testid="stHorizontalBlock"] [data-testid="stMetric"]:nth-child(3) [data-testid="stMetricValue"] { color: #fbbf24 !important; } +[data-testid="stHorizontalBlock"] [data-testid="stMetric"]:nth-child(4) [data-testid="stMetricValue"] { color: #a1a1aa !important; } + +/* ── tabs ───────────────────────────────────────────────────────────────── */ +.stTabs [data-baseweb="tab-list"] { + gap: 0; + background: transparent; + border-bottom: 1px solid #27272a; +} +.stTabs [data-baseweb="tab"] { + height: 50px; + background: transparent !important; + border: none !important; + border-bottom: 2px solid transparent !important; + font-size: 11px !important; + font-weight: 900 !important; + text-transform: uppercase; + letter-spacing: 0.14em; + color: #52525b !important; + padding: 0 1.5rem !important; + transition: color .15s; +} +.stTabs [aria-selected="true"] { + color: #818cf8 !important; + border-bottom: 2px solid #818cf8 !important; +} +.stTabs [data-baseweb="tab-panel"] { padding-top: 1.75rem !important; } + +/* ── buttons ────────────────────────────────────────────────────────────── */ +.stButton > button { + background: #6366f1 !important; + color: white !important; + border: none !important; + border-radius: 12px !important; + font-size: 10px !important; + font-weight: 900 !important; + text-transform: uppercase; + letter-spacing: 0.12em; + padding: 0.65rem 1.25rem !important; + transition: all 0.15s; +} +.stButton > button:hover { + background: #4f46e5 !important; + transform: translateY(-1px); + box-shadow: 0 8px 20px -4px rgba(99,102,241,0.4) !important; +} + +/* ── download buttons ───────────────────────────────────────────────────── */ +[data-testid="stDownloadButton"] > button { + background: #27272a !important; + color: #d4d4d8 !important; + border: 1px solid #3f3f46 !important; + border-radius: 12px !important; + font-size: 9px !important; + font-weight: 900 !important; + text-transform: uppercase; + letter-spacing: 0.12em; + width: 100% !important; + transition: all 0.15s !important; +} +[data-testid="stDownloadButton"] > button:hover { + background: #6366f1 !important; + color: white !important; + border-color: #6366f1 !important; +} + +/* ── file uploader ──────────────────────────────────────────────────────── */ +[data-testid="stFileUploader"] section { + background: #27272a !important; + border: 1.5px dashed #3f3f46 !important; + border-radius: 14px !important; + transition: border-color .15s; +} +[data-testid="stFileUploader"] section:hover { border-color: #6366f1 !important; } +[data-testid="stFileUploadDropzone"] * { font-size: 10px !important; color: #71717a !important; } + +/* ── selectbox ──────────────────────────────────────────────────────────── */ +div[data-baseweb="select"] > div { + background: #27272a !important; + border: 1px solid #3f3f46 !important; + border-radius: 10px !important; + font-size: 12px !important; +} + +/* ── toggles ────────────────────────────────────────────────────────────── */ +[data-testid="stToggle"] { + background: #27272a; + border: 1px solid #3f3f46; + border-radius: 12px; + padding: 0.65rem 0.9rem; + margin-bottom: 0.4rem; +} +[data-testid="stToggle"] label p { + font-size: 11px !important; + font-weight: 700 !important; + color: #d4d4d8 !important; +} + +/* ── dataframe ──────────────────────────────────────────────────────────── */ +[data-testid="stDataFrame"] { + border: 1px solid #27272a !important; + border-radius: 18px !important; + overflow: hidden; +} + +/* ── sidebar widget labels ──────────────────────────────────────────────── */ +[data-testid="stSidebar"] [data-testid="stWidgetLabel"] p { + font-size: 9px !important; + font-weight: 900 !important; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #52525b !important; +} +[data-testid="stSidebar"] hr { border-color: #27272a !important; } + +/* ── status badge (file-ok) ──────────────────────────────────────────────── */ +.file-ok { + background: rgba(16,185,129,0.08); + border: 1px solid rgba(16,185,129,0.25); + border-radius: 12px; + padding: 0.75rem 1rem; + display: flex; align-items: center; gap: 10px; + margin-bottom: 0.75rem; + animation: fadeSlide .3s ease; +} +.file-ok-icon { + width: 34px; height: 34px; border-radius: 9px; + background: rgba(16,185,129,0.15); + display: flex; align-items: center; justify-content: center; + font-size: 15px; flex-shrink: 0; +} +.file-ok-name { font-size: 10px; font-weight: 800; color: #d4d4d8; font-family: 'JetBrains Mono', monospace; } +.file-ok-label { font-size: 9px; font-weight: 700; color: #34d399; text-transform: uppercase; letter-spacing: 0.1em; } + +/* ── chart panels ────────────────────────────────────────────────────────── */ +.chart-panel { + background: #18181b; + border: 1px solid #27272a; + border-radius: 22px; + padding: 1.5rem 1.5rem 0.5rem; + position: relative; overflow: hidden; +} +.chart-panel-accent { + position: absolute; top: 0; left: 0; + width: 3px; height: 100%; + background: linear-gradient(180deg, #6366f1, #a78bfa); +} +.chart-panel-title { + font-size: 9px; font-weight: 900; + text-transform: uppercase; letter-spacing: 0.16em; + color: #52525b; margin-bottom: 0.75rem; + display: flex; align-items: center; gap: 6px; +} + +/* ── export cards ────────────────────────────────────────────────────────── */ +.export-card { + background: #18181b; + border: 1px solid #27272a; + border-radius: 22px; + padding: 2rem 1.5rem; + text-align: center; + transition: border-color .2s, transform .2s, box-shadow .2s; + height: 100%; +} +.export-card:hover { + border-color: rgba(99,102,241,.4); + transform: translateY(-3px); + box-shadow: 0 12px 32px -8px rgba(99,102,241,.2); +} +.export-icon { font-size: 28px; margin-bottom: 1rem; } +.export-title { font-size: 13px; font-weight: 900; color: #f4f4f5; margin-bottom: 6px; } +.export-desc { font-size: 10px; color: #71717a; line-height: 1.6; margin-bottom: 1.25rem; } + +/* ── share banner ────────────────────────────────────────────────────────── */ +.share-banner { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + border-radius: 22px; + padding: 2rem; margin-top: 1.5rem; + display: flex; align-items: center; + justify-content: space-between; gap: 1rem; + box-shadow: 0 20px 40px -10px rgba(99,102,241,.4); +} +.share-circle { + width: 50px; height: 50px; border-radius: 50%; + background: rgba(255,255,255,.18); + display: flex; align-items: center; justify-content: center; + font-size: 20px; flex-shrink: 0; +} +.share-title { font-size: 17px; font-weight: 900; color: white; letter-spacing: -0.3px; } +.share-sub { font-size: 10px; color: rgba(255,255,255,.65); margin-top: 3px; } +.share-btn { + background: white; color: #6366f1; + border-radius: 14px; padding: 0.8rem 1.75rem; + font-size: 9px; font-weight: 900; + text-transform: uppercase; letter-spacing: 0.14em; + white-space: nowrap; flex-shrink: 0; cursor: pointer; + transition: transform .15s, box-shadow .15s; +} +.share-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,.2); +} + +/* ── empty state ─────────────────────────────────────────────────────────── */ +.empty-state { + display: flex; flex-direction: column; + align-items: center; justify-content: center; + min-height: 70vh; text-align: center; +} +.empty-icon { font-size: 72px; opacity: 0.1; margin-bottom: 1rem; } +.empty-title { font-size: 20px; font-weight: 900; color: #52525b; margin-bottom: 0.5rem; } +.empty-sub { font-size: 12px; color: #3f3f46; margin-bottom: 2rem; } + +/* ── animations ──────────────────────────────────────────────────────────── */ +@keyframes fadeSlide { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── main padding ────────────────────────────────────────────────────────── */ +[data-testid="stMainBlockContainer"] { padding: 2rem 2.5rem !important; } +""" + + +def inject_css() -> None: + """Inject the global CSS stylesheet into the Streamlit page.""" + st.markdown(f"", unsafe_allow_html=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..afea1ed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +"""Shared fixtures for the pr-analyzer test suite.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from pr_analyzer.io.csv_reader import PRRecord + + +@pytest.fixture() +def sample_pr() -> PRRecord: + return PRRecord( + pr_id=1, + repo_name="org/repo", + language="python", + title="Fix memory leak in parser", + body="This PR fixes a memory leak found in the CSV parser module.", + state="merged", + created_at="2024-01-01T10:00:00Z", + merged_at="2024-01-02T12:00:00Z", + additions=42, + deletions=7, + changed_files=3, + ) + + +@pytest.fixture() +def sample_pr_no_body(sample_pr: PRRecord) -> PRRecord: + return sample_pr._replace(body="") + + +@pytest.fixture() +def sample_prs(sample_pr: PRRecord) -> tuple[PRRecord, ...]: + return ( + sample_pr, + sample_pr._replace(pr_id=2, language="java", state="open", merged_at=""), + sample_pr._replace(pr_id=3, language="python", state="closed", merged_at=""), + ) + + +@pytest.fixture() +def sample_csv_file(tmp_path: Path, sample_pr: PRRecord) -> str: + csv_path = tmp_path / "prs.csv" + csv_path.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at," + "additions,deletions,changed_files\n" + f"{sample_pr.pr_id},{sample_pr.repo_name},{sample_pr.language}," + f'"{sample_pr.title}","{sample_pr.body}",{sample_pr.state},' + f"{sample_pr.created_at},{sample_pr.merged_at}," + f"{sample_pr.additions},{sample_pr.deletions},{sample_pr.changed_files}\n", + encoding="utf-8", + ) + return str(csv_path) + + +@pytest.fixture() +def mock_llm_client() -> MagicMock: + client = MagicMock() + client.run.return_value.content = '{"tipo_projeto": "biblioteca"}' + return client diff --git a/tests/test_cache/test_cached_classify.py b/tests/test_cache/test_cached_classify.py new file mode 100644 index 0000000..b925785 --- /dev/null +++ b/tests/test_cache/test_cached_classify.py @@ -0,0 +1,109 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from pr_analyzer.cache.memo import cached_classify, make_enriched_classifier +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.pipeline.builder import EnrichedPR + + +def _make_pr() -> PRRecord: + return PRRecord( + pr_id=1, + repo_name="org/repo", + language="python", + title="Fix bug", + body="Detailed body.", + state="merged", + created_at="2024-01-01", + merged_at="2024-01-02", + additions=5, + deletions=2, + changed_files=1, + ) + + +def _make_classifier(return_value: str = "tool") -> MagicMock: + fn = MagicMock(return_value=return_value) + fn.__name__ = "mock_classifier" + return fn + + +def test_cached_classify_returns_correct_result() -> None: + classifier = _make_classifier("tool") + wrapped = cached_classify(classifier) + assert wrapped("repo", "Fix bug") == "tool" + + +def test_cached_classify_calls_fn_only_once_for_same_input(tmp_path: Path) -> None: + classifier = _make_classifier("library") + wrapped = cached_classify(classifier, cache_path=tmp_path / "cache.json") + wrapped("repo", "Add feature") + wrapped("repo", "Add feature") + classifier.assert_called_once() + + +def test_cached_classify_calls_fn_for_different_inputs(tmp_path: Path) -> None: + classifier = _make_classifier("app") + wrapped = cached_classify(classifier, cache_path=tmp_path / "cache.json") + wrapped("repo", "Fix bug") + wrapped("repo", "Add tests") + assert classifier.call_count == 2 + + +def test_cached_classify_persists_across_instances(tmp_path: Path) -> None: + cache_file = tmp_path / "cache.json" + classifier = _make_classifier("tool") + + cached_classify(classifier, cache_path=cache_file)("repo", "Fix bug") + classifier.reset_mock() + + cached_classify(classifier, cache_path=cache_file)("repo", "Fix bug") + classifier.assert_not_called() + + +# ── make_enriched_classifier ────────────────────────────────────────────────── + + +def test_make_enriched_classifier_returns_enriched_pr() -> None: + classify_fn = make_enriched_classifier( + _make_classifier("biblioteca"), + _make_classifier("bug fix"), + _make_classifier("boa"), + ) + result = classify_fn(_make_pr()) + assert isinstance(result, EnrichedPR) + assert result.project_type == "biblioteca" + assert result.contribution_nature == "bug fix" + assert result.description_clarity == "boa" + + +def test_make_enriched_classifier_caches_same_pr() -> None: + type_fn = _make_classifier("biblioteca") + classify_fn = make_enriched_classifier( + type_fn, _make_classifier("bug fix"), _make_classifier("boa") + ) + pr = _make_pr() + classify_fn(pr) + classify_fn(pr) + type_fn.assert_called_once() + + +def test_make_enriched_classifier_persists_cache(tmp_path: Path) -> None: + cache_file = tmp_path / "enrich_cache.json" + type_fn = _make_classifier("ferramenta") + + make_enriched_classifier( + type_fn, + _make_classifier("feature"), + _make_classifier("boa"), + cache_path=cache_file, + )(_make_pr()) + type_fn.reset_mock() + + make_enriched_classifier( + type_fn, + _make_classifier("feature"), + _make_classifier("boa"), + cache_path=cache_file, + )(_make_pr()) + type_fn.assert_not_called() diff --git a/tests/test_cache/test_memo.py b/tests/test_cache/test_memo.py new file mode 100644 index 0000000..8d531a7 --- /dev/null +++ b/tests/test_cache/test_memo.py @@ -0,0 +1,21 @@ +from pr_analyzer.cache.memo import make_cache_key + + +def test_make_cache_key_is_deterministic() -> None: + assert make_cache_key("123", "llama3") == make_cache_key("123", "llama3") + + +def test_make_cache_key_different_inputs_differ() -> None: + assert make_cache_key("123", "llama3") != make_cache_key("456", "llama3") + + +def test_make_cache_key_returns_string() -> None: + assert isinstance(make_cache_key("1", "model"), str) + + +def test_make_cache_key_order_matters() -> None: + assert make_cache_key("a", "b") != make_cache_key("b", "a") + + +def test_make_cache_key_no_separator_collision() -> None: + assert make_cache_key("a:b", "c") != make_cache_key("a", "b:c") diff --git a/tests/test_io/test_csv_reader.py b/tests/test_io/test_csv_reader.py new file mode 100644 index 0000000..a56c5bc --- /dev/null +++ b/tests/test_io/test_csv_reader.py @@ -0,0 +1,348 @@ +from pathlib import Path +from types import GeneratorType + +import pytest + +from pr_analyzer.io.csv_reader import ( + PRRecord, + apply_schema, + detect_schema, + is_valid_row, + read_csv_lazy, + read_prs, + schema_adapter, +) +from pr_analyzer.pipeline.builder import build_pipeline + + +def test_pr_record_is_immutable() -> None: + record = PRRecord( + pr_id=1, + repo_name="owner/repo", + language="python", + title="Add parser", + body="Implements parser", + state="open", + created_at="2026-05-01T10:00:00Z", + merged_at="", + additions=10, + deletions=2, + changed_files=3, + ) + + with pytest.raises(AttributeError): + record.pr_id = 2 # type: ignore[misc] + + +def test_read_csv_lazy_returns_generator_and_reads_rows(tmp_path: Path) -> None: + csv_file = tmp_path / "prs.csv" + csv_file.write_text( + "pr_id,repo_name,language,title\n" + "1,owner/repo,Python,First PR\n" + "2,owner/repo,JavaScript,Second PR\n", + encoding="utf-8", + ) + + rows = read_csv_lazy(str(csv_file)) + + assert isinstance(rows, GeneratorType) + assert next(rows)["title"] == "First PR" + assert next(rows)["language"] == "JavaScript" + + +def test_apply_schema_normalizes_missing_invalid_and_uppercase_fields() -> None: + record = apply_schema( + { + "pr_id": "42", + "repo_name": "owner/repo", + "language": "PYTHON", + "title": "Add cache", + "state": "OPEN", + "additions": "invalid", + "deletions": "7", + } + ) + + assert record == PRRecord( + pr_id=42, + repo_name="owner/repo", + language="python", + title="Add cache", + body="", + state="open", + created_at="", + merged_at="", + additions=None, + deletions=7, + changed_files=None, + ) + + +def test_apply_schema_preserves_legitimate_zero_values() -> None: + record = apply_schema( + { + "pr_id": "0", + "additions": "0", + "deletions": "0", + "changed_files": "0", + } + ) + + assert record.pr_id == 0 + assert record.additions == 0 + assert record.deletions == 0 + assert record.changed_files == 0 + + +def test_detect_schema_identifies_canonical_header() -> None: + header = ( + "pr_id", + "repo_name", + "language", + "title", + "body", + "state", + "created_at", + "merged_at", + "additions", + "deletions", + "changed_files", + ) + + assert detect_schema(header) == "canonical" + + +def test_detect_schema_identifies_github_export_header() -> None: + header = ( + "number", + "repository", + "primary_language", + "title", + "description", + "status", + "created", + "merged", + "additions", + "deletions", + "files_changed", + ) + + assert detect_schema(header) == "github_export" + + +def test_schema_adapter_normalizes_github_export_row() -> None: + adapter = schema_adapter("github_export") + + assert adapter( + { + "number": "7", + "repository": "owner/repo", + "primary_language": "Python", + "title": "Nova tela", + "description": "Implementa a tela inicial", + "status": "OPEN", + "created": "2026-05-10T12:00:00Z", + "merged": "", + "additions": "12", + "deletions": "3", + "files_changed": "2", + } + ) == { + "pr_id": "7", + "repo_name": "owner/repo", + "language": "Python", + "title": "Nova tela", + "body": "Implementa a tela inicial", + "state": "OPEN", + "created_at": "2026-05-10T12:00:00Z", + "merged_at": "", + "additions": "12", + "deletions": "3", + "changed_files": "2", + } + + +def test_schema_adapter_rejects_unknown_schema() -> None: + with pytest.raises(ValueError, match="schema desconhecido"): + schema_adapter("inexistente") + + +def test_is_valid_row_rejects_missing_required_field() -> None: + assert not is_valid_row( + { + "pr_id": "1", + "repo_name": "", + "language": "Python", + "title": "Sem repo", + "state": "open", + "additions": "1", + "deletions": "0", + "changed_files": "1", + } + ) + + +def test_is_valid_row_rejects_invalid_integer_field() -> None: + assert not is_valid_row( + { + "pr_id": "abc", + "repo_name": "owner/repo", + "language": "Python", + "title": "Inteiro invalido", + "state": "open", + "additions": "1", + "deletions": "0", + "changed_files": "1", + } + ) + + +def test_read_prs_filters_malformed_rows(tmp_path: Path) -> None: + csv_file = tmp_path / "prs.csv" + csv_file.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at,additions,deletions,changed_files\n" + "1,owner/repo,Python,Valido,Body,open,2026-05-01T10:00:00Z,,5,1,2\n" + "abc,owner/repo,Python,Invalido,Body,open,2026-05-01T10:00:00Z,,5,1,2\n" + "3,,Python,Sem repo,Body,open,2026-05-01T10:00:00Z,,5,1,2\n", + encoding="utf-8", + ) + + assert list(read_prs(str(csv_file))) == [ + PRRecord( + pr_id=1, + repo_name="owner/repo", + language="python", + title="Valido", + body="Body", + state="open", + created_at="2026-05-01T10:00:00Z", + merged_at="", + additions=5, + deletions=1, + changed_files=2, + ) + ] + + +def test_read_prs_returns_empty_for_empty_csv(tmp_path: Path) -> None: + csv_file = tmp_path / "vazio.csv" + csv_file.write_text("", encoding="utf-8") + + assert list(read_prs(csv_file)) == [] + + +def test_read_prs_returns_empty_for_header_only_csv(tmp_path: Path) -> None: + csv_file = tmp_path / "apenas_cabecalho.csv" + csv_file.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at,additions,deletions,changed_files\n", + encoding="utf-8", + ) + + assert list(read_prs(csv_file)) == [] + + +def test_read_prs_ignores_unexpected_extra_fields(tmp_path: Path) -> None: + csv_file = tmp_path / "campos_extras.csv" + csv_file.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at,additions,deletions,changed_files,reviewers,labels\n" + "9,owner/repo,Python,Com extras,Body,open,2026-05-04T10:00:00Z,,6,2,1,ana;bia,backend\n", + encoding="utf-8", + ) + + assert list(read_prs(csv_file)) == [ + PRRecord( + pr_id=9, + repo_name="owner/repo", + language="python", + title="Com extras", + body="Body", + state="open", + created_at="2026-05-04T10:00:00Z", + merged_at="", + additions=6, + deletions=2, + changed_files=1, + ) + ] + + +def test_read_prs_supports_latin_1_csv(tmp_path: Path) -> None: + csv_file = tmp_path / "prs_latin1.csv" + csv_file.write_text( + "number,repository,primary_language,title,description,status,created,merged,additions,deletions,files_changed\n" + "8,owner/repo,Python,Correção,Descrição com acento,open,2026-05-03T10:00:00Z,,4,1,1\n", + encoding="latin-1", + ) + + assert next(read_prs(str(csv_file), encoding="latin-1")) == PRRecord( + pr_id=8, + repo_name="owner/repo", + language="python", + title="Correção", + body="Descrição com acento", + state="open", + created_at="2026-05-03T10:00:00Z", + merged_at="", + additions=4, + deletions=1, + changed_files=1, + ) + + +def test_read_prs_returns_generator_of_pr_records(tmp_path: Path) -> None: + csv_file = tmp_path / "prs.csv" + csv_file.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at,additions,deletions,changed_files\n" + "1,owner/repo,Python,First PR,Body,merged,2026-05-01T10:00:00Z,2026-05-02T10:00:00Z,5,1,2\n", + encoding="utf-8", + ) + + records = read_prs(str(csv_file)) + + assert isinstance(records, GeneratorType) + assert next(records) == PRRecord( + pr_id=1, + repo_name="owner/repo", + language="python", + title="First PR", + body="Body", + state="merged", + created_at="2026-05-01T10:00:00Z", + merged_at="2026-05-02T10:00:00Z", + additions=5, + deletions=1, + changed_files=2, + ) + + +def test_read_prs_integrates_with_build_pipeline_lazily(tmp_path: Path) -> None: + csv_file = tmp_path / "prs.csv" + csv_file.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at,additions,deletions,changed_files\n" + "1,owner/repo,Python,First PR,Body,open,2026-05-01T10:00:00Z,,5,1,2\n" + "2,owner/repo,Go,Second PR,Body,closed,2026-05-02T10:00:00Z,,3,2,1\n", + encoding="utf-8", + ) + + pipeline = build_pipeline( + read_prs(str(csv_file)), + filters=(lambda pr: pr.state == "open",), + mappers=(lambda pr: pr,), + ) + + assert not isinstance(pipeline, list | tuple) + assert list(pipeline) == [ + PRRecord( + pr_id=1, + repo_name="owner/repo", + language="python", + title="First PR", + body="Body", + state="open", + created_at="2026-05-01T10:00:00Z", + merged_at="", + additions=5, + deletions=1, + changed_files=2, + ) + ] diff --git a/tests/test_io/test_exporters.py b/tests/test_io/test_exporters.py new file mode 100644 index 0000000..90f0896 --- /dev/null +++ b/tests/test_io/test_exporters.py @@ -0,0 +1,99 @@ +import csv +import json +from pathlib import Path + +import pytest + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.io.exporters import export_to_csv, export_to_json, serialize_records + + +def _record() -> PRRecord: + return PRRecord( + pr_id=1, + repo_name="owner/repo", + language="python", + title="Add exporter", + body="Implements CSV and JSON export", + state="open", + created_at="2026-05-10T10:00:00Z", + merged_at="", + additions=12, + deletions=3, + changed_files=2, + ) + + +def test_serialize_records_converts_named_tuple_to_dict() -> None: + assert serialize_records([_record()]) == [ + { + "pr_id": 1, + "repo_name": "owner/repo", + "language": "python", + "title": "Add exporter", + "body": "Implements CSV and JSON export", + "state": "open", + "created_at": "2026-05-10T10:00:00Z", + "merged_at": "", + "additions": 12, + "deletions": 3, + "changed_files": 2, + } + ] + + +def test_serialize_records_copies_dict_records() -> None: + original = {"repo_name": "owner/repo", "state": "open"} + + result = serialize_records([original]) + + assert result == [{"repo_name": "owner/repo", "state": "open"}] + assert result[0] is not original + + +def test_export_to_csv_writes_serialized_records(tmp_path: Path) -> None: + csv_file = tmp_path / "prs.csv" + + export_to_csv([_record()], csv_file) + + with csv_file.open(encoding="utf-8", newline="") as output: + rows = list(csv.DictReader(output)) + + assert rows == [ + { + "pr_id": "1", + "repo_name": "owner/repo", + "language": "python", + "title": "Add exporter", + "body": "Implements CSV and JSON export", + "state": "open", + "created_at": "2026-05-10T10:00:00Z", + "merged_at": "", + "additions": "12", + "deletions": "3", + "changed_files": "2", + } + ] + + +def test_export_to_csv_writes_empty_file_for_empty_records(tmp_path: Path) -> None: + csv_file = tmp_path / "empty.csv" + + export_to_csv([], csv_file) + + assert csv_file.read_text(encoding="utf-8") == "" + + +def test_export_to_json_writes_serialized_records(tmp_path: Path) -> None: + json_file = tmp_path / "prs.json" + + export_to_json([_record()], json_file) + + assert json.loads(json_file.read_text(encoding="utf-8")) == serialize_records( + [_record()] + ) + + +def test_serialize_records_rejects_unknown_record_type() -> None: + with pytest.raises(TypeError, match="Mapping ou objeto compatível com NamedTuple"): + serialize_records([object()]) diff --git a/tests/test_io/test_io_pipeline_export_integration.py b/tests/test_io/test_io_pipeline_export_integration.py new file mode 100644 index 0000000..89f3332 --- /dev/null +++ b/tests/test_io/test_io_pipeline_export_integration.py @@ -0,0 +1,52 @@ +import csv +import json +from pathlib import Path + +from pr_analyzer.io.csv_reader import read_prs +from pr_analyzer.io.exporters import export_to_csv, export_to_json +from pr_analyzer.pipeline.builder import build_pipeline +from pr_analyzer.transforms.filters import by_state +from pr_analyzer.transforms.mappers import compute_stats + + +def test_read_pipeline_and_export_end_to_end(tmp_path: Path) -> None: + csv_entrada = tmp_path / "prs.csv" + csv_saida = tmp_path / "resultado.csv" + json_saida = tmp_path / "resultado.json" + csv_entrada.write_text( + "pr_id,repo_name,language,title,body,state,created_at,merged_at,additions,deletions,changed_files\n" + "1,owner/repo,Python,Aberto,Texto da descricao,open,2026-05-20T10:00:00Z,,10,4,2\n" + "2,owner/repo,Python,Fechado,Outro texto,closed,2026-05-21T10:00:00Z,,5,1,1\n", + encoding="utf-8", + ) + + resultado = tuple( + build_pipeline( + read_prs(csv_entrada), + filters=(by_state("open"),), + mappers=(compute_stats,), + ) + ) + + export_to_csv(resultado, csv_saida) + export_to_json(resultado, json_saida) + + with csv_saida.open(encoding="utf-8", newline="") as arquivo_csv: + linhas_csv = list(csv.DictReader(arquivo_csv)) + + assert linhas_csv == [ + { + "body_char_count": "18", + "body_word_count": "3", + "total_changes": "14", + "is_merged": "False", + } + ] + assert json.loads(json_saida.read_text(encoding="utf-8")) == [ + { + "body_char_count": 18, + "body_word_count": 3, + "total_changes": 14, + "is_merged": False, + } + ] diff --git a/tests/test_llm/test_classifiers.py b/tests/test_llm/test_classifiers.py new file mode 100644 index 0000000..9ba2f23 --- /dev/null +++ b/tests/test_llm/test_classifiers.py @@ -0,0 +1,293 @@ +"""Testes para src/pr_analyzer/llm/classifiers.py — TASK-09, TASK-34, TASK-35.""" + +import os +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from dotenv import load_dotenv + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.llm.classifiers import ( + NATUREZAS_CONTRIBUICAO, + NIVEIS_CLAREZA_DESCRICAO, + TIPOS_PROJETO, + avaliar_clareza_descricao, + classificar_natureza_contribuicao, + classificar_tipo_projeto, + classify_repos_batch, + enrich_prs, +) +from pr_analyzer.llm.client import create_groq_client +from pr_analyzer.pipeline.builder import EnrichedPR + + +@pytest.fixture() # type: ignore[misc] +def mock_client() -> MagicMock: + return MagicMock() + + +# ── frozensets ──────────────────────────────────────────────────────────────── + + +def test_tipos_projeto_é_frozenset() -> None: + assert isinstance(TIPOS_PROJETO, frozenset) + + +def test_naturezas_contribuicao_é_frozenset() -> None: + assert isinstance(NATUREZAS_CONTRIBUICAO, frozenset) + + +def test_niveis_clareza_descricao_é_frozenset() -> None: + assert isinstance(NIVEIS_CLAREZA_DESCRICAO, frozenset) + + +def test_tipos_projeto_nao_esta_vazio() -> None: + assert len(TIPOS_PROJETO) > 0 + + +def test_naturezas_contribuicao_nao_esta_vazio() -> None: + assert len(NATUREZAS_CONTRIBUICAO) > 0 + + +def test_niveis_clareza_descricao_nao_esta_vazio() -> None: + assert len(NIVEIS_CLAREZA_DESCRICAO) > 0 + + +# ── classificar_tipo_projeto ───────────────────────────────────────────────────── + + +def test_classificar_tipo_projeto_parse_json(mock_client: MagicMock) -> None: + mock_client.run.return_value.content = '{"tipo_projeto": "biblioteca"}' + result = classificar_tipo_projeto("repo", ["title"], mock_client) + assert result == "biblioteca" + # Garantir que a palavra JSON está no prompt + assert "JSON" in mock_client.run.call_args[0][0].upper() + + +def test_classificar_tipo_projeto_fallback_invalid_json(mock_client: MagicMock) -> None: + mock_client.run.return_value.content = "invalid json" + result = classificar_tipo_projeto("repo", ["title"], mock_client) + assert result == "outro" + + +def test_classificar_tipo_projeto_fallback_invalid_value( + mock_client: MagicMock, +) -> None: + mock_client.run.return_value.content = '{"tipo_projeto": "valor_invalido"}' + result = classificar_tipo_projeto("repo", ["title"], mock_client) + assert result == "outro" + + +@pytest.mark.integration() +def test_classificar_tipo_projeto_integration() -> None: + load_dotenv() + if "GROQ_API_KEY" not in os.environ: + pytest.skip("Requer GROQ_API_KEY no .env") + client = create_groq_client() + result = classificar_tipo_projeto( + "django/django", ["fix admin bug", "add feature"], client + ) + assert result in TIPOS_PROJETO + + +# ── classificar_natureza_contribuicao ────────────────────────────────────────────── + + +def test_classificar_natureza_contribuicao_parse_json(mock_client: MagicMock) -> None: + mock_client.run.return_value.content = '{"natureza": "bug fix"}' + result = classificar_natureza_contribuicao( + "Fix memory leak", "Body content", mock_client + ) + assert result == "bug fix" + prompt = mock_client.run.call_args[0][0] + assert "JSON" in prompt.upper() + + +def test_classificar_natureza_contribuicao_truncates_body_to_300( + mock_client: MagicMock, +) -> None: + mock_client.run.return_value.content = '{"natureza": "outro"}' + long_body = "A" * 500 + classificar_natureza_contribuicao("title", long_body, mock_client) + prompt = mock_client.run.call_args[0][0] + assert len(long_body) > 300 + assert "A" * 300 in prompt + assert "A" * 301 not in prompt + + +def test_classificar_natureza_contribuicao_fallback_invalid_json( + mock_client: MagicMock, +) -> None: + mock_client.run.return_value.content = "invalid" + result = classificar_natureza_contribuicao("title", "body", mock_client) + assert result == "outro" + + +def test_classificar_natureza_contribuicao_fallback_invalid_value( + mock_client: MagicMock, +) -> None: + mock_client.run.return_value.content = '{"natureza": "invalido"}' + result = classificar_natureza_contribuicao("title", "body", mock_client) + assert result == "outro" + + +# ── avaliar_clareza_descricao ────────────────────────────────────────────── + + +def test_avaliar_clareza_descricao_short_circuit_empty(mock_client: MagicMock) -> None: + result = avaliar_clareza_descricao(" \n", mock_client) + assert result == "insuficiente" + mock_client.run.assert_not_called() + + +def test_avaliar_clareza_descricao_truncates_body_to_500( + mock_client: MagicMock, +) -> None: + mock_client.run.return_value.content = "boa" + long_body = "B" * 600 + avaliar_clareza_descricao(long_body, mock_client) + prompt = mock_client.run.call_args[0][0] + assert "B" * 500 in prompt + assert "B" * 501 not in prompt + + +def test_avaliar_clareza_descricao_valid_return(mock_client: MagicMock) -> None: + mock_client.run.return_value.content = '{"clareza": "excelente"}' + result = avaliar_clareza_descricao("Good body", mock_client) + assert result == "excelente" + + +def test_avaliar_clareza_descricao_fallback_invalid(mock_client: MagicMock) -> None: + mock_client.run.return_value.content = "muito bom (invalido)" + result = avaliar_clareza_descricao("Good body", mock_client) + assert result == "insuficiente" + + +# ── classify_repos_batch (TASK-34) ──────────────────────────────────────────── + + +def test_classify_repos_batch_vazio(mock_client: MagicMock) -> None: + result = classify_repos_batch({}, mock_client) + assert result == {} + mock_client.run.assert_not_called() + + +def test_classify_repos_batch_um_repo_uma_chamada_llm( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.return_value.content = '{"tipo_projeto": "biblioteca"}' + groups: dict[str, list[PRRecord]] = {"org/repo": [sample_pr] * 10} + classify_repos_batch(groups, mock_client) + assert mock_client.run.call_count == 1 + + +def test_classify_repos_batch_dois_repos_duas_chamadas( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.return_value.content = '{"tipo_projeto": "framework"}' + groups: dict[str, list[PRRecord]] = { + "org/repo1": [sample_pr], + "org/repo2": [sample_pr._replace(repo_name="org/repo2")], + } + classify_repos_batch(groups, mock_client) + assert mock_client.run.call_count == 2 + + +def test_classify_repos_batch_retorna_dict_str_str( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.return_value.content = '{"tipo_projeto": "ferramenta"}' + result = classify_repos_batch({"org/repo": [sample_pr]}, mock_client) + assert isinstance(result, dict) + assert result["org/repo"] == "ferramenta" + + +def test_classify_repos_batch_fallback_json_invalido( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.return_value.content = "não é json" + result = classify_repos_batch({"org/repo": [sample_pr]}, mock_client) + assert result["org/repo"] == "outro" + + +def test_classify_repos_batch_inclui_titulos_no_prompt( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.return_value.content = '{"tipo_projeto": "aplicação web"}' + classify_repos_batch({"org/repo": [sample_pr]}, mock_client) + prompt = mock_client.run.call_args[0][0] + assert sample_pr.title in prompt + + +# ── enrich_prs (TASK-35) ────────────────────────────────────────────────────── + + +def test_enrich_prs_retorna_iteravel( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + result = enrich_prs([sample_pr], mock_client) + assert hasattr(result, "__iter__") + assert not isinstance(result, list | tuple) + + +def test_enrich_prs_e_lazy(mock_client: MagicMock, sample_pr: PRRecord) -> None: + enrich_prs(iter([sample_pr, sample_pr]), mock_client) + mock_client.run.assert_not_called() + + +def test_enrich_prs_produz_enriched_pr( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.side_effect = [ + MagicMock(content='{"tipo_projeto": "biblioteca"}'), + MagicMock(content='{"natureza": "bug fix"}'), + MagicMock(content="boa"), + ] + result = list(enrich_prs([sample_pr], mock_client)) + assert len(result) == 1 + assert isinstance(result[0], EnrichedPR) + assert result[0].pr == sample_pr + + +def test_enrich_prs_aplica_tres_classificadores( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + mock_client.run.side_effect = [ + MagicMock(content='{"tipo_projeto": "framework"}'), + MagicMock(content='{"natureza": "feature"}'), + MagicMock(content='{"clareza": "excelente"}'), + ] + result = list(enrich_prs([sample_pr], mock_client)) + assert result[0].project_type == "framework" + assert result[0].contribution_nature == "feature" + assert result[0].description_clarity == "excelente" + + +def test_enrich_prs_body_vazio_retorna_insuficiente_sem_chamar_llm( + mock_client: MagicMock, sample_pr: PRRecord +) -> None: + pr_sem_body = sample_pr._replace(body="") + mock_client.run.side_effect = [ + MagicMock(content='{"tipo_projeto": "biblioteca"}'), + MagicMock(content='{"natureza": "bug fix"}'), + ] + result = list(enrich_prs([pr_sem_body], mock_client)) + assert result[0].description_clarity == "insuficiente" + assert mock_client.run.call_count == 2 + + +def test_enrich_prs_cache_persiste_entre_chamadas( + mock_client: MagicMock, sample_pr: PRRecord, tmp_path: Path +) -> None: + cache_file = tmp_path / "enrich.json" + mock_client.run.side_effect = [ + MagicMock(content='{"tipo_projeto": "biblioteca"}'), + MagicMock(content='{"natureza": "bug fix"}'), + MagicMock(content='{"clareza": "boa"}'), + ] + list(enrich_prs([sample_pr], mock_client, cache_path=cache_file)) + + mock_client.run.reset_mock() + list(enrich_prs([sample_pr], mock_client, cache_path=cache_file)) + assert mock_client.run.call_count == 0 diff --git a/tests/test_llm/test_client.py b/tests/test_llm/test_client.py new file mode 100644 index 0000000..c7b15ae --- /dev/null +++ b/tests/test_llm/test_client.py @@ -0,0 +1,71 @@ +"""Testes para src/pr_analyzer/llm/client.py — TASK-08.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) # type: ignore[misc] +def _patch_agno() -> object: + """Impede que o módulo agno seja chamado de verdade em qualquer teste.""" + with ( + patch("pr_analyzer.llm.client.Agent", return_value=MagicMock()), + patch("pr_analyzer.llm.client.Groq", return_value=MagicMock()), + ): + yield + + +def test_create_groq_client_lê_api_key_do_ambiente( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GROQ_API_KEY", "test-key-123") + monkeypatch.setenv("LLM_MODEL", "llama3-8b-8192") + + with patch("pr_analyzer.llm.client.Groq") as mock_groq: + from pr_analyzer.llm.client import create_groq_client + + create_groq_client() + + mock_groq.assert_called_once_with(id="llama3-8b-8192", api_key="test-key-123") + + +def test_create_groq_client_usa_modelo_padrao_sem_llm_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GROQ_API_KEY", "test-key") + monkeypatch.delenv("LLM_MODEL", raising=False) + + with patch("pr_analyzer.llm.client.Groq") as mock_groq: + from pr_analyzer.llm.client import create_groq_client + + create_groq_client() + + _args, kwargs = mock_groq.call_args + assert kwargs.get("id") == "llama3-8b-8192" + + +def test_create_groq_client_levanta_sem_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GROQ_API_KEY", raising=False) + + from pr_analyzer.llm.client import create_groq_client + + with pytest.raises(KeyError): + create_groq_client() + + +def test_create_groq_client_passa_api_key_para_groq( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GROQ_API_KEY", "minha-chave-secreta") + monkeypatch.setenv("LLM_MODEL", "llama3-70b-8192") + + with patch("pr_analyzer.llm.client.Groq") as mock_groq: + from pr_analyzer.llm.client import create_groq_client + + create_groq_client() + + mock_groq.assert_called_once_with( + id="llama3-70b-8192", api_key="minha-chave-secreta" + ) diff --git a/tests/test_pipeline/test_builder.py b/tests/test_pipeline/test_builder.py new file mode 100644 index 0000000..0b88a5a --- /dev/null +++ b/tests/test_pipeline/test_builder.py @@ -0,0 +1,331 @@ +import pytest + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.pipeline.builder import ( + EnrichedPR, + build_pipeline, + compose, + enrich_pipeline, + pipe, + pipeline_from_env, + stats_pipeline, +) + + +def _make_pr(pr_id: int = 1, language: str = "python") -> PRRecord: + return PRRecord( + pr_id=pr_id, + repo_name="org/repo", + language=language, + title="Fix bug", + body="Some body text.", + state="merged", + created_at="2024-01-01", + merged_at="2024-01-02", + additions=5, + deletions=2, + changed_files=1, + ) + + +def _classify(pr: PRRecord) -> EnrichedPR: + return EnrichedPR( + pr=pr, + project_type="biblioteca", + contribution_nature="bug fix", + description_clarity="boa", + ) + + +# ── TASK-25: compose() ──────────────────────────────────────────────────────── + + +def test_compose_returns_callable() -> None: + assert callable(compose(str)) + + +def test_compose_no_args_is_identity() -> None: + assert compose()(42) == 42 + + +def test_compose_single_fn() -> None: + double = lambda x: x * 2 + assert compose(double)(3) == 6 + + +def test_compose_two_fns() -> None: + double = lambda x: x * 2 + add_one = lambda x: x + 1 + assert compose(double, add_one)(3) == 7 + + +def test_compose_three_fns() -> None: + double = lambda x: x * 2 + add_one = lambda x: x + 1 + negate = lambda x: -x + assert compose(double, add_one, negate)(3) == -7 + + +# ── TASK-25: pipe() ─────────────────────────────────────────────────────────── + + +def test_pipe_no_fns_returns_value() -> None: + assert pipe(42) == 42 + + +def test_pipe_single_fn() -> None: + double = lambda x: x * 2 + assert pipe(3, double) == 6 + + +def test_pipe_two_fns() -> None: + double = lambda x: x * 2 + add_one = lambda x: x + 1 + assert pipe(3, double, add_one) == 7 + + +def test_pipe_three_fns() -> None: + double = lambda x: x * 2 + add_one = lambda x: x + 1 + negate = lambda x: -x + assert pipe(3, double, add_one, negate) == -7 + + +def test_pipe_consistent_with_compose() -> None: + double = lambda x: x * 2 + add_one = lambda x: x + 1 + assert pipe(3, double, add_one) == compose(double, add_one)(3) + + +# ── TASK-26: build_pipeline() ──────────────────────────────────────────────── +# Mocks: lambdas simples no lugar de PRRecord + filtros/mappers reais do dev2. +# No merge da Sprint 3, os testes de integração substituem estes mocks. + + +def test_build_pipeline_sem_filtros_sem_mappers() -> None: + result = list(build_pipeline([1, 2, 3], filters=(), mappers=())) + assert result == [1, 2, 3] + + +def test_build_pipeline_filtro_unico() -> None: + is_even = lambda x: x % 2 == 0 + result = list(build_pipeline([1, 2, 3, 4, 5], filters=(is_even,), mappers=())) + assert result == [2, 4] + + +def test_build_pipeline_filtros_combinados() -> None: + is_even = lambda x: x % 2 == 0 + gt_two = lambda x: x > 2 + result = list( + build_pipeline([1, 2, 3, 4, 5, 6], filters=(is_even, gt_two), mappers=()) + ) + assert result == [4, 6] + + +def test_build_pipeline_mapper_unico() -> None: + double = lambda x: x * 2 + result = list(build_pipeline([1, 2, 3], filters=(), mappers=(double,))) + assert result == [2, 4, 6] + + +def test_build_pipeline_filtro_depois_mapper() -> None: + is_even = lambda x: x % 2 == 0 + double = lambda x: x * 2 + result = list( + build_pipeline([1, 2, 3, 4, 5], filters=(is_even,), mappers=(double,)) + ) + assert result == [4, 8] + + +def test_build_pipeline_mappers_compostos() -> None: + double = lambda x: x * 2 + add_one = lambda x: x + 1 + result = list(build_pipeline([1, 2, 3], filters=(), mappers=(double, add_one))) + assert result == [3, 5, 7] + + +def test_build_pipeline_retorna_iteravel_lazy() -> None: + calls: list[int] = [] + + def rastrear(x: int) -> int: + calls.append(x) + return x + + result = build_pipeline(range(1000), filters=(), mappers=(rastrear,)) + assert not isinstance(result, list | tuple) + next(iter(result)) + assert len(calls) == 1 + + +def test_build_pipeline_source_vazio() -> None: + is_even = lambda x: x % 2 == 0 + result = list(build_pipeline([], filters=(is_even,), mappers=())) + assert result == [] + + +# ── enrich_pipeline ─────────────────────────────────────────────────────────── + + +def test_enrich_pipeline_applies_classify_fn() -> None: + prs = [_make_pr(1), _make_pr(2)] + result = list(enrich_pipeline(prs, _classify)) + assert len(result) == 2 + assert all(r.project_type == "biblioteca" for r in result) + + +def test_enrich_pipeline_preserves_original_pr() -> None: + pr = _make_pr(42) + result = list(enrich_pipeline([pr], _classify)) + assert result[0].pr == pr + + +def test_enrich_pipeline_empty_source() -> None: + assert list(enrich_pipeline([], _classify)) == [] + + +def test_enrich_pipeline_is_lazy() -> None: + calls: list[int] = [] + + def counting_classify(pr: PRRecord) -> EnrichedPR: + calls.append(1) + return _classify(pr) + + pipeline = enrich_pipeline(iter([_make_pr(), _make_pr()]), counting_classify) + assert not isinstance(pipeline, list | tuple) + next(iter(pipeline)) + assert len(calls) == 1 + + +# ── stats_pipeline ──────────────────────────────────────────────────────────── + + +def test_stats_pipeline_returns_stats_for_each_pr() -> None: + prs = [_make_pr(1), _make_pr(2), _make_pr(3)] + result = list(stats_pipeline(prs)) + assert len(result) == 3 + + +def test_stats_pipeline_computes_correct_total_changes() -> None: + pr = _make_pr() + result = list(stats_pipeline([pr])) + assert result[0].total_changes == 7 # additions=5 + deletions=2 from _make_pr() + + +def test_stats_pipeline_empty_source() -> None: + assert list(stats_pipeline([])) == [] + + +def test_stats_pipeline_is_lazy() -> None: + pipeline = stats_pipeline(iter([_make_pr(), _make_pr()])) + assert not isinstance(pipeline, list | tuple) + next(iter(pipeline)) + + +# ── pipeline_from_env (ISSUE-46) ────────────────────────────────────────────── + + +def _make_pr_state(state: str, additions: int = 5, deletions: int = 2) -> PRRecord: + return PRRecord( + pr_id=1, + repo_name="org/repo", + language="python", + title="Fix bug", + body="Body.", + state=state, + created_at="2024-01-01", + merged_at="2024-01-02", + additions=additions, + deletions=deletions, + changed_files=1, + ) + + +def test_pipeline_from_env_sem_vars_passa_tudo(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FILTER_STATE", raising=False) + monkeypatch.delenv("MIN_CHANGES", raising=False) + monkeypatch.delenv("ENABLE_LLM", raising=False) + prs = [_make_pr_state("merged"), _make_pr_state("open")] + assert list(pipeline_from_env(iter(prs))) == prs + + +def test_pipeline_from_env_filter_state(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FILTER_STATE", "merged") + monkeypatch.delenv("MIN_CHANGES", raising=False) + monkeypatch.delenv("ENABLE_LLM", raising=False) + prs = [_make_pr_state("merged"), _make_pr_state("open")] + result = list(pipeline_from_env(iter(prs))) + assert len(result) == 1 + assert result[0].state == "merged" + + +def test_pipeline_from_env_min_changes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FILTER_STATE", raising=False) + monkeypatch.setenv("MIN_CHANGES", "10") + monkeypatch.delenv("ENABLE_LLM", raising=False) + prs = [ + _make_pr_state("merged", additions=3, deletions=2), + _make_pr_state("merged", additions=8, deletions=5), + ] + result = list(pipeline_from_env(iter(prs))) + assert len(result) == 1 + assert (result[0].additions or 0) + (result[0].deletions or 0) >= 10 + + +def test_pipeline_from_env_filtros_combinados(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FILTER_STATE", "merged") + monkeypatch.setenv("MIN_CHANGES", "10") + monkeypatch.delenv("ENABLE_LLM", raising=False) + prs = [ + _make_pr_state("merged", additions=8, deletions=5), + _make_pr_state("merged", additions=2, deletions=1), + _make_pr_state("open", additions=8, deletions=5), + ] + result = list(pipeline_from_env(iter(prs))) + assert len(result) == 1 + assert result[0].state == "merged" + + +def test_pipeline_from_env_enable_llm_com_fn(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FILTER_STATE", raising=False) + monkeypatch.delenv("MIN_CHANGES", raising=False) + monkeypatch.setenv("ENABLE_LLM", "true") + pr = _make_pr_state("merged") + result = list(pipeline_from_env(iter([pr]), classify_fn=_classify)) + assert isinstance(result[0], EnrichedPR) + + +def test_pipeline_from_env_enable_llm_sem_fn_nao_aplica( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FILTER_STATE", raising=False) + monkeypatch.delenv("MIN_CHANGES", raising=False) + monkeypatch.setenv("ENABLE_LLM", "true") + pr = _make_pr_state("merged") + result = list(pipeline_from_env(iter([pr]))) + assert result[0] == pr + + +def test_pipeline_from_env_enable_llm_false_ignora_fn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("FILTER_STATE", raising=False) + monkeypatch.delenv("MIN_CHANGES", raising=False) + monkeypatch.setenv("ENABLE_LLM", "false") + pr = _make_pr_state("merged") + result = list(pipeline_from_env(iter([pr]), classify_fn=_classify)) + assert result[0] == pr + + +def test_pipeline_from_env_e_lazy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FILTER_STATE", raising=False) + monkeypatch.delenv("MIN_CHANGES", raising=False) + monkeypatch.delenv("ENABLE_LLM", raising=False) + result = pipeline_from_env(iter([_make_pr_state("merged")])) + assert not isinstance(result, list | tuple) + + +def test_pipeline_from_env_source_vazio(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FILTER_STATE", "merged") + monkeypatch.setenv("MIN_CHANGES", "5") + monkeypatch.delenv("ENABLE_LLM", raising=False) + assert list(pipeline_from_env(iter([]))) == [] diff --git a/tests/test_transforms/test_filters.py b/tests/test_transforms/test_filters.py new file mode 100644 index 0000000..9bac793 --- /dev/null +++ b/tests/test_transforms/test_filters.py @@ -0,0 +1,175 @@ +from typing import NamedTuple + +from pr_analyzer.transforms.filters import ( + by_date_range, + by_language, + by_state, + combine_filters, + with_min_size, + with_non_empty_body, +) + + +class DummyPR(NamedTuple): + state: str = "" + language: str = "" + created_at: str = "" + body: str = "" + additions: int = 0 + deletions: int = 0 + + +def test_by_state_match() -> None: + pr = DummyPR(state="OPEN", language="Python") + predicate = by_state("open") + assert predicate(pr) + + +def test_by_state_mismatch() -> None: + pr = DummyPR(state="CLOSED", language="Python") + predicate = by_state("open") + assert not predicate(pr) + + +def test_by_state_case_insensitive() -> None: + pr1 = DummyPR(state="OpEn", language="Python") + pr2 = DummyPR(state="open", language="Python") + predicate = by_state("OpeN") + assert predicate(pr1) + assert predicate(pr2) + + +def test_by_language_match() -> None: + pr = DummyPR(state="OPEN", language="Python") + predicate = by_language("python") + assert predicate(pr) + + +def test_by_language_mismatch() -> None: + pr = DummyPR(state="OPEN", language="Java") + predicate = by_language("python") + assert not predicate(pr) + + +def test_by_language_case_insensitive() -> None: + pr1 = DummyPR(state="OPEN", language="pYtHoN") + pr2 = DummyPR(state="OPEN", language="python") + predicate = by_language("PyThon") + assert predicate(pr1) + assert predicate(pr2) + + +def test_by_date_range_inside() -> None: + pr = DummyPR(created_at="2023-05-15T10:00:00Z") + predicate = by_date_range("2023-05-01", "2023-05-31") + assert predicate(pr) + + +def test_by_date_range_outside() -> None: + pr = DummyPR(created_at="2023-06-01T10:00:00Z") + predicate = by_date_range("2023-05-01", "2023-05-31") + assert not predicate(pr) + + +def test_by_date_range_boundaries() -> None: + pr_start = DummyPR(created_at="2023-05-01T00:00:00Z") + pr_end = DummyPR(created_at="2023-05-31T23:59:59Z") + predicate = by_date_range("2023-05-01", "2023-05-31") + assert predicate(pr_start) + assert predicate(pr_end) + + +def test_with_non_empty_body_valid() -> None: + pr = DummyPR(body="Fixes a bug") + predicate = with_non_empty_body() + assert predicate(pr) + + +def test_with_non_empty_body_empty_or_spaces() -> None: + pr1 = DummyPR(body="") + pr2 = DummyPR(body=" \n ") + predicate = with_non_empty_body() + assert not predicate(pr1) + assert not predicate(pr2) + + +def test_with_min_size() -> None: + pr = DummyPR(additions=50, deletions=20) + predicate1 = with_min_size(50) + predicate2 = with_min_size(100) + assert predicate1(pr) + assert not predicate2(pr) + + +def test_combine_filters_multiple() -> None: + pr1 = DummyPR(state="OPEN", language="Python") + pr2 = DummyPR(state="OPEN", language="Java") + pr3 = DummyPR(state="CLOSED", language="Python") + + predicate = combine_filters(by_state("OPEN"), by_language("Python")) + + assert predicate(pr1) + assert not predicate(pr2) + assert not predicate(pr3) + + +def test_combine_filters_empty() -> None: + pr = DummyPR(state="OPEN", language="Python") + predicate = combine_filters() + assert predicate(pr) + + +def test_by_state_missing_attr() -> None: + pr = DummyPR(state=None) # type: ignore + predicate = by_state("open") + assert not predicate(pr) + + # testing without attribute + class NoStatePR: + pass + + assert not predicate(NoStatePR()) + + +def test_by_language_missing_attr() -> None: + pr = DummyPR(language=None) # type: ignore + predicate = by_language("python") + assert not predicate(pr) + + class NoLangPR: + pass + + assert not predicate(NoLangPR()) + + +def test_by_date_range_missing_attr() -> None: + pr = DummyPR(created_at=None) # type: ignore + predicate = by_date_range("2023-05-01", "2023-05-31") + assert not predicate(pr) + + class NoDatePR: + pass + + assert not predicate(NoDatePR()) + + +def test_with_non_empty_body_missing_attr() -> None: + pr = DummyPR(body=None) # type: ignore + predicate = with_non_empty_body() + assert not predicate(pr) + + class NoBodyPR: + pass + + assert not predicate(NoBodyPR()) + + +def test_with_min_size_type_error() -> None: + pr = DummyPR(additions="many", deletions=None) # type: ignore + predicate = with_min_size(10) + assert not predicate(pr) + + class NoSizePR: + pass + + assert not predicate(NoSizePR()) diff --git a/tests/test_transforms/test_mappers.py b/tests/test_transforms/test_mappers.py new file mode 100644 index 0000000..a3b1665 --- /dev/null +++ b/tests/test_transforms/test_mappers.py @@ -0,0 +1,55 @@ +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.transforms.mappers import PRStats, compute_stats + + +def create_sample_pr( + body: str = "", additions: int = 0, deletions: int = 0, merged_at: str = "" +) -> PRRecord: + return PRRecord( + pr_id=1, + repo_name="test/repo", + language="python", + title="Test PR", + body=body, + state="open", + created_at="2023-05-15T09:00:00Z", + merged_at=merged_at, + additions=additions, + deletions=deletions, + changed_files=1, + ) + + +def test_compute_stats_normal_pr() -> None: + pr = create_sample_pr( + body="This is a test PR with 8 words.", + additions=10, + deletions=5, + merged_at="2023-05-15T10:00:00Z", + ) + stats = compute_stats(pr) + + assert isinstance(stats, PRStats) + assert stats.body_char_count == 31 + assert stats.body_word_count == 8 + assert stats.total_changes == 15 + assert stats.is_merged is True + + +def test_compute_stats_empty_body() -> None: + pr = create_sample_pr(body="") + stats = compute_stats(pr) + assert stats.body_char_count == 0 + assert stats.body_word_count == 0 + + +def test_compute_stats_not_merged() -> None: + pr = create_sample_pr(merged_at="") + stats = compute_stats(pr) + assert stats.is_merged is False + + +def test_compute_stats_zero_changes() -> None: + pr = create_sample_pr(additions=0, deletions=0) + stats = compute_stats(pr) + assert stats.total_changes == 0 diff --git a/tests/test_transforms/test_reducers.py b/tests/test_transforms/test_reducers.py new file mode 100644 index 0000000..edc4826 --- /dev/null +++ b/tests/test_transforms/test_reducers.py @@ -0,0 +1,155 @@ +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.transforms.mappers import PRStats +from pr_analyzer.transforms.reducers import ( + EnrichedPR, + aggregate_stats, + count_by_contribution_nature, + count_by_description_clarity, + count_by_language, + count_by_project_type, + group_by_repo, +) + + +def create_sample_pr(language: str) -> PRRecord: + return PRRecord( + pr_id=1, + repo_name="test/repo", + language=language, + title="Test PR", + body="", + state="open", + created_at="2023-05-15T09:00:00Z", + merged_at="", + additions=0, + deletions=0, + changed_files=1, + ) + + +def test_count_by_language_multiple() -> None: + prs = [ + create_sample_pr("python"), + create_sample_pr("python"), + create_sample_pr("java"), + create_sample_pr("unknown"), + ] + result = count_by_language(prs) + + assert result == {"python": 2, "java": 1, "unknown": 1} + + +def test_count_by_language_empty() -> None: + result = count_by_language([]) + assert result == {} + + +def test_count_by_language_unknown_only() -> None: + prs = [create_sample_pr("unknown")] + result = count_by_language(prs) + assert result == {"unknown": 1} + + +def test_count_by_project_type_multiple() -> None: + prs = [ + EnrichedPR(create_sample_pr("python"), "aplicação web", "feature", "boa"), + EnrichedPR(create_sample_pr("python"), "biblioteca", "bug fix", "excelente"), + EnrichedPR(create_sample_pr("java"), "aplicação web", "refatoração", "boa"), + ] + result = count_by_project_type(prs) + + assert result == {"aplicação web": 2, "biblioteca": 1} + + +def test_count_by_project_type_empty() -> None: + result = count_by_project_type([]) + assert result == {} + + +def test_count_by_contribution_nature_multiple() -> None: + prs = [ + EnrichedPR(create_sample_pr("python"), "aplicação web", "feature", "boa"), + EnrichedPR(create_sample_pr("python"), "biblioteca", "bug fix", "excelente"), + EnrichedPR(create_sample_pr("java"), "aplicação web", "feature", "boa"), + ] + result = count_by_contribution_nature(prs) + assert result == {"feature": 2, "bug fix": 1} + + +def test_count_by_contribution_nature_empty() -> None: + assert count_by_contribution_nature([]) == {} + + +def test_count_by_description_clarity_multiple() -> None: + prs = [ + EnrichedPR(create_sample_pr("python"), "aplicação web", "feature", "boa"), + EnrichedPR(create_sample_pr("python"), "biblioteca", "bug fix", "excelente"), + EnrichedPR(create_sample_pr("java"), "aplicação web", "feature", "boa"), + ] + result = count_by_description_clarity(prs) + assert result == {"boa": 2, "excelente": 1} + + +def test_count_by_description_clarity_empty() -> None: + assert count_by_description_clarity([]) == {} + + +def test_group_by_repo_multiple() -> None: + pr1 = create_sample_pr("python")._replace(repo_name="org/repoA") + pr2 = create_sample_pr("java")._replace(repo_name="org/repoB") + pr3 = create_sample_pr("go")._replace(repo_name="org/repoA") + + prs = [pr1, pr2, pr3] + result = group_by_repo(prs) + + assert result == { + "org/repoA": [pr1, pr3], + "org/repoB": [pr2], + } + # verify immutability + assert len(prs) == 3 + assert prs == [pr1, pr2, pr3] + + +def test_group_by_repo_empty() -> None: + assert group_by_repo([]) == {} + + +def test_aggregate_stats_multiple() -> None: + stats = [ + PRStats( + body_char_count=100, body_word_count=20, total_changes=10, is_merged=True + ), + PRStats( + body_char_count=200, body_word_count=40, total_changes=30, is_merged=False + ), + ] + result = aggregate_stats(stats) + + assert result["avg_chars"] == 150.0 + assert result["avg_words"] == 30.0 + assert result["avg_changes"] == 20.0 + assert result["merge_rate"] == 0.5 + + +def test_aggregate_stats_empty() -> None: + result = aggregate_stats([]) + assert result == { + "avg_chars": 0.0, + "avg_words": 0.0, + "avg_changes": 0.0, + "merge_rate": 0.0, + } + + +def test_aggregate_stats_single() -> None: + stats = [ + PRStats(body_char_count=10, body_word_count=2, total_changes=5, is_merged=True), + ] + result = aggregate_stats(stats) + assert result == { + "avg_chars": 10.0, + "avg_words": 2.0, + "avg_changes": 5.0, + "merge_rate": 1.0, + } diff --git a/tests/test_transforms/test_reducers_property.py b/tests/test_transforms/test_reducers_property.py new file mode 100644 index 0000000..62834d9 --- /dev/null +++ b/tests/test_transforms/test_reducers_property.py @@ -0,0 +1,104 @@ +""" +Testes baseados em propriedades para os transformadores e filtros. + +Este módulo utiliza a biblioteca Hypothesis para verificar invariantes e +propriedades matemáticas das funções de redução, agregação e filtros de PRs. +""" + +from hypothesis import given +from hypothesis import strategies as st + +from pr_analyzer.io.csv_reader import PRRecord +from pr_analyzer.transforms.filters import by_language, with_non_empty_body +from pr_analyzer.transforms.mappers import PRStats +from pr_analyzer.transforms.reducers import aggregate_stats, count_by_language + +pr_record_strategy = st.builds( + PRRecord, + pr_id=st.one_of(st.none(), st.integers()), + repo_name=st.text(), + language=st.text(), + title=st.text(), + body=st.text(), + state=st.text(), + created_at=st.text(), + merged_at=st.text(), + additions=st.one_of(st.none(), st.integers(min_value=0)), + deletions=st.one_of(st.none(), st.integers(min_value=0)), + changed_files=st.one_of(st.none(), st.integers(min_value=0)), +) + +pr_stats_strategy = st.builds( + PRStats, + body_char_count=st.integers(min_value=0), + body_word_count=st.integers(min_value=0), + total_changes=st.integers(min_value=0), + is_merged=st.booleans(), +) + + +@given(st.lists(pr_record_strategy), st.lists(pr_record_strategy)) +def test_count_by_language_concatenation(list1, list2): + """ + Testa a propriedade de que a contagem por linguagem de duas listas concatenadas + é igual à soma dos resultados individuais de cada lista. + + Args: + list1 (list[PRRecord]): A primeira lista de registros de pull requests. + list2 (list[PRRecord]): A segunda lista de registros de pull requests. + """ + res1 = count_by_language(list1) + res2 = count_by_language(list2) + res_combined = count_by_language(list1 + list2) + + all_keys = frozenset(res1.keys()) | frozenset(res2.keys()) + expected = {lang: res1.get(lang, 0) + res2.get(lang, 0) for lang in all_keys} + + assert res_combined == expected + + +@given(pr_stats_strategy) +def test_aggregate_stats_single_element(stat): + """ + Testa a propriedade de que a agregação de estatísticas para um único + elemento retorna exatamente as métricas desse elemento. + + Args: + stat (PRStats): Um objeto contendo as estatísticas de um pull request. + """ + result = aggregate_stats([stat]) + assert result["avg_chars"] == float(stat.body_char_count) + assert result["avg_words"] == float(stat.body_word_count) + assert result["avg_changes"] == float(stat.total_changes) + assert result["merge_rate"] == (1.0 if stat.is_merged else 0.0) + + +@given(st.lists(pr_record_strategy), st.text()) +def test_filter_by_language_idempotent(prs, lang): + """ + Testa a propriedade de idempotência do filtro por linguagem, garantindo que + aplicá-lo duas vezes resulta na mesma saída de aplicá-lo apenas uma vez. + + Args: + prs (list[PRRecord]): Uma lista de registros de pull requests. + lang (str): A linguagem de programação a ser filtrada. + """ + predicate = by_language(lang) + first_pass = list(filter(predicate, prs)) + second_pass = list(filter(predicate, first_pass)) + assert first_pass == second_pass + + +@given(st.lists(pr_record_strategy)) +def test_filter_non_empty_body_idempotent(prs): + """ + Testa a propriedade de idempotência do filtro de corpo não vazio, garantindo que + aplicá-lo duas vezes resulta na mesma saída de aplicá-lo apenas uma vez. + + Args: + prs (list[PRRecord]): Uma lista de registros de pull requests. + """ + predicate = with_non_empty_body() + first_pass = list(filter(predicate, prs)) + second_pass = list(filter(predicate, first_pass)) + assert first_pass == second_pass