v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
- OAuth2 Gitea (login/callback/logout) with fallback admin mode
- Session management with signed cookies (itsdangerous, 7 days)
- CSRF protection middleware on all POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min per IP)
- Issue creation from FlowDeck (POST /api/issues/{owner}/{repo})
- Inline issue editing (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card detail modal (double-click): description, labels, comments
- Checklists with toggleable items (CRUD endpoints + UI)
- Priority + due date editable on cards
- Webhook receiver (POST /api/webhook) with HMAC-SHA256
- Auto-register/status webhooks for repos
- GiteaClient: create_issue, update_issue, get_comments, webhooks, collaborators
- New DB tables: users, user_tokens, checklists, checklist_items
- CSS: modal, forms, checklists, comments, PR section
This commit is contained in:
+115
-32
@@ -35,7 +35,6 @@ class GiteaClient:
|
||||
# ── repos ──
|
||||
|
||||
async def get_user_repos(self, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List user repos (sorted by updated desc)."""
|
||||
cache_key = f"repos:{page}:{limit}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
@@ -53,7 +52,6 @@ class GiteaClient:
|
||||
return data
|
||||
|
||||
async def get_org_repos(self, org: str, page: int = 1, limit: int = 20) -> list[dict]:
|
||||
"""List repos for an org."""
|
||||
cache_key = f"org_repos:{org}:{page}:{limit}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
@@ -71,7 +69,6 @@ class GiteaClient:
|
||||
return data
|
||||
|
||||
async def get_user_orgs(self) -> list[dict]:
|
||||
"""List user orgs."""
|
||||
cache_key = "user_orgs"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
@@ -91,7 +88,6 @@ class GiteaClient:
|
||||
async def get_issues(
|
||||
self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50
|
||||
) -> list[dict]:
|
||||
"""List issues for a repo."""
|
||||
cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
@@ -109,7 +105,6 @@ class GiteaClient:
|
||||
return data
|
||||
|
||||
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||
"""Get a single issue."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
@@ -118,40 +113,63 @@ class GiteaClient:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def create_issue(
|
||||
self, owner: str, repo: str, title: str, body: str = "",
|
||||
labels: list[int] | None = None, milestone: int | None = None,
|
||||
assignee: str = "",
|
||||
) -> dict:
|
||||
"""Create a new issue in Gitea."""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if labels:
|
||||
payload["labels"] = labels
|
||||
if milestone:
|
||||
payload["milestone"] = milestone
|
||||
if assignee:
|
||||
payload["assignees"] = [assignee]
|
||||
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def update_issue(
|
||||
self, owner: str, repo: str, issue_id: int, **kwargs
|
||||
) -> dict:
|
||||
"""Update an issue (title, body, state, labels, milestone, assignees)."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.patch(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
json=kwargs,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def update_issue_labels(
|
||||
self, owner: str, repo: str, issue_id: int, labels: list[str]
|
||||
) -> dict:
|
||||
"""Replace issue labels."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.put(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
json={"labels": labels},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return await self.update_issue(owner, repo, issue_id, labels=labels)
|
||||
|
||||
async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||
"""Close an issue (move to Terminé)."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.patch(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
json={"state": "closed"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return await self.update_issue(owner, repo, issue_id, state="closed")
|
||||
|
||||
async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||
"""Reopen an issue."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
return await self.update_issue(owner, repo, issue_id, state="open")
|
||||
|
||||
async def get_issue_comments(
|
||||
self, owner: str, repo: str, issue_id: int
|
||||
) -> list[dict]:
|
||||
"""Get comments for an issue."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.patch(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
|
||||
headers=self._headers,
|
||||
json={"state": "open"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -165,7 +183,6 @@ class GiteaClient:
|
||||
# ── labels ──
|
||||
|
||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List repo labels."""
|
||||
cache_key = f"labels:{owner}:{repo}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
@@ -186,7 +203,6 @@ class GiteaClient:
|
||||
async def get_milestones(
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
"""List repo milestones."""
|
||||
cache_key = f"milestones:{owner}:{repo}:{state}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
@@ -203,6 +219,73 @@ class GiteaClient:
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
# ── webhooks ──
|
||||
|
||||
async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List webhooks for a repo."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def create_webhook(
|
||||
self, owner: str, repo: str, url: str, secret: str,
|
||||
events: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a webhook for a repo."""
|
||||
if events is None:
|
||||
events = ["issues", "pull_request", "repository", "issue_comment"]
|
||||
payload = {
|
||||
"type": "gitea",
|
||||
"config": {
|
||||
"url": url,
|
||||
"content_type": "json",
|
||||
"secret": secret,
|
||||
},
|
||||
"events": events,
|
||||
"active": True,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool:
|
||||
"""Delete a webhook."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.delete(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}",
|
||||
headers=self._headers,
|
||||
)
|
||||
return resp.status_code == 204
|
||||
|
||||
# ── assignees/collaborators ──
|
||||
|
||||
async def get_collaborators(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List repo collaborators."""
|
||||
cache_key = f"collaborators:{owner}:{repo}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/collaborators",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
|
||||
# ── singleton ──
|
||||
|
||||
gitea = GiteaClient()
|
||||
|
||||
Reference in New Issue
Block a user