This commit is contained in:
Patrick Nisble
2026-06-01 09:56:50 +02:00
parent 7c6c3d7223
commit 5c2016a05c
14 changed files with 1140 additions and 261 deletions
+54 -13
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterator
from dataclasses import dataclass, field
from json import loads
from pydantic import BaseModel
@@ -6,9 +7,39 @@ from requests import HTTPError, get
from .common import EnvVar, JSONDataMap, JSONData, JSONDataList, JSONDataScalar
class ClickupStatus(BaseModel):
status: str
@property
def status_symbol(self) -> str:
match self.status:
case "backlog":
return "󰧟"
case "selected for development":
return "󱥸"
case "in progress":
return "󰝦"
case "in review":
return "󰬫"
case "done":
return "󰻃"
case "closed":
return "󰄴"
case "on hold":
return "󰏦"
case _:
return self.status
class ClickupTask(BaseModel):
id: str
name: str
markdown_description: str
status: ClickupStatus
class ClickupList(BaseModel):
task_count: int
@dataclass
@@ -44,20 +75,27 @@ class ClickupSession:
) as resp:
return resp.json()
def get_tasks(self, **filters: str) -> list[ClickupTask]:
data = self._get(
f"/team/{self.workspace_id}/task",
**{
"subtasks": "true",
"include_markdown_description": "true",
"assignees[]": self.user_id,
}
| filters,
).get("tasks", [])
if isinstance(data, list):
return [ClickupTask.model_validate(t) for t in data if isinstance(t, dict)]
def get_tasks(self, **filters: str) -> Iterator[ClickupTask]:
pix = 0
while True:
partial_data = self._get(
f"/team/{self.workspace_id}/task",
**{
"subtasks": "true",
"include_markdown_description": "true",
"include_closed": "true",
"assignees[]": self.user_id,
"page": pix,
}
| filters,
).get("tasks", [])
pix += 1
return []
for inst in partial_data:
yield ClickupTask.model_validate(inst)
if len(partial_data) < 100:
break
def get_task(self, task_id: str) -> ClickupTask:
return ClickupTask.model_validate(
@@ -66,3 +104,6 @@ class ClickupSession:
include_markdown_description="true",
),
)
def get_list(self, list_id: str) -> ClickupList:
return ClickupList.model_validate(self._get(f"/list/{list_id}"))
+133
View File
@@ -0,0 +1,133 @@
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel, Field
from requests import get
from requests.compat import quote
from eta.common import EnvVar, JSONDataMap
class GitlabProject(BaseModel):
name: str
name_with_namespace: str
path_with_namespace: str
web_url: str
id: int
issues: list["GitlabIssue"] = Field(default_factory=list, init=False)
merge_requests: list["GitlabMergeRequest"] = Field(default_factory=list, init=False)
class GitlabMilestone(BaseModel):
id: int
title: str
state: str
class GitlabIssue(BaseModel):
title: str
description: str | None
id: int
project_id: int
iid: int
state: str
labels: list[str]
milestone: GitlabMilestone | None
# web_url: str
class GitlabMergeRequest(BaseModel):
id: int
iid: int
description: str | None
draft: bool
labels: list[str]
milestone: GitlabMilestone | None
source_branch: str
state: str
target_branch: str
project_id: int
title: str
web_url: str
@dataclass
class GitlabSession:
auth_key: str = field(
default_factory=EnvVar(
"GITLAB_AUTH",
"gitlab auth token is required to be set",
)
)
base_url: str = "https://git.extoll.de/api/v4"
def _get(self, endpoint: str, **query_params: str) -> JSONDataMap:
with get(
self.base_url + endpoint,
query_params,
headers={
"accept": "application/json",
"PRIVATE-TOKEN": self.auth_key,
},
verify=False,
) as resp:
return resp.json()
def get_projects(self, **filters: str) -> list[GitlabProject]:
ret: list[GitlabProject] = []
page = 1
while projs := self._get(
"/projects",
active="true",
simple="true",
per_page="100",
page=str(page),
):
ret.extend(GitlabProject.model_validate(proj) for proj in projs)
page = page + 1
if len(projs) < 100:
break
return ret
def get_issues(self, projects: list[GitlabProject]) -> list[GitlabIssue]:
ret: list[GitlabIssue] = []
pids = {p.id for p in projects}
page = 1
while True:
issues = self._get(
"/issues",
per_page="100",
page=str(page),
)
ret.extend(
GitlabIssue.model_validate(iss) for iss in issues if int(iss["project_id"]) in pids
)
page = page + 1
if len(issues) < 100:
break
return ret
def get_merge_requests(self, projects: list[GitlabProject]) -> list[GitlabMergeRequest]:
ret: list[GitlabMergeRequest] = []
pids = {p.id for p in projects}
page = 1
while True:
merge_requests = self._get(
"/merge_requests",
simple="true",
per_page="100",
page=str(page),
)
ret.extend(
GitlabMergeRequest.model_validate(mr)
for mr in merge_requests
if int(mr["project_id"]) in pids
)
page = page + 1
if len(merge_requests) < 100:
break
return ret
+367 -22
View File
@@ -1,6 +1,20 @@
from enum import Enum, IntEnum
import re
from lsprotocol.types import (
INITIALIZED,
TEXT_DOCUMENT_CODE_ACTION,
TEXT_DOCUMENT_COMPLETION,
TEXT_DOCUMENT_DOCUMENT_SYMBOL,
TEXT_DOCUMENT_HOVER,
TEXT_DOCUMENT_INLAY_HINT,
TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
WORKSPACE_INLAY_HINT_REFRESH,
CodeAction,
CodeActionKind,
CodeActionOptions,
CodeActionParams,
Command,
CompletionItem,
CompletionItemKind,
CompletionItemLabelDetails,
@@ -8,9 +22,19 @@ from lsprotocol.types import (
CompletionParams,
DocumentSymbol,
DocumentSymbolParams,
Hover,
HoverParams,
InitializedParams,
InlayHint,
InlayHintParams,
MarkupContent,
MarkupKind,
NotebookDocumentSyncOptions,
Position,
Range,
SemanticTokens,
SemanticTokensLegend,
SemanticTokensParams,
SymbolKind,
TextDocumentSyncKind,
WorkDoneProgressBegin,
@@ -19,13 +43,22 @@ from lsprotocol.types import (
)
from pygls.lsp.server import LanguageServer
from eta.gitlab import GitlabProject, GitlabSession
from .clickup import ClickupSession, ClickupTask
GL_PRJ_PATTERN = r"#project\/(?P<ns>((\w+)/)+)(?P<prj>\w+)"
GL_ID_PATTERN = r"#(?P<idt>mr|issue)\/(?P<sid>\d+)"
CU_PATTERN = r"#task/(?P<id>\w{8,})"
class CustomServer(LanguageServer):
cache: dict[str, ClickupTask]
cu_cache: dict[str, ClickupTask]
cu_session: ClickupSession
gl_cache: dict[str, GitlabProject]
gl_session: GitlabSession
def __init__(
self,
name: str,
@@ -34,29 +67,62 @@ class CustomServer(LanguageServer):
notebook_document_sync: NotebookDocumentSyncOptions | None = None,
) -> None:
super().__init__(name, version, text_document_sync_kind, notebook_document_sync)
self.cache = {}
self.cu_cache = {}
self.cu_session = ClickupSession()
self.update_task_cache()
self.gl_cache = {}
self.gl_session = GitlabSession()
def update_task_cache(self) -> None:
self.protocol.progress.begin(
"startup", WorkDoneProgressBegin("Fetching Cache ...", percentage=0, cancellable=True)
)
self.cache = {}
tasks = self.cu_session.get_ta()
async def update_task_cache(self) -> None:
self.cu_cache = {}
tasks = self.cu_session.get_tasks()
for ti, t in enumerate(tasks):
self.cache[t.id] = t
self.cu_cache[t.id] = t
self.protocol.progress.report(
"startup",
WorkDoneProgressReport(
message="Fetched Cache", percentage=int(100 * (1 + ti) / len(tasks))
message="ClickUp Tasks", percentage=int(100 * (1 + ti) / len(tasks))
),
)
self.protocol.progress.end("startup", WorkDoneProgressEnd(message="Done Caching"))
async def update_proj_cache(self) -> None:
self.gl_cache = {}
projs = self.gl_session.get_projects()
for pi, p in enumerate(projs):
self.gl_cache[p.path_with_namespace] = p
self.protocol.progress.report(
"startup",
WorkDoneProgressReport(
message="Gitlab Prjects", percentage=int(100 * (1 + pi) / len(projs))
),
)
await self.update_issue_cache(projs)
await self.update_mr_cache(projs)
async def update_issue_cache(self, prjs: list[GitlabProject]) -> None:
p_by_id = {p.id: p for p in prjs}
issues = self.gl_session.get_issues(prjs)
for issue in issues:
self.gl_cache[p_by_id[issue.project_id].path_with_namespace].issues.append(issue)
async def update_mr_cache(self, prjs: list[GitlabProject]) -> None:
p_by_id = {p.id: p for p in prjs}
mrs = self.gl_session.get_merge_requests(prjs)
for mr in mrs:
self.gl_cache[p_by_id[mr.project_id].path_with_namespace].merge_requests.append(mr)
server = CustomServer("mrpy-server", "0.1.0")
server = CustomServer("eta-server", "0.1.0")
@server.feature(INITIALIZED)
async def on_init(params: InitializedParams) -> None:
server.protocol.progress.begin(
"startup", WorkDoneProgressBegin("Caching ", percentage=0, cancellable=True)
)
await server.update_task_cache()
await server.update_proj_cache()
server.protocol.progress.end("startup", WorkDoneProgressEnd(message="Done Caching"))
@server.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL)
@@ -69,22 +135,301 @@ async def list_ids(params: DocumentSymbolParams) -> list[DocumentSymbol]:
Range(Position(i, 0), Position(i, 0)),
detail=t.name,
)
for i, t in enumerate(server.cache.values())
for i, t in enumerate(server.cu_cache.values())
]
@server.feature(TEXT_DOCUMENT_COMPLETION)
@server.feature(TEXT_DOCUMENT_COMPLETION, CompletionOptions(trigger_characters=["/", "!", "#"]))
async def complete_cu_ids(params: CompletionParams) -> list[CompletionItem]:
doc = server.workspace.get_text_document(params.text_document.uri)
line = doc.lines[params.position.line]
if not line[: params.position.character].strip().endswith(("/", "!", "#")):
return []
prev = (
line[: params.position.character]
.strip()
.removesuffix("/")
.removesuffix("!")
.removesuffix("#")
.split("#")[-1]
)
if prev.endswith(("task", "cu", "clickup")):
return [
CompletionItem(
f"{t.name}",
CompletionItemLabelDetails(detail=f" #{t.id}"),
kind=CompletionItemKind.Constant,
insert_text=f"{t.id}",
)
for t in server.cu_cache.values()
]
if prev.endswith(("project", "gitlab", "gl")):
return [
CompletionItem(
p.path_with_namespace,
detail=f" {p.path_with_namespace}",
kind=CompletionItemKind.Enum,
)
for p in server.gl_cache.values()
]
prj = next(re.finditer(GL_PRJ_PATTERN, line), None)
if prj is None:
return [
CompletionItem(
f"{line}: {prj}",
)
]
if prev.endswith("issue"):
return [
CompletionItem(
i.title,
insert_text=f"{i.iid}",
detail=f"#{i.iid}: {i.title} {' '.join('[' + t + ']' for t in i.labels)}",
documentation=i.description,
kind=CompletionItemKind.EnumMember,
)
for p in server.gl_cache.values()
for i in p.issues
if i.state == "opened"
and p.path_with_namespace == f"{prj.group('ns')}{prj.group('prj')}"
]
if prev.endswith("mr"):
return [
CompletionItem(
f"!{m.title} ({p.path_with_namespace}!{m.iid})",
insert_text=f"{m.iid}",
detail=f"!{m.iid}: {m.title} {' '.join('[' + t + ']' for t in m.labels)}",
documentation=m.description,
kind=CompletionItemKind.EnumMember,
)
for p in server.gl_cache.values()
for m in p.merge_requests
if m.state == "opened"
and p.path_with_namespace.casefold()
== f"{prj.group('ns')}{prj.group('prj')}".casefold()
]
return []
@server.feature(TEXT_DOCUMENT_INLAY_HINT)
async def inlay_info(params: InlayHintParams) -> list[InlayHint]:
ret: list[InlayHint] = []
for lid, line in enumerate(server.workspace.text_documents[params.text_document.uri].lines):
# for m in re.finditer(GL_PATTERN, line):
# ns = m.group("ns")
# prj = m.group("prj")
# idt = m.group("styp")
# id = m.group("sid")
#
# if p := server.gl_cache.get(f"{ns}{prj}"):
# if not idt:
# pass
# elif idt == "!" and (mr := next(m for m in p.merge_requests if str(m.iid) == id)):
# ret.append(
# InlayHint(
# Position(line=lid, character=m.end()),
# label=f"({mr.title} | {mr.state})",
# padding_right=True,
# padding_left=True,
# )
# )
# elif idt == "#" and (issue := next(i for i in p.issues if str(i.iid) == id)):
# ret.append(
# InlayHint(
# Position(line=lid, character=m.end()),
# label=f"({issue.title} | {issue.state})",
# padding_right=True,
# padding_left=True,
# )
# )
for m in re.finditer(CU_PATTERN, line):
id = m.group(1)
if t := server.cu_cache.get(id):
ret.append(
InlayHint(
Position(line=lid, character=m.start()),
label=f"{t.status.status_symbol}",
padding_left=False,
padding_right=False,
)
)
ret.append(
InlayHint(
Position(line=lid, character=m.end()),
label=f"{t.name}",
padding_left=True,
padding_right=False,
)
)
return ret
@server.command("recache_gl")
async def recache_gl(*_) -> None:
await server.update_proj_cache()
await server.protocol.send_request_async(WORKSPACE_INLAY_HINT_REFRESH, None)
@server.command("recache_cu")
async def recache_cu(*_) -> None:
await server.update_task_cache()
await server.protocol.send_request_async(WORKSPACE_INLAY_HINT_REFRESH, None)
@server.feature(
TEXT_DOCUMENT_CODE_ACTION,
CodeActionOptions(code_action_kinds=[CodeActionKind.QuickFix]),
)
def code_actions(params: CodeActionParams) -> list[CodeAction]:
return [
CompletionItem(
t.name,
CompletionItemLabelDetails(detail=f" #{t.id}"),
kind=CompletionItemKind.Constant,
insert_text=f"[{t.name} #{t.id}](https://app.clickup.com/t/{t.id})",
)
for t in server.cache.values()
CodeAction(
"Re-Cache GitLab Project Info",
kind=CodeActionKind.QuickFix,
command=Command("recache gl", "recache_gl"),
),
CodeAction(
"Re-Cache ClickUp Task Info",
kind=CodeActionKind.QuickFix,
command=Command("recache cu", "recache_cu"),
),
]
@server.feature(TEXT_DOCUMENT_HOVER)
def on_hover(params: HoverParams) -> Hover | None:
doc = server.workspace.get_text_document(params.text_document.uri)
line = doc.lines[params.position.line]
# prj_match = list(re.finditer(GL_PRJ_PATTERN, line))[0]
# ns = prj_match.group("ns")
# prj = prj_match.group("prj")
# id_match = list(re.finditer(GL_ID_PATTERN, line))
#
# if p := server.gl_cache.get(f"{ns}{prj}"):
# for m in id_match:
# if params.position.character >= m.start() and params.position.character < m.end():
# idt = m.group("idt")
# id = m.group("sid")
#
# if not idt:
# pass
# elif idt == "mr" and (mr := next(m for m in p.merge_requests if str(m.iid) == id)):
# return Hover(
# MarkupContent(
# kind=MarkupKind.Markdown,
# value=f"# {mr.title}\n\n{mr.description or ''}",
# ),
# range=Range(
# Position(line=params.position.line, character=m.start()),
# Position(line=params.position.line, character=m.end()),
# ),
# )
# elif idt == "issue" and (issue := next(i for i in p.issues if str(i.iid) == id)):
# return Hover(
# MarkupContent(
# kind=MarkupKind.Markdown,
# value=f"# {issue.title}\n\n{issue.description or ''}",
# ),
# range=Range(
# Position(line=params.position.line, character=m.start()),
# Position(line=params.position.line, character=m.end()),
# ),
# )
for m in re.finditer(CU_PATTERN, line):
if params.position.character >= m.start() and params.position.character < m.end():
id = m.group("id")
if t := server.cu_cache.get(id):
return Hover(
MarkupContent(
kind=MarkupKind.Markdown,
value=f"# {t.name} - 󱇯 {t.status.status}\n\n{t.markdown_description}",
),
range=Range(
Position(line=params.position.line, character=m.start()),
Position(line=params.position.line, character=m.end()),
),
)
else:
return Hover(
MarkupContent(
kind=MarkupKind.Markdown,
value=f"# {id}: {m.group(0)}\n\n{list(server.cu_cache)}",
),
range=Range(
Position(line=params.position.line, character=m.start()),
Position(line=params.position.line, character=m.end()),
),
)
return None
def token_offset(rest: list[int], current: tuple[int, int]) -> tuple[int, int]:
lines = rest[::5]
# offsets = rest[:-4:-5]
last_line = sum(lines)
return (current[0] - last_line, current[1])
class TaskModifiers(Enum):
Backlog = "backlog"
Selected = "selected for development"
Progress = "in progress"
Review = "in review"
Done = "done"
Closed = "closed"
@server.feature(
TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
SemanticTokensLegend(["cuTask"], list(t.name for t in TaskModifiers)),
)
def sem_tokens(params: SemanticTokensParams) -> SemanticTokens:
ret: list[int] = []
doc = server.workspace.get_text_document(params.text_document.uri)
for lix, line in enumerate(doc.lines):
# ms = re.finditer(GL_PATTERN, line)
#
# for m in ms:
# idt = m.group("styp")
# id = m.group("sid")
# rel_line, rel_char = token_offset(ret, (lix, m.start()))
#
# if not idt:
# ret.extend([rel_line, rel_char, len(m.group(0)), 0, 0])
# elif idt == "!":
# ret.extend([rel_line, rel_char, len(m.group(0)), 2, 0])
# elif idt == "#":
# ret.extend([rel_line, rel_char, len(m.group(0)), 2, 0])
for m in re.finditer(CU_PATTERN, line):
id = m.group("id")
rel_line, rel_char = token_offset(ret, (lix, m.start()))
if t := server.cu_cache.get(id):
ret.extend(
[
rel_line,
rel_char,
len(m.group(0)),
0,
list(t.value for t in TaskModifiers).index(t.status.status),
]
)
return SemanticTokens(data=ret)
def main() -> None:
server.start_io()