Compare commits

..

2 commits

Author SHA1 Message Date
Jens Reinemann
2af82d60d0 infra(forgejo): migrate gh-tickets scripts + skills to Forgejo API 2026-05-19 22:41:20 +02:00
Jens Reinemann
00b28d2f58 feat(contacts): show online status in user list (#131) 2026-05-19 22:34:43 +02:00
11 changed files with 227 additions and 208 deletions

View file

@ -1,11 +1,11 @@
--- ---
name: gh-tickets name: gh-tickets
description: "Konventionen für GitHub-Issues in diesem Workspace: Aufgabentyp-Labels, Sortierung über das Project Board (Number-Feld 'Order') und CLI-Abfragen. Verwende diesen Skill immer dann, wenn Issues angelegt, abgefragt, sortiert oder vom nextstep-Router verarbeitet werden. Trigger-Phrasen: 'nächstes Ticket', 'Issue anlegen', 'Reihenfolge', 'Priorität', 'nextstep', 'Order', 'Board'." description: "Konventionen für Forgejo-Issues in diesem Workspace: Aufgabentyp-Labels, Sortierung über status:todo-Labels und CLI-Abfragen. Verwende diesen Skill immer dann, wenn Issues angelegt, abgefragt, sortiert oder vom nextstep-Router verarbeitet werden. Trigger-Phrasen: 'nächstes Ticket', 'Issue anlegen', 'Reihenfolge', 'Priorität', 'nextstep', 'Status', 'Board'."
--- ---
# Skill: GitHub Tickets (gh-tickets) # Skill: Forgejo Tickets (gh-tickets)
Dieses Dokument definiert die verbindlichen Konventionen für GitHub-Issues im Repository `jreinemann-euris/bollwerk`. Dieses Dokument definiert die verbindlichen Konventionen für Issues im Forgejo-Repository `bollwerkadmin/bollwerk` (`https://git.bollwerk.online`).
--- ---
@ -49,83 +49,69 @@ Stoppe und fordere den User zur Zuordnung auf. Starte den Workflow **nicht** ohn
--- ---
## 2. Sortierung über Project Board ## 2. Sortierung über Status-Labels
Die Abarbeitungsreihenfolge wird über das **Project Board** gesteuert, nicht über Labels. Die Abarbeitungsreihenfolge wird über **Status-Labels** in Forgejo gesteuert.
### Board-Daten ### Status-Labels
| Label | Bedeutung |
| ------------------- | ------------------------------------------------- |
| `status:todo` | Nächste offene Aufgabe (wird von next-ticket.ps1 gefunden) |
| `status:in-progress`| Aktuell in Bearbeitung |
| `status:done` | Abgeschlossen |
| `status:backlog` | Im Backlog wird erst nach todo-Items bearbeitet |
### Repository
| Eigenschaft | Wert | | Eigenschaft | Wert |
| ------------ | ---------------------------------------------------- | | ------------ | ------------------------------------------------- |
| Projekt-Name | `Bollwerk` | | URL | https://git.bollwerk.online/bollwerkadmin/bollwerk |
| Projekt-Nr | `2` | | API-Base | https://git.bollwerk.online/api/v1 |
| Owner | `jreinemann-euris` | | Token | `.github/skills/gh-tickets/forgejo-token.txt` (lokal, nicht committed) |
| Sortierfeld | `Order` (Number-Feld) |
| URL | https://github.com/users/jreinemann-euris/projects/2 |
### Order-Feld Konventionen
- **Kleinere Zahl = höhere Priorität** (wird zuerst abgearbeitet)
- **Jeder Order-Wert MUSS eindeutig sein** keine zwei Items dürfen denselben Wert haben
- Standardwerte: 10, 20, 30, 40, … (in 10er-Schritten, um Platz für Einfügungen zu lassen)
- Ein dringend vorgezogenes Ticket bekommt einen Wert **zwischen** den bestehenden (z.B. 15 zwischen 10 und 20, oder 5 vor 10)
- Wenn keine bestimmte Position vorgegeben ist, erhält das Ticket den **höchsten bestehenden Order-Wert + 10** (= wird am Ende eingefügt)
- Items ohne Order-Wert sind ein **Fehler** und müssen sofort einen Order-Wert erhalten
### Nächstes offenes Issue ermitteln ### Nächstes offenes Issue ermitteln
**Skript:** `.github/skills/gh-tickets/next-ticket.ps1` **Skript:** `.github/skills/gh-tickets/next-ticket.ps1`
```powershell ```powershell
# Nächstes Ticket (priority:high zuerst, dann Order aufsteigend): # Nächstes Ticket (priority:high zuerst, dann Issue-Nummer aufsteigend):
& ".github/skills/gh-tickets/next-ticket.ps1" & ".github/skills/gh-tickets/next-ticket.ps1"
# Bestimmtes Issue abfragen (Typ-Label prüfen): # Bestimmtes Issue abfragen (Typ-Label prüfen):
& ".github/skills/gh-tickets/next-ticket.ps1" -IssueNumber 98 & ".github/skills/gh-tickets/next-ticket.ps1" -IssueNumber 128
``` ```
Ausgabe: `#68 [M] CRM: Erweiterte Kundensuche (Order: 120)` Ausgabe: `#128 [I] infra(forgejo): Projektlinks, Skills und Referenzen umstellen (Order: 128)`
### Issue zum Board hinzufügen ### Status aktualisieren
```powershell
# Issue zum Board hinzufügen
gh project item-add 2 --owner jreinemann-euris --url "https://github.com/jreinemann-euris/bollwerk/issues/<N>"
# Order-Wert setzen (erfordert Item-ID und Field-ID)
gh project item-edit --id <ITEM_ID> --field-id <ORDER_FIELD_ID> --project-id <PROJECT_ID> --number <ORDER_VALUE>
```
### Board-Status aktualisieren
**Skript:** `.github/skills/gh-tickets/set-board-status.ps1` **Skript:** `.github/skills/gh-tickets/set-board-status.ps1`
```powershell ```powershell
# Status auf "In Progress" setzen # Status auf "In Progress" setzen
& ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 68 -Status InProgress & ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 128 -Status InProgress
# Status auf "Done" setzen # Status auf "Done" setzen
& ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 68 -Status Done & ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 128 -Status Done
# Status auf "Todo" zurücksetzen # Status auf "Todo" zurücksetzen
& ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 68 -Status Todo & ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 128 -Status Todo
``` ```
Gültige Status-Werte: `Todo`, `InProgress`, `Done` Gültige Status-Werte: `Todo`, `InProgress`, `Done`, `Backlog`
--- ---
## 3. Issue anlegen (Checkliste) ## 3. Issue anlegen (Checkliste)
Beim Anlegen eines neuen Issues **MÜSSEN alle 5 Schritte** durchgeführt werden: Beim Anlegen eines neuen Issues **MÜSSEN alle 3 Schritte** durchgeführt werden:
1. **Titel**: Beschreibend, ggf. mit Modulpräfix (z.B. `CRM:`, `feat(core):`) 1. **Titel**: Beschreibend, ggf. mit Modulpräfix (z.B. `feat(core):`, `infra(forgejo):`)
2. **Type-Label**: Genau eines von `migration`, `tech-decision`, `infrastructure`, `planning`, `test` 2. **Type-Label**: Genau eines von `migration`, `tech-decision`, `infrastructure`, `block-planning`, `feature`, `refactoring`, `planning`, `test`
3. **Weitere Labels**: Optional (z.B. `crm`, `enhancement`) 3. **Status-Label**: `status:todo` (sofort bearbeiten) oder `status:backlog` (später)
4. **Board**: Issue **sofort** zum Project Board hinzufügen (ein Issue ohne Board-Eintrag existiert nicht für die Abarbeitung!)
5. **Order**: **Eindeutigen** Order-Wert setzen. Vor dem Setzen die bestehenden Order-Werte abfragen und sicherstellen, dass der gewählte Wert noch nicht vergeben ist. Ohne spezifische Positionsvorgabe: höchsten bestehenden Wert + 10
> **Kein Issue ohne Board + Order!** Ein Issue ohne Board-Eintrag wird bei „nächste Aufgabe" nicht gefunden. > **Kein Issue ohne Status-Label!** Ein Issue ohne `status:todo` oder `status:backlog` wird bei „nächste Aufgabe" nicht gefunden.
### Dringend vorgezogenes Issue anlegen ("als nächstes") ### Dringend vorgezogenes Issue anlegen ("als nächstes")
@ -134,21 +120,24 @@ Beim Anlegen eines neuen Issues **MÜSSEN alle 5 Schritte** durchgeführt werden
Wenn während einer laufenden Aufgabe ein neues Issue entdeckt wird, das **direkt danach** bearbeitet werden soll: Wenn während einer laufenden Aufgabe ein neues Issue entdeckt wird, das **direkt danach** bearbeitet werden soll:
```powershell ```powershell
# Issue anlegen und als nächstes Ticket einsortieren (niedrigster Order-Wert) # Issue anlegen mit status:todo (wird als nächstes gefunden, niedrigste Nummer)
& ".github/skills/gh-tickets/create-next-ticket.ps1" -Title "CRM: Bug in Kundensuche" -Labels "migration,crm" -Body "Beschreibung..." & ".github/skills/gh-tickets/create-next-ticket.ps1" -Title "fix(server): Bug in Auth" -Labels "infrastructure" -Body "Beschreibung..."
# Issue ins Backlog legen
& ".github/skills/gh-tickets/create-next-ticket.ps1" -Title "feat: Neues Feature" -Labels "feature" -Status Backlog
``` ```
Das Skript erledigt automatisch alle 5 Schritte: Issue anlegen, Board hinzufügen, Order auf niedrigsten Wert setzen. Das Skript erledigt automatisch: Issue anlegen + Status-Label setzen.
**Trigger-Phrasen:** „als nächstes anlegen", „nächstes Ticket erstellen", „direkt danach bearbeiten" **Trigger-Phrasen:** „als nächstes anlegen", „nächstes Ticket erstellen", „direkt danach bearbeiten"
--- ---
## 4. Sortierung: Priorität + Order (zweistufig) ## 4. Sortierung: Priorität + Issue-Nummer (zweistufig)
Die Abarbeitungsreihenfolge wird **zweistufig** bestimmt: Die Abarbeitungsreihenfolge wird **zweistufig** bestimmt:
1. **Primär: Label `priority:high`** → Issues mit diesem Label kommen **immer zuerst**, unabhängig vom Order-Wert 1. **Primär: Label `priority:high`** → Issues mit diesem Label kommen **immer zuerst**
2. **Sekundär: Label `priority:low`** → Issues mit diesem Label kommen **immer zuletzt**, unabhängig vom Order-Wert 2. **Sekundär: Label `priority:low`** → Issues mit diesem Label kommen **immer zuletzt**
3. **Tertiär: Order-Feld** → Innerhalb derselben Prioritätsstufe sortiert der Order-Wert aufsteigend (kleinere Zahl = früher dran) 3. **Tertiär: Issue-Nummer aufsteigend** → Niedrigere Nummer = früher erstellt = höhere Priorität
Beispiel: Ein Issue mit `priority:high` und Order 300 kommt **vor** einem Issue ohne Priorität mit Order 5. Ein normales Issue mit Order 999 kommt **vor** einem `priority:low`-Issue mit Order 1. Beispiel: Ein Issue #50 mit `priority:high` kommt **vor** Issue #10 ohne Priorität.

View file

@ -1,20 +1,21 @@
<# <#
.SYNOPSIS .SYNOPSIS
Erstellt ein neues GitHub-Issue und fügt es zum Project Board hinzu. Erstellt ein neues Issue auf Forgejo und setzt das Status-Label.
.DESCRIPTION .DESCRIPTION
Legt ein Issue an, fügt es zum Project Board hinzu, setzt den Board-Status Legt ein Issue an und setzt ein Status-Label (status:todo oder status:backlog).
und den Order-Wert. Die Reihenfolge ergibt sich aus der Issue-Nummer (aufsteigend = ältere zuerst).
- Status "Todo" (Standard): Order = niedrigster Todo-Wert minus 1 wird als nächstes Ticket eingereiht. - Status "Todo" (Standard): Label status:todo wird als nächstes Ticket gefunden.
- Status "Backlog": Order = höchster nicht-Done-Wert plus 10 wird ans Ende gestellt. - Status "Backlog": Label status:backlog wird erst nach Todo-Items bearbeitet.
.PARAMETER Title .PARAMETER Title
Titel des neuen Issues. Titel des neuen Issues.
.PARAMETER Body .PARAMETER Body
Body-Text des Issues (Markdown). Optional, Default: leer. Body-Text des Issues (Markdown). Optional, Default: leer.
.PARAMETER Labels .PARAMETER Labels
Komma-separierte Labels (z.B. "migration,crm,enhancement"). Muss mindestens Komma-separierte Labels (z.B. "infrastructure,enhancement"). Muss mindestens
ein Type-Label enthalten: migration, tech-decision oder infrastructure. ein Type-Label enthalten: migration, tech-decision, infrastructure, block-planning,
feature, refactoring, planning oder test.
.PARAMETER Status .PARAMETER Status
Board-Status des neuen Tickets: "Todo" (Standard) oder "Backlog". Status des neuen Tickets: "Todo" (Standard) oder "Backlog".
#> #>
param( param(
[Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Title,
@ -24,13 +25,15 @@ param(
[string]$Status = "Todo" [string]$Status = "Todo"
) )
$repo = "jreinemann-euris/bollwerk" $forgejoBase = "https://git.bollwerk.online/api/v1"
$projectId = "PVT_kwHOCFqiJ84BXk9U" $repo = "bollwerkadmin/bollwerk"
$orderFieldId = "PVTF_lAHOCFqiJ84BXk9UzhSw4jo" $tokenFile = Join-Path $PSScriptRoot "forgejo-token.txt"
$statusFieldId = "PVTSSF_lAHOCFqiJ84BXk9UzhSw4es" $token = (Get-Content $tokenFile -Raw -ErrorAction Stop).Trim()
$statusOptionMap = @{ $headers = @{ Authorization = "token $token"; "Content-Type" = "application/json" }
"Todo" = "f75ad846"
"Backlog" = "4ce6ee37" $statusLabelMap = @{
"Todo" = "status:todo"
"Backlog" = "status:backlog"
} }
# --- 1. Type-Label validieren --- # --- 1. Type-Label validieren ---
@ -42,65 +45,27 @@ if (-not $hasType) {
exit 1 exit 1
} }
# --- 2. Issue anlegen --- # --- 2. Alle Label-Namen zu IDs auflösen ---
$createArgs = @("issue", "create", "--repo", $repo, "--title", $Title, "--label", $Labels) $allLabels = (Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/labels?limit=50" -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
if ($Body) { $statusLabelName = $statusLabelMap[$Status]
$createArgs += "--body" $allRequiredLabels = $labelList + $statusLabelName
$createArgs += $Body
} $labelIds = @()
$issueUrl = & gh @createArgs 2>&1 foreach ($name in $allRequiredLabels) {
if ($LASTEXITCODE -ne 0) { $found = $allLabels | Where-Object { $_.name -eq $name } | Select-Object -First 1
Write-Error "Fehler beim Anlegen des Issues: $issueUrl" if ($found) { $labelIds += $found.id }
exit 1 else { Write-Warning "Label '$name' nicht gefunden wird übersprungen." }
} }
$issueNumber = ($issueUrl -split '/')[-1] # --- 3. Issue anlegen ---
$issueBody = @{ title = $Title; labels = $labelIds }
if ($Body) { $issueBody["body"] = $Body }
$issueJson = $issueBody | ConvertTo-Json
# --- 3. Zum Board hinzufügen --- $issueResp = Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/issues" -Method Post -Headers $headers -Body $issueJson -UseBasicParsing
$addResult = gh project item-add 2 --owner jreinemann-euris --url $issueUrl --format json 2>&1 $issue = $issueResp.Content | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { $issueNumber = $issue.number
Write-Error "Fehler beim Hinzufügen zum Board: $addResult" $issueUrl = $issue.html_url
exit 1
}
$itemId = ($addResult | ConvertFrom-Json).id
# --- 4. Order-Wert ermitteln --- Write-Host "#$issueNumber $Title (Status: $Status)"
$raw = gh project item-list 2 --owner jreinemann-euris --format json --limit 200 | ConvertFrom-Json Write-Host "URL: $issueUrl"
$otherItems = $raw.items | Where-Object { $_.status -ne "Done" -and $_.id -ne $itemId }
if ($Status -eq "Todo") {
# Als nächstes Ticket: niedrigster bestehender Wert minus 1
$refOrder = $otherItems |
ForEach-Object { [double]$_.order } |
Where-Object { $_ -gt 0 } |
Measure-Object -Minimum |
Select-Object -ExpandProperty Minimum
if ($null -eq $refOrder) { $refOrder = 10 }
$newOrder = $refOrder - 1
} else {
# Backlog: ans Ende stellen (höchster bestehender Wert plus 10)
$refOrder = $otherItems |
ForEach-Object { [double]$_.order } |
Where-Object { $_ -gt 0 } |
Measure-Object -Maximum |
Select-Object -ExpandProperty Maximum
if ($null -eq $refOrder) { $refOrder = 0 }
$newOrder = $refOrder + 10
}
# --- 5. Order setzen ---
gh project item-edit --project-id $projectId --id $itemId --field-id $orderFieldId --number $newOrder 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Fehler beim Setzen des Order-Werts."
exit 1
}
# --- 6. Board-Status setzen ---
$optionId = $statusOptionMap[$Status]
gh project item-edit --project-id $projectId --id $itemId --field-id $statusFieldId --single-select-option-id $optionId 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Fehler beim Setzen des Board-Status."
exit 1
}
Write-Host "#$issueNumber $Title (Status: $Status, Order: $newOrder)"

View file

@ -1,56 +1,58 @@
<# <#
.SYNOPSIS .SYNOPSIS
Ermittelt das nächste offene Ticket aus dem GitHub Project Board. Ermittelt das nächste offene Ticket aus Forgejo (via status:todo Label).
.DESCRIPTION .DESCRIPTION
Sortierung: priority:high (0) normal (1) priority:low (2), dann Order aufsteigend. Sortierung: priority:high (0) normal (1) priority:low (2), dann Issue-Nummer aufsteigend.
Gibt Nummer, Typ-Label, Titel und Order aus. Gibt Nummer, Typ-Label, Titel und Order (= Issue-Nummer) aus.
.PARAMETER IssueNumber .PARAMETER IssueNumber
Optional: Direkt eine Issue-Nummer abfragen statt Board-Suche. Optional: Direkt eine Issue-Nummer abfragen statt automatischer Suche.
#> #>
param([int]$IssueNumber) param([int]$IssueNumber)
$repo = "jreinemann-euris/bollwerk" $forgejoBase = "https://git.bollwerk.online/api/v1"
$repo = "bollwerkadmin/bollwerk"
$tokenFile = Join-Path $PSScriptRoot "forgejo-token.txt"
$token = (Get-Content $tokenFile -Raw -ErrorAction Stop).Trim()
$headers = @{ Authorization = "token $token" }
function Get-IssueType($labelNames) {
if ($labelNames -contains "block-planning") { "[B]" }
elseif ($labelNames -contains "migration") { "[M]" }
elseif ($labelNames -contains "feature") { "[F]" }
elseif ($labelNames -contains "refactoring") { "[F]" }
elseif ($labelNames -contains "tech-decision") { "[T]" }
elseif ($labelNames -contains "infrastructure") { "[I]" }
elseif ($labelNames -contains "planning") { "[P]" }
elseif ($labelNames -contains "test") { "[X]" }
else { "[?]" }
}
if ($IssueNumber -gt 0) { if ($IssueNumber -gt 0) {
# Variante A: Explizite Issue-Nummer # Variante A: Explizite Issue-Nummer → Forgejo REST API
$json = gh issue view $IssueNumber --repo $repo --json number,title,labels | ConvertFrom-Json $url = "$forgejoBase/repos/$repo/issues/$IssueNumber"
$labels = $json.labels | ForEach-Object { $_.name } $issue = (Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
$type = if ($labels -contains "block-planning") { "[B]" } $labelNames = $issue.labels | ForEach-Object { $_.name }
elseif ($labels -contains "migration") { "[M]" } $type = Get-IssueType $labelNames
elseif ($labels -contains "feature") { "[F]" } Write-Host "#$($issue.number) $type $($issue.title)"
elseif ($labels -contains "refactoring") { "[F]" }
elseif ($labels -contains "tech-decision") { "[T]" }
elseif ($labels -contains "infrastructure") { "[I]" }
elseif ($labels -contains "planning") { "[P]" }
elseif ($labels -contains "test") { "[X]" }
else { "[?]" }
Write-Host "#$($json.number) $type $($json.title)"
} }
else { else {
# Variante B: Nächstes Ticket per Board-Query (client-seitig auf Todo gefiltert) # Variante B: Nächstes Ticket per Forgejo-Label status:todo
$raw = gh project item-list 2 --owner jreinemann-euris --format json --limit 200 | ConvertFrom-Json $url = "$forgejoBase/repos/$repo/issues?state=open&type=issues&labels=status%3Atodo&limit=50&sort=oldest"
if ($null -eq $raw -or $null -eq $raw.items) { $issues = (Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
Write-Host "Keine offenen Tickets gefunden." if ($null -eq $issues -or $issues.Count -eq 0) {
Write-Host "Keine offenen Tickets mit 'status:todo' gefunden."
return return
} }
$todos = $raw.items | Where-Object { $_.status -eq "Todo" } | ForEach-Object { $todos = $issues | ForEach-Object {
$labels = $_.labels $labelNames = $_.labels | ForEach-Object { $_.name }
$prio = if ($labels -contains "priority:high") { 0 } $prio = if ($labelNames -contains "priority:high") { 0 }
elseif ($labels -contains "priority:low") { 2 } elseif ($labelNames -contains "priority:low") { 2 }
else { 1 } else { 1 }
$type = if ($labels -contains "block-planning") { "[B]" } $type = Get-IssueType $labelNames
elseif ($labels -contains "migration") { "[M]" }
elseif ($labels -contains "feature") { "[F]" }
elseif ($labels -contains "refactoring") { "[F]" }
elseif ($labels -contains "tech-decision") { "[T]" }
elseif ($labels -contains "infrastructure") { "[I]" }
elseif ($labels -contains "planning") { "[P]" }
elseif ($labels -contains "test") { "[X]" }
else { "[?]" }
[PSCustomObject]@{ [PSCustomObject]@{
Number = $_.content.number Number = $_.number
Title = $_.content.title Title = $_.title
Order = if ($null -ne $_.order) { $_.order } else { 999999 } Order = $_.number # Issue-Nummer als Tiebreaker (aufsteigend = älteste zuerst)
Prio = $prio Prio = $prio
Type = $type Type = $type
} }

View file

@ -1,61 +1,62 @@
<# <#
.SYNOPSIS .SYNOPSIS
Setzt den Board-Status eines Issues im GitHub Project Board. Setzt den Status eines Issues in Forgejo via Label (status:todo / status:in-progress / status:done / status:backlog).
.DESCRIPTION .DESCRIPTION
Ermittelt die Item-ID des Issues im Board und setzt den Status Entfernt alle status:*-Labels und setzt das neue Status-Label.
auf den angegebenen Wert.
Akzeptierte Werte: "Todo", "InProgress" (oder "In Progress"), "Done", "Backlog". Akzeptierte Werte: "Todo", "InProgress" (oder "In Progress"), "Done", "Backlog".
.PARAMETER IssueNumber .PARAMETER IssueNumber
Die Issue-Nummer (z.B. 68). Die Issue-Nummer (z.B. 68).
.PARAMETER Status .PARAMETER Status
Der Zielstatus: "Todo", "InProgress", "In Progress" oder "Done". Der Zielstatus: "Todo", "InProgress", "In Progress", "Done" oder "Backlog".
#> #>
param( param(
[Parameter(Mandatory)][int]$IssueNumber, [Parameter(Mandatory)][int]$IssueNumber,
[Parameter(Mandatory)][string]$Status [Parameter(Mandatory)][string]$Status
) )
$projectId = "PVT_kwHOCFqiJ84BXk9U" $forgejoBase = "https://git.bollwerk.online/api/v1"
$statusFieldId = "PVTSSF_lAHOCFqiJ84BXk9UzhSw4es" $repo = "bollwerkadmin/bollwerk"
$tokenFile = Join-Path $PSScriptRoot "forgejo-token.txt"
$token = (Get-Content $tokenFile -Raw -ErrorAction Stop).Trim()
$headers = @{ Authorization = "token $token"; "Content-Type" = "application/json" }
# Normalisierung: "In Progress" → "InProgress" (robust gegen Agent-Varianten) # Normalisierung
$normalizedStatus = $Status.Trim() -replace '\s+', '' $normalizedStatus = $Status.Trim() -replace '\s+', ''
# "InProgress" nach Kleinschreibung angleichen an Map-Schlüssel
$statusMap = @{ $labelMap = @{
"Todo" = "f75ad846" "Todo" = "status:todo"
"InProgress" = "47fc9ee4" "InProgress" = "status:in-progress"
"Done" = "98236657" "Done" = "status:done"
"Backlog" = "4ce6ee37" "Backlog" = "status:backlog"
} }
if (-not $statusMap.ContainsKey($normalizedStatus)) { if (-not $labelMap.ContainsKey($normalizedStatus)) {
$valid = $statusMap.Keys -join ", " $valid = $labelMap.Keys -join ", "
Write-Error "Ungültiger Status '$Status'. Gültige Werte: $valid (auch 'In Progress' für InProgress, 'Backlog' für Backlog)." Write-Error "Ungültiger Status '$Status'. Gültige Werte: $valid (auch 'In Progress' für InProgress)."
exit 1 exit 1
} }
$optionId = $statusMap[$normalizedStatus] $newLabel = $labelMap[$normalizedStatus]
# Item-ID im Board finden # Alle Labels des Issues abrufen
$raw = gh project item-list 2 --owner jreinemann-euris --format json --limit 200 2>&1 $issueUrl = "$forgejoBase/repos/$repo/issues/$IssueNumber"
if ($LASTEXITCODE -ne 0) { $issue = (Invoke-WebRequest -Uri $issueUrl -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
Write-Error "Fehler beim Abrufen des Project Boards: $raw"
exit 1 # Alte status:*-Labels entfernen
$statusLabelIds = $issue.labels | Where-Object { $_.name -like "status:*" } | ForEach-Object { $_.id }
foreach ($id in $statusLabelIds) {
Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/issues/$IssueNumber/labels/$id" -Method Delete -Headers $headers -UseBasicParsing | Out-Null
} }
$items = $raw | ConvertFrom-Json
$item = $items.items | Where-Object { $null -ne $_.content -and $_.content.number -eq $IssueNumber } | Select-Object -First 1
if (-not $item) { # Neues Status-Label-ID ermitteln
Write-Error "Issue #$IssueNumber nicht im Project Board gefunden. Bitte zuerst mit 'gh project item-add' hinzufügen." $allLabels = (Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/labels?limit=50" -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
$targetLabel = $allLabels | Where-Object { $_.name -eq $newLabel } | Select-Object -First 1
if (-not $targetLabel) {
Write-Error "Label '$newLabel' nicht auf Forgejo gefunden. Bitte zuerst anlegen."
exit 1 exit 1
} }
$itemId = $item.id # Neues Status-Label setzen
$body = "{`"labels`":[" + $targetLabel.id + "]}"
Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/issues/$IssueNumber/labels" -Method Post -Headers $headers -Body $body -UseBasicParsing | Out-Null
$editOutput = gh project item-edit --project-id $projectId --id $itemId --field-id $statusFieldId --single-select-option-id $optionId 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Issue #$IssueNumber$normalizedStatus" Write-Host "Issue #$IssueNumber$normalizedStatus"
}
else {
Write-Error "Fehler beim Setzen des Status für Issue #${IssueNumber}: $editOutput"
exit 1
}

View file

@ -1,11 +1,11 @@
--- ---
name: ship name: ship
description: "CoPuPi-Workflow: Commit, Mac-Build-Test, Push, GitHub-Pipeline beobachten. Verwende diesen Skill für alles rund um das Ausliefern von Code git push, Pipeline-Status, CI-Fehler beheben, Version hochzählen. Trigger-Phrasen: 'ship', 'push', 'pipeline', 'CI', 'deployen', 'ausliefern', 'version bump'." description: "CoPuPi-Workflow: Commit, Mac-Build-Test, Push zu Forgejo. Verwende diesen Skill für alles rund um das Ausliefern von Code git push, Version hochzählen. Trigger-Phrasen: 'ship', 'push', 'pipeline', 'CI', 'deployen', 'ausliefern', 'version bump'."
--- ---
# Skill: Ship (CoPuPi-Workflow) # Skill: Ship (CoPuPi-Workflow)
Dieser Skill kapselt Tools und Konventionen für den Ship-Workflow den Weg vom lokalen Commit bis zur grünen CI-Pipeline. Dieser Skill kapselt Tools und Konventionen für den Ship-Workflow den Weg vom lokalen Commit bis zum Push nach Forgejo (`git.bollwerk.online`).
Der vollständige Workflow wird über `.github/prompts/ship.prompt.md` gesteuert. Der vollständige Workflow wird über `.github/prompts/ship.prompt.md` gesteuert.
@ -15,7 +15,7 @@ Der vollständige Workflow wird über `.github/prompts/ship.prompt.md` gesteuert
### `watch-pipeline.ps1` ### `watch-pipeline.ps1`
Beobachtet den neuesten GitHub Actions Run bis zum Abschluss. Gibt Status, Dauer und Warnungen aus. Beobachtet den neuesten Forgejo Actions Run bis zum Abschluss. Gibt Status, Dauer und Warnungen aus.
**Aufruf:** **Aufruf:**

View file

@ -216,6 +216,6 @@ Die SQLite-Datenbank wird unter `/opt/bollwerk/data/` auf dem Host gemountet und
- **1 GB RAM:** JVM-Heap auf 384 MB begrenzt. Kein Spielraum für weitere Dienste. - **1 GB RAM:** JVM-Heap auf 384 MB begrenzt. Kein Spielraum für weitere Dienste.
- **Kein HTTPS:** Server läuft aktuell nur auf HTTP Port 8080. Für HTTPS → Caddy als Reverse Proxy einrichten. - **Kein HTTPS:** Server läuft aktuell nur auf HTTP Port 8080. Für HTTPS → Caddy als Reverse Proxy einrichten.
- **Kein CI/CD:** Deployment ist manuell (JAR bauen → scp → docker compose up). Ggf. GitHub Actions Pipeline ergänzen. - **Kein CI/CD:** Deployment ist manuell (JAR bauen → scp → docker compose up). Ggf. Forgejo Actions Pipeline ergänzen.
- **Dockerfile lokal:** Das Dockerfile auf dem VPS (`/opt/bollwerk/Dockerfile`) ist ein schlankes Runtime-Only-Image. Das Multi-Stage-Dockerfile im Repo-Root ist für lokale Builds gedacht. - **Dockerfile lokal:** Das Dockerfile auf dem VPS (`/opt/bollwerk/Dockerfile`) ist ein schlankes Runtime-Only-Image. Das Multi-Stage-Dockerfile im Repo-Root ist für lokale Builds gedacht.
- **SSH-Escape-Problem:** Beim Schreiben von Dateien via SSH-Heredoc werden JSON-Quotes zerstört. Dateien immer lokal erstellen und per `scp` hochladen. - **SSH-Escape-Problem:** Beim Schreiben von Dateien via SSH-Heredoc werden JSON-Quotes zerstört. Dateien immer lokal erstellen und per `scp` hochladen.

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -83,8 +82,10 @@ internal fun UserListScreen(
@Composable @Composable
private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) { private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) {
val statusText = formatOnlineStatus(user.lastSeen)
ListItem( ListItem(
headlineContent = { Text(user.username) }, headlineContent = { Text(user.username) },
supportingContent = statusText?.let { { Text(it, style = MaterialTheme.typography.bodySmall) } },
leadingContent = { leadingContent = {
Icon(imageVector = Icons.Outlined.Person, contentDescription = null) Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
}, },
@ -97,3 +98,23 @@ private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () ->
) )
HorizontalDivider() HorizontalDivider()
} }
private fun formatOnlineStatus(lastSeen: Long?): String? {
if (lastSeen == null) return null
val now = System.currentTimeMillis()
if (now - lastSeen < ONLINE_THRESHOLD_MS) return "Verbunden"
val dt = java.util.Date(lastSeen)
val cal = java.util.Calendar.getInstance().apply { time = dt }
val todayCal = java.util.Calendar.getInstance()
val isToday = cal.get(java.util.Calendar.YEAR) == todayCal.get(java.util.Calendar.YEAR) &&
cal.get(java.util.Calendar.DAY_OF_YEAR) == todayCal.get(java.util.Calendar.DAY_OF_YEAR)
val timeFmt = java.text.SimpleDateFormat("HH:mm", java.util.Locale.GERMAN)
return if (isToday) {
"zuletzt online um ${timeFmt.format(dt)}"
} else {
val dateFmt = java.text.SimpleDateFormat("dd.MM.", java.util.Locale.GERMAN)
"zuletzt online am ${dateFmt.format(dt)} um ${timeFmt.format(dt)}"
}
}
private const val ONLINE_THRESHOLD_MS = 60_000L

View file

@ -5,10 +5,13 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.bollwerk.app.domain.repository.MessageRepository import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.shared.model.UserListItemDto import de.bollwerk.shared.model.UserListItemDto
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -29,8 +32,30 @@ internal class UserListViewModel @Inject constructor(
val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender() val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private var pollingJob: Job? = null
init { init {
loadUsers() loadUsers()
startPolling()
}
private fun startPolling() {
pollingJob = viewModelScope.launch {
while (isActive) {
delay(POLL_INTERVAL_MS)
refreshStatuses()
}
}
}
private suspend fun refreshStatuses() {
val result = messageRepository.fetchUsers()
if (result.isSuccess) {
val fresh = result.getOrDefault(emptyList())
_uiState.value = _uiState.value.copy(users = fresh)
} else {
android.util.Log.w(TAG, "Online-Status-Polling fehlgeschlagen: ${result.exceptionOrNull()?.message}")
}
} }
private fun loadUsers() { private fun loadUsers() {
@ -49,4 +74,9 @@ internal class UserListViewModel @Inject constructor(
} }
fun retry() = loadUsers() fun retry() = loadUsers()
private companion object {
const val POLL_INTERVAL_MS = 30_000L
const val TAG = "UserListViewModel"
}
} }

View file

@ -25,9 +25,14 @@ internal fun Route.userRoutes(userRepository: UserRepository, webSocketManager:
HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized") ErrorResponse(status = 401, message = "Unauthorized")
) )
val now = System.currentTimeMillis()
val users = userRepository.listAll() val users = userRepository.listAll()
.filter { it.id != principal.userId } .filter { it.id != principal.userId }
.map { UserListItemDto(id = it.id, username = it.username) } .map { user ->
val online = webSocketManager.isOnline(user.id)
val lastSeen = if (online) now else webSocketManager.getLastSeen(user.id)
UserListItemDto(id = user.id, username = user.username, lastSeen = lastSeen)
}
call.respond(HttpStatusCode.OK, users) call.respond(HttpStatusCode.OK, users)
} }

View file

@ -13,17 +13,22 @@ import java.util.concurrent.CopyOnWriteArraySet
internal class WebSocketManager { internal class WebSocketManager {
private val sessions = ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>() private val sessions = ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>()
private val lastSeenMap = ConcurrentHashMap<String, Long>()
fun addSession(userId: String, session: WebSocketSession) { fun addSession(userId: String, session: WebSocketSession) {
sessions.getOrPut(userId) { CopyOnWriteArraySet() }.add(session) sessions.computeIfAbsent(userId) { CopyOnWriteArraySet() }.add(session)
lastSeenMap[userId] = System.currentTimeMillis()
} }
fun removeSession(userId: String, session: WebSocketSession) { fun removeSession(userId: String, session: WebSocketSession) {
sessions[userId]?.remove(session) sessions[userId]?.remove(session)
lastSeenMap[userId] = System.currentTimeMillis()
} }
fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true
fun getLastSeen(userId: String): Long? = lastSeenMap[userId]
suspend fun notifyInventoryUpdated(userId: String, itemId: String) { suspend fun notifyInventoryUpdated(userId: String, itemId: String) {
val payload = buildJsonObject { val payload = buildJsonObject {
put("type", "inventoryUpdated") put("type", "inventoryUpdated")

View file

@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class UserListItemDto( data class UserListItemDto(
val id: String, val id: String,
val username: String val username: String,
val lastSeen: Long? = null
) )