chore(workplan): WP-0016 — Issue-Facade Integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 04:29:29 +02:00
parent d88aa9318c
commit 256fb4c75c

View File

@@ -0,0 +1,574 @@
---
id: WP-0016
title: Issue-Facade Integration — lokale Aufgabenverfolgung + Remote-Delegation
status: todo
phase: 16-of-n
created: "2026-05-14"
depends_on: WP-0015
---
# WP-0016 — Issue-Facade Integration
Ersetzt den Platzhalter-`issue_facade.py` aus WP-0015 durch eine echte
Integration mit dem Sister-Projekt `issue-facade` (`/home/worsch/issue-facade`,
Package-Name `universal-issue-tracker`).
**Leitgedanke:** Aufgaben werden zunächst lokal im SQLite-Backend von
`issue-facade` verwaltet (offline-fähig, keine externe Abhängigkeit). Optional
können sie in ein entferntes System (zunächst Gitea) delegiert werden. Der
Status wird von dort zurückgelesen.
**Key interfaces aus dem Sister-Projekt:**
- `issue_tracker.core.models.Issue``id` (UUID), `number`, `title`,
`description`, `state` (IssueState: open/closed/in_progress/blocked)
- `issue_tracker.core.interfaces.IssueBackend` ABC — `create_issue()`,
`get_issue()`, `update_issue()`, `list_issues()`
- `issue_tracker.backends.local.LocalSQLiteBackend`
`connect({'db_path': ...})`
- `issue_tracker.backends.gitea.GiteaBackend`
`connect({'base_url', 'token', 'owner', 'repo'})`
---
```task
id: WP-0016-T01
title: Package-Installation + Django-Settings
status: todo
**`pyproject.toml`** — Dependency ergänzen:
```toml
[project]
dependencies = [
...
"universal-issue-tracker @ file:///home/worsch/issue-facade",
]
```
Danach:
```bash
uv sync
```
**`vergabe_teilnahme/settings/base.py`** — neue Optionen am Ende:
```python
# Issue Facade — lokales SQLite-Backend (immer aktiv)
ISSUE_FACADE_LOCAL_DB = BASE_DIR / '.issue-facade' / 'issues.db'
# Issue Facade — Gitea-Remote (optional, None = deaktiviert)
ISSUE_FACADE_GITEA: dict | None = None
# Beispiel:
# ISSUE_FACADE_GITEA = {
# 'base_url': 'https://gitea.example.com',
# 'token': env('GITEA_TOKEN', default=''),
# 'owner': 'org',
# 'repo': 'vergabe',
# }
```
**`.gitignore`** — SQLite-DB ausschließen:
```
.issue-facade/
```
Smoke-Check:
```bash
uv run python -c "from issue_tracker.backends.local import LocalSQLiteBackend; print('ok')"
```
```
```task
id: WP-0016-T02
title: Backend-Utility — issue_backends.py
status: todo
Neues Modul `vergabe_teilnahme/apps/aufgaben/issue_backends.py`:
```python
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def local_backend():
from django.conf import settings
from issue_tracker.backends.local import LocalSQLiteBackend
db_path = str(getattr(settings, 'ISSUE_FACADE_LOCAL_DB', '.issue-facade/issues.db'))
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
b = LocalSQLiteBackend()
b.connect({'db_path': db_path})
try:
yield b
finally:
b.disconnect()
@contextmanager
def remote_backend():
"""Yields GiteaBackend wenn konfiguriert, sonst None."""
from django.conf import settings
from issue_tracker.backends.gitea import GiteaBackend
cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None)
if not cfg:
yield None
return
b = GiteaBackend()
b.connect(cfg)
try:
yield b
finally:
b.disconnect()
def gitea_configured() -> bool:
from django.conf import settings
cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None)
return bool(cfg and cfg.get('token'))
```
Kontext-Manager-Pattern (kein Prozess-globaler Singleton, SQLite ist
pro Request cheap genug für eine Einzelbenutzer-App).
```
```task
id: WP-0016-T03
title: ExternalIssue-Modell erweitern + Migration
status: todo
In `apps/aufgaben/models.py` das Modell `ExternalIssue` anpassen:
```python
class ExternalIssue(models.Model):
BACKEND_CHOICES = [
('local', 'Lokal (SQLite)'),
('gitea', 'Gitea'),
]
SYNC_STATUS_CHOICES = [
('open', 'Offen'),
('in_progress', 'In Bearbeitung'),
('blocked', 'Blockiert'),
('closed', 'Geschlossen'),
('error', 'Sync-Fehler'),
]
aufgabe = models.OneToOneField(
Aufgabe, on_delete=models.CASCADE, related_name='external_issue'
)
issue_facade_backend = models.CharField(
max_length=20, choices=BACKEND_CHOICES, default='local'
)
issue_facade_id = models.CharField(
max_length=200, blank=True,
help_text='UUID (lokal) oder Issue-Number (Gitea)'
)
issue_url = models.URLField(blank=True)
issue_key = models.CharField(max_length=100, blank=True,
help_text='z.B. "#42" oder "PROJ-1234"')
sync_status = models.CharField(
max_length=20, choices=SYNC_STATUS_CHOICES, default='open'
)
letzter_sync = models.DateTimeField(null=True, blank=True)
notizen = models.TextField(blank=True)
erstellt_am = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'External Issue'
verbose_name_plural = 'External Issues'
def __str__(self):
return f'{self.get_issue_facade_backend_display()} #{self.issue_key or self.issue_facade_id[:8]}'
```
**Änderungen vs. WP-0015:**
- `system` (github/jira/linear/…) entfällt — ersetzt durch `issue_facade_backend`
- `issue_facade_id` neu — UUID für lokale Issues, Issue-Number-String für Gitea
- `sync_status`-Choices auf IssueState-Werte ausgerichtet
Migration erstellen und ausführen:
```bash
uv run python manage.py makemigrations aufgaben --name external_issue_redesign
uv run python manage.py migrate
```
```
```task
id: WP-0016-T04
title: Service-Schicht — issue_facade.py ersetzen
status: todo
`vergabe_teilnahme/apps/aufgaben/issue_facade.py` **komplett ersetzen**:
```python
from datetime import datetime, timezone
from issue_tracker.core.models import (
Issue, IssueState, IssueType, Label, Priority
)
from .issue_backends import gitea_configured, local_backend, remote_backend
def aufgabe_zu_issue(aufgabe) -> Issue:
"""Konvertiert eine Aufgabe in ein issue-facade Issue-Objekt."""
prioritaet_map = {1: Priority.HIGH, 2: Priority.MEDIUM, 3: Priority.LOW}
labels = [
Label(name='task'),
Label(name=f'priority:{prioritaet_map.get(aufgabe.prioritaet, Priority.MEDIUM).value}'),
]
if aufgabe.typ:
labels.append(Label(name=aufgabe.typ))
now = datetime.now(timezone.utc)
return Issue(
id=None, number=0,
title=aufgabe.titel,
description=aufgabe.beschreibung or '',
state=IssueState.OPEN,
created_at=now,
updated_at=now,
labels=labels,
)
def lokales_issue_erstellen(aufgabe) -> dict:
"""
Legt ein Issue im lokalen SQLite-Backend an.
Gibt {'issue_facade_id', 'issue_key', 'sync_status'} zurück.
"""
issue = aufgabe_zu_issue(aufgabe)
with local_backend() as b:
created = b.create_issue(issue)
return {
'issue_facade_id': created.id,
'issue_key': f'#{created.number}',
'sync_status': created.state.value,
}
def an_gitea_delegieren(aufgabe, external_issue) -> dict:
"""
Schiebt ein lokales Issue nach Gitea.
Gibt {'issue_facade_id', 'issue_url', 'issue_key', 'sync_status'} zurück.
Wirft ValueError wenn Gitea nicht konfiguriert ist.
"""
with remote_backend() as b:
if b is None:
raise ValueError('Gitea nicht konfiguriert (ISSUE_FACADE_GITEA fehlt in settings)')
issue = aufgabe_zu_issue(aufgabe)
created = b.create_issue(issue)
return {
'issue_facade_backend': 'gitea',
'issue_facade_id': str(created.number),
'issue_url': created.sync_metadata.get('url', '') if created.sync_metadata else '',
'issue_key': f'#{created.number}',
'sync_status': created.state.value,
}
def status_synchronisieren(external_issue) -> str:
"""
Liest den aktuellen Status aus dem konfigurierten Backend.
Gibt den neuen sync_status-String zurück.
"""
from django.utils import timezone as dj_tz
backend_ctx = (
remote_backend() if external_issue.issue_facade_backend == 'gitea'
else local_backend()
)
with backend_ctx as b:
if b is None:
raise ValueError('Backend nicht verfügbar')
issue = b.get_issue(external_issue.issue_facade_id)
neuer_status = issue.state.value if issue else 'error'
external_issue.sync_status = neuer_status
external_issue.letzter_sync = dj_tz.now()
external_issue.save(update_fields=['sync_status', 'letzter_sync'])
return neuer_status
def issue_schliessen(external_issue) -> None:
"""Setzt das Issue im Backend auf CLOSED."""
backend_ctx = (
remote_backend() if external_issue.issue_facade_backend == 'gitea'
else local_backend()
)
with backend_ctx as b:
if b is None:
return
issue = b.get_issue(external_issue.issue_facade_id)
if issue:
issue.state = IssueState.CLOSED
b.update_issue(issue)
external_issue.sync_status = 'closed'
external_issue.save(update_fields=['sync_status'])
def get_adapter(system: str):
"""Rückwärtskompatible Stub — gibt None zurück (kein manuelles Adapter-Dict mehr)."""
return None
```
Der ABC `IssueAdapter` aus WP-0015 entfällt damit vollständig —
`issue-facade` übernimmt diese Rolle.
```
```task
id: WP-0016-T05
title: ExternalIssueForm anpassen + Views verkabeln
status: todo
**Form** (`forms.py`) — vereinfacht, da `system` entfällt:
```python
class ExternalIssueForm(forms.ModelForm):
class Meta:
model = ExternalIssue
fields = ['notizen']
widgets = {
'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2,
'placeholder': 'Notizen (optional)'}),
}
```
(Backend und issue_facade_id werden vom Service gesetzt, nicht vom User.)
**`views.py`** — `external_issue_bearbeiten` anpassen:
```python
def external_issue_bearbeiten(request, ausschreibung_id, pk):
from .issue_facade import lokales_issue_erstellen
from .forms import ExternalIssueForm
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
issue = getattr(aufgabe, 'external_issue', None)
if request.method == 'POST':
form = ExternalIssueForm(request.POST, instance=issue)
if form.is_valid():
ei = form.save(commit=False)
ei.aufgabe = aufgabe
if not issue: # Neues Issue — im lokalen Backend anlegen
daten = lokales_issue_erstellen(aufgabe)
ei.issue_facade_backend = 'local'
ei.issue_facade_id = daten['issue_facade_id']
ei.issue_key = daten['issue_key']
ei.sync_status = daten['sync_status']
ei.save()
if _is_htmx(request):
return render(request,
'aufgaben/partials/external_issue_panel.html',
{'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
else:
form = ExternalIssueForm(instance=issue)
if _is_htmx(request):
return render(request, 'aufgaben/partials/external_issue_form.html',
{'form': form, 'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
```
Zwei neue Views ergänzen:
```python
def external_issue_push_remote(request, ausschreibung_id, pk):
"""Schiebt ein lokales Issue nach Gitea."""
from .issue_facade import an_gitea_delegieren
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe)
if request.method == 'POST':
try:
daten = an_gitea_delegieren(aufgabe, ei)
for k, v in daten.items():
setattr(ei, k, v)
ei.save()
except ValueError as exc:
# Gitea nicht konfiguriert — ignorieren, Fehlermeldung im Panel
pass
if _is_htmx(request):
return render(request,
'aufgaben/partials/external_issue_panel.html',
{'aufgabe': aufgabe, 'ausschreibung': ausschreibung,
'push_error': str(exc) if 'exc' in dir() else None})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
def external_issue_sync(request, ausschreibung_id, pk):
"""Aktualisiert sync_status aus dem Backend."""
from .issue_facade import status_synchronisieren
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung)
ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe)
if request.method == 'POST':
try:
status_synchronisieren(ei)
except Exception:
ei.sync_status = 'error'
ei.save(update_fields=['sync_status'])
if _is_htmx(request):
return render(request,
'aufgaben/partials/external_issue_panel.html',
{'aufgabe': aufgabe, 'ausschreibung': ausschreibung})
return redirect('ausschreibungen:aufgaben:detail',
ausschreibung_id=ausschreibung_id, pk=pk)
```
URLs in `urls.py` ergänzen:
```python
path('<int:pk>/issue/push/', views.external_issue_push_remote, name='external_issue_push'),
path('<int:pk>/issue/sync/', views.external_issue_sync, name='external_issue_sync'),
```
```
```task
id: WP-0016-T06
title: Admin + ExternalIssueAdmin aktualisieren
status: todo
`apps/aufgaben/admin.py` — `ExternalIssueAdmin` an neue Felder anpassen:
```python
@admin.register(ExternalIssue)
class ExternalIssueAdmin(admin.ModelAdmin):
list_display = ['aufgabe', 'issue_facade_backend', 'issue_key',
'sync_status', 'letzter_sync']
list_filter = ['issue_facade_backend', 'sync_status']
readonly_fields = ['issue_facade_id', 'issue_key', 'issue_url',
'sync_status', 'letzter_sync', 'erstellt_am']
```
```
```task
id: WP-0016-T07
title: UI — ExternalIssue-Panel aktualisieren
status: todo
`templates/aufgaben/partials/external_issue_card.html` überarbeiten:
```html
{% with ei=aufgabe.external_issue %}
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2 flex-wrap">
<!-- Backend-Badge -->
{% if ei.issue_facade_backend == 'local' %}
<span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">LOKAL</span>
{% else %}
<span class="text-xs bg-blue-100 text-blue-700 rounded px-1.5 py-0.5 font-medium">GITEA</span>
{% endif %}
<!-- Issue-Key -->
{% if ei.issue_url %}
<a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
class="text-xs font-mono text-blue-600 hover:underline">{{ ei.issue_key }}</a>
{% else %}
<span class="text-xs font-mono text-slate-600">{{ ei.issue_key }}</span>
{% endif %}
<!-- Status-Badge -->
<span class="text-xs rounded px-1.5 py-0.5
{% if ei.sync_status == 'closed' %}bg-green-100 text-green-700
{% elif ei.sync_status == 'in_progress' %}bg-amber-100 text-amber-700
{% elif ei.sync_status == 'blocked' %}bg-red-100 text-red-700
{% elif ei.sync_status == 'error' %}bg-red-200 text-red-800
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ ei.get_sync_status_display }}
</span>
<!-- Letzter Sync -->
{% if ei.letzter_sync %}
<span class="text-xs text-slate-400 ml-auto">Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }}</span>
{% endif %}
</div>
{% if ei.notizen %}
<p class="text-xs text-slate-500 whitespace-pre-wrap">{{ ei.notizen }}</p>
{% endif %}
<div class="flex gap-2 flex-wrap mt-1">
<!-- Bearbeiten -->
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">Bearbeiten</button>
<!-- Push to Gitea (nur wenn lokal und Gitea konfiguriert) -->
{% if ei.issue_facade_backend == 'local' and gitea_configured %}
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_push' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-blue-600">↑ Nach Gitea</button>
</form>
{% endif %}
<!-- Status syncen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_sync' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs">⟳ Status syncen</button>
</form>
<!-- Entfernen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_loeschen' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML"
hx-confirm="Externe Verknüpfung entfernen?">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-red-500">Entfernen</button>
</form>
</div>
</div>
{% endwith %}
```
`gitea_configured` im Template benötigt einen Context-Processor oder muss im
View als Kontext-Variable übergeben werden. Einfachste Lösung: in
`external_issue_panel.html` und den Views direkt via
`from .issue_backends import gitea_configured` hinzufügen.
`external_issue_form.html` vereinfachen — nur noch Notizen-Feld + Hinweis
"Issue wird automatisch im lokalen Backend angelegt".
```
```task
id: WP-0016-T08
title: Tests + Smoke-Check
status: todo
Alle 76 bestehenden Tests müssen grün bleiben.
Neue Tests in `apps/aufgaben/tests.py` — lokales Backend per tmp-Datei:
```python
@pytest.fixture
def tmp_issue_db(tmp_path, settings):
settings.ISSUE_FACADE_LOCAL_DB = tmp_path / 'test_issues.db'
settings.ISSUE_FACADE_GITEA = None
return settings.ISSUE_FACADE_LOCAL_DB
```
- `test_aufgabe_zu_issue_mapping` — title, description, priority-Label korrekt
- `test_lokales_issue_erstellen` — ExternalIssue bekommt issue_facade_id + issue_key
- `test_status_synchronisieren` — sync_status wird aktualisiert
- `test_external_issue_bearbeiten_view_erstellt_issue` — POST → Issue in DB
- `test_external_issue_sync_view` — POST → sync_status geändert
- `test_get_adapter_stub` — gibt None zurück (Rückwärtskompatibilität)
```bash
uv run pytest vergabe_teilnahme/apps/aufgaben/tests.py -v
uv run pytest vergabe_teilnahme/ -q # alle Tests
```
```