first commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,773 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
app = FastAPI(title="Project Agent")
|
||||
|
||||
CONFIG_PATH = "/app/config.yml"
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
project: str | None = None
|
||||
|
||||
|
||||
class TaskFromCodeRequest(BaseModel):
|
||||
project: str
|
||||
text: str
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: str | None = None, timeout: int = 120):
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"cmd": cmd,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
|
||||
|
||||
def strip_markdown_json(text: str) -> str:
|
||||
text = text.strip()
|
||||
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
|
||||
if fenced:
|
||||
return fenced.group(1).strip()
|
||||
return text
|
||||
|
||||
|
||||
def sync_one_project(project: dict):
|
||||
repo_url = project.get("repo_url")
|
||||
repo_path = project.get("repo_path")
|
||||
|
||||
if not repo_url:
|
||||
return {"skipped": True, "reason": "repo_url is empty"}
|
||||
|
||||
if not repo_path:
|
||||
return {"skipped": True, "reason": "repo_path is empty"}
|
||||
|
||||
if os.path.exists(repo_path):
|
||||
fetch = run(["git", "fetch", "--all", "--prune"], cwd=repo_path)
|
||||
pull = run(["git", "pull", "--ff-only"], cwd=repo_path)
|
||||
return {
|
||||
"action": "pull",
|
||||
"fetch": fetch,
|
||||
"pull": pull,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||
clone = run(["git", "clone", repo_url, repo_path])
|
||||
return {
|
||||
"action": "clone",
|
||||
"clone": clone,
|
||||
}
|
||||
|
||||
|
||||
def read_file_safe(path: str, max_chars: int = 12000) -> str:
|
||||
try:
|
||||
if not os.path.isfile(path):
|
||||
return ""
|
||||
if os.path.getsize(path) > 512 * 1024:
|
||||
return ""
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read(max_chars)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def collect_repo_context(repo_path: str, task_text: str) -> str:
|
||||
parts = []
|
||||
|
||||
tree = run(
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
"find . -maxdepth 4 "
|
||||
"-not -path './.git/*' "
|
||||
"-not -path './node_modules/*' "
|
||||
"-not -path './vendor/*' "
|
||||
"-not -path './dist/*' "
|
||||
"-not -path './build/*' "
|
||||
"-not -path './target/*' "
|
||||
"| sort | head -300",
|
||||
],
|
||||
cwd=repo_path,
|
||||
timeout=30,
|
||||
)
|
||||
parts.append("## File tree\n" + tree["stdout"])
|
||||
|
||||
git_log = run(
|
||||
["git", "log", "--oneline", "-15"],
|
||||
cwd=repo_path,
|
||||
timeout=30,
|
||||
)
|
||||
parts.append("## Recent commits\n" + git_log["stdout"])
|
||||
|
||||
candidate_files = [
|
||||
"README.md",
|
||||
"readme.md",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"compose.yml",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"settings.gradle",
|
||||
"go.mod",
|
||||
"Cargo.toml",
|
||||
".gitignore",
|
||||
]
|
||||
|
||||
for rel in candidate_files:
|
||||
content = read_file_safe(os.path.join(repo_path, rel), max_chars=10000)
|
||||
if content:
|
||||
parts.append(f"## {rel}\n{content}")
|
||||
|
||||
words = []
|
||||
for word in re.findall(r"[A-Za-zА-Яа-я0-9_/-]{4,}", task_text):
|
||||
word = word.strip().lower()
|
||||
if word not in words:
|
||||
words.append(word)
|
||||
words = words[:8]
|
||||
|
||||
if words:
|
||||
pattern = "|".join(re.escape(w) for w in words)
|
||||
grep = run(
|
||||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
f"rg -n -i --glob '!node_modules' --glob '!vendor' --glob '!dist' --glob '!build' --glob '!target' \"{pattern}\" . | head -80",
|
||||
],
|
||||
cwd=repo_path,
|
||||
timeout=30,
|
||||
)
|
||||
if grep["stdout"]:
|
||||
parts.append("## Relevant grep matches\n" + grep["stdout"])
|
||||
|
||||
context = "\n\n".join(parts)
|
||||
return context[:50000]
|
||||
|
||||
|
||||
def aimtr_make_task(raw_text: str, repo_context: str) -> dict:
|
||||
base_url = os.getenv("AIMTR_BASE_URL", "").rstrip("/")
|
||||
api_key = os.getenv("AIMTR_API_KEY")
|
||||
model = os.getenv("AIMTR_MODEL", "claude-haiku-4.5")
|
||||
|
||||
if not base_url or not api_key:
|
||||
raise HTTPException(status_code=500, detail="AIMTR_BASE_URL or AIMTR_API_KEY is missing")
|
||||
|
||||
system_prompt = """
|
||||
Ты технический ассистент и тимлид.
|
||||
Тебе дают описание задачи и краткий контекст репозитория.
|
||||
Сформируй задачу для Taiga с учетом структуры кода.
|
||||
|
||||
Отвечай только валидным JSON без markdown.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"title": "короткое название",
|
||||
"description": "описание с учетом контекста кода",
|
||||
"type": "Story",
|
||||
"priority": "low|normal|high",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"acceptance_criteria": [
|
||||
"проверяемый критерий"
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"title": "техническая подзадача",
|
||||
"description": "что сделать и где примерно смотреть в коде",
|
||||
"type": "Task",
|
||||
"priority": "low|normal|high"
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
"уточняющий вопрос, если данных не хватает"
|
||||
],
|
||||
"code_notes": [
|
||||
"заметка по найденному контексту кода"
|
||||
]
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Пиши на русском.
|
||||
- Не выдумывай файлы, если их нет в контексте.
|
||||
- Если предполагаешь файл или модуль, явно пиши 'вероятно' или 'проверить'.
|
||||
- Acceptance criteria должны быть проверяемыми.
|
||||
- Подзадачи должны быть полезны разработчику.
|
||||
""".strip()
|
||||
|
||||
user_prompt = f"""
|
||||
Описание задачи:
|
||||
{raw_text}
|
||||
|
||||
Контекст репозитория:
|
||||
{repo_context}
|
||||
""".strip()
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.json()["choices"][0]["message"]["content"]
|
||||
clean = strip_markdown_json(content)
|
||||
|
||||
try:
|
||||
return json.loads(clean)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"LLM returned invalid JSON: {content[:1000]}") from exc
|
||||
|
||||
|
||||
def taiga_auth() -> str:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
username = os.getenv("TAIGA_USERNAME")
|
||||
password = os.getenv("TAIGA_PASSWORD")
|
||||
|
||||
if not base_url or not username or not password:
|
||||
raise HTTPException(status_code=500, detail="Taiga env vars are missing")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/auth",
|
||||
json={
|
||||
"type": "normal",
|
||||
"username": username,
|
||||
"password": password,
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["auth_token"]
|
||||
|
||||
|
||||
def taiga_headers(token: str):
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def format_story_description(task: dict, raw_text: str) -> str:
|
||||
lines = []
|
||||
|
||||
if task.get("description"):
|
||||
lines.append(task["description"])
|
||||
else:
|
||||
lines.append(raw_text)
|
||||
|
||||
if task.get("code_notes"):
|
||||
lines.append("")
|
||||
lines.append("## Заметки по коду")
|
||||
for item in task["code_notes"]:
|
||||
lines.append(f"- {item}")
|
||||
|
||||
if task.get("acceptance_criteria"):
|
||||
lines.append("")
|
||||
lines.append("## Acceptance criteria")
|
||||
for item in task["acceptance_criteria"]:
|
||||
lines.append(f"- {item}")
|
||||
|
||||
if task.get("questions"):
|
||||
lines.append("")
|
||||
lines.append("## Вопросы / уточнения")
|
||||
for item in task["questions"]:
|
||||
lines.append(f"- {item}")
|
||||
|
||||
if task.get("tags"):
|
||||
lines.append("")
|
||||
lines.append("## Теги")
|
||||
lines.append(", ".join(task["tags"]))
|
||||
|
||||
lines.append("")
|
||||
lines.append("## Исходное описание")
|
||||
lines.append(raw_text)
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def create_userstory(token: str, project_id: int, task: dict, raw_text: str) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/userstories",
|
||||
headers=taiga_headers(token),
|
||||
json={
|
||||
"project": project_id,
|
||||
"subject": (task.get("title") or raw_text)[:500],
|
||||
"description": format_story_description(task, raw_text),
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def create_subtask(token: str, project_id: int, userstory_id: int, child: dict) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/tasks",
|
||||
headers=taiga_headers(token),
|
||||
json={
|
||||
"project": project_id,
|
||||
"user_story": userstory_id,
|
||||
"subject": (child.get("title") or "Подзадача")[:500],
|
||||
"description": child.get("description") or "",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"ok": True,
|
||||
"repos_dir": os.getenv("AGENT_REPOS_DIR", "/repos"),
|
||||
"state_dir": os.getenv("AGENT_STATE_DIR", "/state"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/projects")
|
||||
def projects():
|
||||
config = load_config()
|
||||
return config.get("projects", {})
|
||||
|
||||
|
||||
@app.post("/repos/sync")
|
||||
def sync_repos(req: SyncRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project:
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
selected = {req.project: projects[req.project]}
|
||||
else:
|
||||
selected = projects
|
||||
|
||||
results = {}
|
||||
for name, project in selected.items():
|
||||
results[name] = sync_one_project(project)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/tasks/from-code")
|
||||
def task_from_code(req: TaskFromCodeRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
|
||||
project = projects[req.project]
|
||||
taiga_project_id = project.get("taiga_project_id")
|
||||
repo_path = project.get("repo_path")
|
||||
|
||||
if not taiga_project_id:
|
||||
raise HTTPException(status_code=400, detail="taiga_project_id is missing")
|
||||
|
||||
sync_result = sync_one_project(project)
|
||||
|
||||
if not repo_path or not os.path.exists(repo_path):
|
||||
raise HTTPException(status_code=400, detail="Repo path does not exist after sync")
|
||||
|
||||
repo_context = collect_repo_context(repo_path, req.text)
|
||||
structured = aimtr_make_task(req.text, repo_context)
|
||||
|
||||
token = taiga_auth()
|
||||
story = create_userstory(token, int(taiga_project_id), structured, req.text)
|
||||
|
||||
subtasks = []
|
||||
for child in structured.get("children", []):
|
||||
if isinstance(child, dict):
|
||||
subtasks.append(create_subtask(token, int(taiga_project_id), story["id"], child))
|
||||
|
||||
return {
|
||||
"project": req.project,
|
||||
"sync": sync_result,
|
||||
"story": {
|
||||
"id": story["id"],
|
||||
"ref": story["ref"],
|
||||
"subject": story["subject"],
|
||||
},
|
||||
"subtasks": [
|
||||
{
|
||||
"id": task.get("id"),
|
||||
"ref": task.get("ref"),
|
||||
"subject": task.get("subject"),
|
||||
}
|
||||
for task in subtasks
|
||||
],
|
||||
"structured": structured,
|
||||
}
|
||||
class NextActionRequest(BaseModel):
|
||||
project: str
|
||||
minutes: int = 25
|
||||
energy: str = "normal"
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def compact_userstory(us: dict) -> dict:
|
||||
return {
|
||||
"id": us.get("id"),
|
||||
"ref": us.get("ref"),
|
||||
"subject": us.get("subject"),
|
||||
"status": (us.get("status_extra_info") or {}).get("name"),
|
||||
"is_closed": us.get("is_closed"),
|
||||
"assigned_to": (us.get("assigned_to_extra_info") or {}).get("username"),
|
||||
"created_date": us.get("created_date"),
|
||||
"modified_date": us.get("modified_date"),
|
||||
"description": (us.get("description") or "")[:1500],
|
||||
}
|
||||
|
||||
|
||||
def compact_task(task: dict) -> dict:
|
||||
return {
|
||||
"id": task.get("id"),
|
||||
"ref": task.get("ref"),
|
||||
"subject": task.get("subject"),
|
||||
"status": (task.get("status_extra_info") or {}).get("name"),
|
||||
"is_closed": task.get("is_closed"),
|
||||
"assigned_to": (task.get("assigned_to_extra_info") or {}).get("username"),
|
||||
"user_story": task.get("user_story"),
|
||||
"created_date": task.get("created_date"),
|
||||
"modified_date": task.get("modified_date"),
|
||||
"description": (task.get("description") or "")[:1200],
|
||||
}
|
||||
|
||||
|
||||
def taiga_get_backlog(token: str, project_id: int) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
stories_resp = requests.get(
|
||||
f"{base_url}/api/v1/userstories",
|
||||
headers=taiga_headers(token),
|
||||
params={
|
||||
"project": project_id,
|
||||
"order_by": "-modified_date",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
stories_resp.raise_for_status()
|
||||
|
||||
tasks_resp = requests.get(
|
||||
f"{base_url}/api/v1/tasks",
|
||||
headers=taiga_headers(token),
|
||||
params={
|
||||
"project": project_id,
|
||||
"order_by": "-modified_date",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
tasks_resp.raise_for_status()
|
||||
|
||||
stories = stories_resp.json()
|
||||
tasks = tasks_resp.json()
|
||||
|
||||
open_stories = [compact_userstory(x) for x in stories if not x.get("is_closed")]
|
||||
open_tasks = [compact_task(x) for x in tasks if not x.get("is_closed")]
|
||||
|
||||
return {
|
||||
"userstories": open_stories[:50],
|
||||
"tasks": open_tasks[:80],
|
||||
}
|
||||
|
||||
|
||||
def aimtr_next_action(project_name: str, minutes: int, energy: str, notes: str | None, backlog: dict) -> dict:
|
||||
base_url = os.getenv("AIMTR_BASE_URL", "").rstrip("/")
|
||||
api_key = os.getenv("AIMTR_API_KEY")
|
||||
model = os.getenv("AIMTR_MODEL", "claude-haiku-4.5")
|
||||
|
||||
if not base_url or not api_key:
|
||||
raise HTTPException(status_code=500, detail="AIMTR_BASE_URL or AIMTR_API_KEY is missing")
|
||||
|
||||
system_prompt = """
|
||||
Ты техлид и персональный фокус-ассистент.
|
||||
Твоя задача — выбрать ОДНО лучшее действие на ближайший pomodoro.
|
||||
|
||||
Отвечай только валидным JSON без markdown.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"recommended": {
|
||||
"kind": "task|story|meta",
|
||||
"ref": 123,
|
||||
"title": "что делать",
|
||||
"why_now": "почему именно это сейчас",
|
||||
"expected_result": "что должно быть готово к концу pomodoro",
|
||||
"risk": "главный риск или блокер"
|
||||
},
|
||||
"pomodoro_plan": [
|
||||
"шаг 1",
|
||||
"шаг 2",
|
||||
"шаг 3"
|
||||
],
|
||||
"definition_of_done": [
|
||||
"критерий готовности 1",
|
||||
"критерий готовности 2"
|
||||
],
|
||||
"skip_reasoning": [
|
||||
"почему не выбраны другие важные задачи"
|
||||
],
|
||||
"questions": [
|
||||
"короткий вопрос, если без него нельзя начать"
|
||||
]
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Выбирай задачу, которую реально сдвинуть за заданное количество минут.
|
||||
- Предпочитай маленький понятный next step, а не огромную важную задачу.
|
||||
- Если есть недавно созданные подзадачи без прогресса — они хорошие кандидаты.
|
||||
- Не выбирай закрытые задачи.
|
||||
- Если данных мало, всё равно предложи лучший следующий шаг.
|
||||
- Пиши на русском.
|
||||
""".strip()
|
||||
|
||||
payload = {
|
||||
"project": project_name,
|
||||
"minutes": minutes,
|
||||
"energy": energy,
|
||||
"notes": notes,
|
||||
"backlog": backlog,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(payload, ensure_ascii=False),
|
||||
},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.json()["choices"][0]["message"]["content"]
|
||||
clean = strip_markdown_json(content)
|
||||
|
||||
try:
|
||||
return json.loads(clean)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"LLM returned invalid JSON: {content[:1000]}") from exc
|
||||
|
||||
|
||||
@app.post("/next-action")
|
||||
def next_action(req: NextActionRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
|
||||
project = projects[req.project]
|
||||
taiga_project_id = project.get("taiga_project_id")
|
||||
|
||||
if not taiga_project_id:
|
||||
raise HTTPException(status_code=400, detail="taiga_project_id is missing")
|
||||
|
||||
token = taiga_auth()
|
||||
backlog = taiga_get_backlog(token, int(taiga_project_id))
|
||||
|
||||
recommendation = aimtr_next_action(
|
||||
project_name=req.project,
|
||||
minutes=req.minutes,
|
||||
energy=req.energy,
|
||||
notes=req.notes,
|
||||
backlog=backlog,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": req.project,
|
||||
"minutes": req.minutes,
|
||||
"energy": req.energy,
|
||||
"backlog_counts": {
|
||||
"userstories": len(backlog["userstories"]),
|
||||
"tasks": len(backlog["tasks"]),
|
||||
},
|
||||
"recommendation": recommendation,
|
||||
}
|
||||
class PomodoroFinishRequest(BaseModel):
|
||||
project: str
|
||||
task_ref: int
|
||||
minutes: int = 25
|
||||
result: str
|
||||
done: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def taiga_find_task_by_ref(token: str, project_id: int, task_ref: int) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.get(
|
||||
f"{base_url}/api/v1/tasks/by_ref",
|
||||
headers=taiga_headers(token),
|
||||
params={
|
||||
"project": project_id,
|
||||
"ref": task_ref,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def taiga_get_closed_task_status(token: str, project_id: int) -> int | None:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
response = requests.get(
|
||||
f"{base_url}/api/v1/task-statuses",
|
||||
headers=taiga_headers(token),
|
||||
params={"project": project_id},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
statuses = response.json()
|
||||
for status in statuses:
|
||||
if status.get("is_closed"):
|
||||
return status.get("id")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_pomodoro_comment(req: PomodoroFinishRequest) -> str:
|
||||
lines = [
|
||||
f"🍅 Pomodoro report: {req.minutes} min",
|
||||
"",
|
||||
"Result:",
|
||||
req.result.strip(),
|
||||
]
|
||||
|
||||
if req.notes:
|
||||
lines.extend(["", "Notes:", req.notes.strip()])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
f"Marked done: {'yes' if req.done else 'no'}",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def taiga_update_task_comment(
|
||||
token: str,
|
||||
task: dict,
|
||||
comment: str,
|
||||
close: bool,
|
||||
closed_status_id: int | None,
|
||||
) -> dict:
|
||||
base_url = os.getenv("TAIGA_BASE_URL", "").rstrip("/")
|
||||
|
||||
payload = {
|
||||
"version": task.get("version"),
|
||||
"comment": comment,
|
||||
}
|
||||
|
||||
if close and closed_status_id:
|
||||
payload["status"] = closed_status_id
|
||||
|
||||
response = requests.patch(
|
||||
f"{base_url}/api/v1/tasks/{task['id']}",
|
||||
headers=taiga_headers(token),
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@app.post("/pomodoro/finish")
|
||||
def pomodoro_finish(req: PomodoroFinishRequest):
|
||||
config = load_config()
|
||||
projects = config.get("projects", {})
|
||||
|
||||
if req.project not in projects:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown project: {req.project}")
|
||||
|
||||
project = projects[req.project]
|
||||
taiga_project_id = project.get("taiga_project_id")
|
||||
|
||||
if not taiga_project_id:
|
||||
raise HTTPException(status_code=400, detail="taiga_project_id is missing")
|
||||
|
||||
token = taiga_auth()
|
||||
|
||||
task = taiga_find_task_by_ref(token, int(taiga_project_id), req.task_ref)
|
||||
comment = format_pomodoro_comment(req)
|
||||
|
||||
closed_status_id = None
|
||||
if req.done:
|
||||
closed_status_id = taiga_get_closed_task_status(token, int(taiga_project_id))
|
||||
|
||||
updated_task = taiga_update_task_comment(
|
||||
token=token,
|
||||
task=task,
|
||||
comment=comment,
|
||||
close=req.done,
|
||||
closed_status_id=closed_status_id,
|
||||
)
|
||||
|
||||
backlog = taiga_get_backlog(token, int(taiga_project_id))
|
||||
recommendation = aimtr_next_action(
|
||||
project_name=req.project,
|
||||
minutes=req.minutes,
|
||||
energy="normal",
|
||||
notes=f"Just finished task #{req.task_ref}. Result: {req.result}",
|
||||
backlog=backlog,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": req.project,
|
||||
"task": {
|
||||
"id": updated_task.get("id"),
|
||||
"ref": updated_task.get("ref"),
|
||||
"subject": updated_task.get("subject"),
|
||||
"status": (updated_task.get("status_extra_info") or {}).get("name"),
|
||||
"is_closed": updated_task.get("is_closed"),
|
||||
},
|
||||
"comment_added": True,
|
||||
"closed": bool(req.done and closed_status_id),
|
||||
"next_recommendation": recommendation,
|
||||
}
|
||||
Reference in New Issue
Block a user