Compare commits

..

No commits in common. "2af82d60d018b8ba2639c5b65c34510563725e8a" and "c16c9fff97d7a6c92018a1472a88bc7647c84aba" have entirely different histories.

11 changed files with 208 additions and 227 deletions

View file

@ -1,11 +1,11 @@
--- ---
name: gh-tickets name: gh-tickets
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'." 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'."
--- ---
# Skill: Forgejo Tickets (gh-tickets) # Skill: GitHub Tickets (gh-tickets)
Dieses Dokument definiert die verbindlichen Konventionen für Issues im Forgejo-Repository `bollwerkadmin/bollwerk` (`https://git.bollwerk.online`). Dieses Dokument definiert die verbindlichen Konventionen für GitHub-Issues im Repository `jreinemann-euris/bollwerk`.
--- ---
@ -49,69 +49,83 @@ Stoppe und fordere den User zur Zuordnung auf. Starte den Workflow **nicht** ohn
--- ---
## 2. Sortierung über Status-Labels ## 2. Sortierung über Project Board
Die Abarbeitungsreihenfolge wird über **Status-Labels** in Forgejo gesteuert. Die Abarbeitungsreihenfolge wird über das **Project Board** gesteuert, nicht über Labels.
### Status-Labels ### Board-Daten
| Label | Bedeutung | | Eigenschaft | Wert |
| ------------------- | ------------------------------------------------- | | ------------ | ---------------------------------------------------- |
| `status:todo` | Nächste offene Aufgabe (wird von next-ticket.ps1 gefunden) | | Projekt-Name | `Bollwerk` |
| `status:in-progress`| Aktuell in Bearbeitung | | Projekt-Nr | `2` |
| `status:done` | Abgeschlossen | | Owner | `jreinemann-euris` |
| `status:backlog` | Im Backlog wird erst nach todo-Items bearbeitet | | Sortierfeld | `Order` (Number-Feld) |
| URL | https://github.com/users/jreinemann-euris/projects/2 |
### Repository ### Order-Feld Konventionen
| Eigenschaft | Wert | - **Kleinere Zahl = höhere Priorität** (wird zuerst abgearbeitet)
| ------------ | ------------------------------------------------- | - **Jeder Order-Wert MUSS eindeutig sein** keine zwei Items dürfen denselben Wert haben
| URL | https://git.bollwerk.online/bollwerkadmin/bollwerk | - Standardwerte: 10, 20, 30, 40, … (in 10er-Schritten, um Platz für Einfügungen zu lassen)
| API-Base | https://git.bollwerk.online/api/v1 | - Ein dringend vorgezogenes Ticket bekommt einen Wert **zwischen** den bestehenden (z.B. 15 zwischen 10 und 20, oder 5 vor 10)
| Token | `.github/skills/gh-tickets/forgejo-token.txt` (lokal, nicht committed) | - 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 Issue-Nummer aufsteigend): # Nächstes Ticket (priority:high zuerst, dann Order 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 128 & ".github/skills/gh-tickets/next-ticket.ps1" -IssueNumber 98
``` ```
Ausgabe: `#128 [I] infra(forgejo): Projektlinks, Skills und Referenzen umstellen (Order: 128)` Ausgabe: `#68 [M] CRM: Erweiterte Kundensuche (Order: 120)`
### Status aktualisieren ### Issue zum Board hinzufügen
```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 128 -Status InProgress & ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 68 -Status InProgress
# Status auf "Done" setzen # Status auf "Done" setzen
& ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 128 -Status Done & ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 68 -Status Done
# Status auf "Todo" zurücksetzen # Status auf "Todo" zurücksetzen
& ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 128 -Status Todo & ".github/skills/gh-tickets/set-board-status.ps1" -IssueNumber 68 -Status Todo
``` ```
Gültige Status-Werte: `Todo`, `InProgress`, `Done`, `Backlog` Gültige Status-Werte: `Todo`, `InProgress`, `Done`
--- ---
## 3. Issue anlegen (Checkliste) ## 3. Issue anlegen (Checkliste)
Beim Anlegen eines neuen Issues **MÜSSEN alle 3 Schritte** durchgeführt werden: Beim Anlegen eines neuen Issues **MÜSSEN alle 5 Schritte** durchgeführt werden:
1. **Titel**: Beschreibend, ggf. mit Modulpräfix (z.B. `feat(core):`, `infra(forgejo):`) 1. **Titel**: Beschreibend, ggf. mit Modulpräfix (z.B. `CRM:`, `feat(core):`)
2. **Type-Label**: Genau eines von `migration`, `tech-decision`, `infrastructure`, `block-planning`, `feature`, `refactoring`, `planning`, `test` 2. **Type-Label**: Genau eines von `migration`, `tech-decision`, `infrastructure`, `planning`, `test`
3. **Status-Label**: `status:todo` (sofort bearbeiten) oder `status:backlog` (später) 3. **Weitere Labels**: Optional (z.B. `crm`, `enhancement`)
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 Status-Label!** Ein Issue ohne `status:todo` oder `status:backlog` wird bei „nächste Aufgabe" nicht gefunden. > **Kein Issue ohne Board + Order!** Ein Issue ohne Board-Eintrag wird bei „nächste Aufgabe" nicht gefunden.
### Dringend vorgezogenes Issue anlegen ("als nächstes") ### Dringend vorgezogenes Issue anlegen ("als nächstes")
@ -120,24 +134,21 @@ Beim Anlegen eines neuen Issues **MÜSSEN alle 3 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 mit status:todo (wird als nächstes gefunden, niedrigste Nummer) # Issue anlegen und als nächstes Ticket einsortieren (niedrigster Order-Wert)
& ".github/skills/gh-tickets/create-next-ticket.ps1" -Title "fix(server): Bug in Auth" -Labels "infrastructure" -Body "Beschreibung..." & ".github/skills/gh-tickets/create-next-ticket.ps1" -Title "CRM: Bug in Kundensuche" -Labels "migration,crm" -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: Issue anlegen + Status-Label setzen. Das Skript erledigt automatisch alle 5 Schritte: Issue anlegen, Board hinzufügen, Order auf niedrigsten Wert 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 + Issue-Nummer (zweistufig) ## 4. Sortierung: Priorität + Order (zweistufig)
Die Abarbeitungsreihenfolge wird **zweistufig** bestimmt: Die Abarbeitungsreihenfolge wird **zweistufig** bestimmt:
1. **Primär: Label `priority:high`** → Issues mit diesem Label kommen **immer zuerst** 1. **Primär: Label `priority:high`** → Issues mit diesem Label kommen **immer zuerst**, unabhängig vom Order-Wert
2. **Sekundär: Label `priority:low`** → Issues mit diesem Label kommen **immer zuletzt** 2. **Sekundär: Label `priority:low`** → Issues mit diesem Label kommen **immer zuletzt**, unabhängig vom Order-Wert
3. **Tertiär: Issue-Nummer aufsteigend** → Niedrigere Nummer = früher erstellt = höhere Priorität 3. **Tertiär: Order-Feld** → Innerhalb derselben Prioritätsstufe sortiert der Order-Wert aufsteigend (kleinere Zahl = früher dran)
Beispiel: Ein Issue #50 mit `priority:high` kommt **vor** Issue #10 ohne 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.

View file

@ -1,21 +1,20 @@
<# <#
.SYNOPSIS .SYNOPSIS
Erstellt ein neues Issue auf Forgejo und setzt das Status-Label. Erstellt ein neues GitHub-Issue und fügt es zum Project Board hinzu.
.DESCRIPTION .DESCRIPTION
Legt ein Issue an und setzt ein Status-Label (status:todo oder status:backlog). Legt ein Issue an, fügt es zum Project Board hinzu, setzt den Board-Status
Die Reihenfolge ergibt sich aus der Issue-Nummer (aufsteigend = ältere zuerst). und den Order-Wert.
- Status "Todo" (Standard): Label status:todo wird als nächstes Ticket gefunden. - Status "Todo" (Standard): Order = niedrigster Todo-Wert minus 1 wird als nächstes Ticket eingereiht.
- Status "Backlog": Label status:backlog wird erst nach Todo-Items bearbeitet. - Status "Backlog": Order = höchster nicht-Done-Wert plus 10 wird ans Ende gestellt.
.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. "infrastructure,enhancement"). Muss mindestens Komma-separierte Labels (z.B. "migration,crm,enhancement"). Muss mindestens
ein Type-Label enthalten: migration, tech-decision, infrastructure, block-planning, ein Type-Label enthalten: migration, tech-decision oder infrastructure.
feature, refactoring, planning oder test.
.PARAMETER Status .PARAMETER Status
Status des neuen Tickets: "Todo" (Standard) oder "Backlog". Board-Status des neuen Tickets: "Todo" (Standard) oder "Backlog".
#> #>
param( param(
[Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Title,
@ -25,15 +24,13 @@ param(
[string]$Status = "Todo" [string]$Status = "Todo"
) )
$forgejoBase = "https://git.bollwerk.online/api/v1" $repo = "jreinemann-euris/bollwerk"
$repo = "bollwerkadmin/bollwerk" $projectId = "PVT_kwHOCFqiJ84BXk9U"
$tokenFile = Join-Path $PSScriptRoot "forgejo-token.txt" $orderFieldId = "PVTF_lAHOCFqiJ84BXk9UzhSw4jo"
$token = (Get-Content $tokenFile -Raw -ErrorAction Stop).Trim() $statusFieldId = "PVTSSF_lAHOCFqiJ84BXk9UzhSw4es"
$headers = @{ Authorization = "token $token"; "Content-Type" = "application/json" } $statusOptionMap = @{
"Todo" = "f75ad846"
$statusLabelMap = @{ "Backlog" = "4ce6ee37"
"Todo" = "status:todo"
"Backlog" = "status:backlog"
} }
# --- 1. Type-Label validieren --- # --- 1. Type-Label validieren ---
@ -45,27 +42,65 @@ if (-not $hasType) {
exit 1 exit 1
} }
# --- 2. Alle Label-Namen zu IDs auflösen --- # --- 2. Issue anlegen ---
$allLabels = (Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/labels?limit=50" -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json $createArgs = @("issue", "create", "--repo", $repo, "--title", $Title, "--label", $Labels)
$statusLabelName = $statusLabelMap[$Status] if ($Body) {
$allRequiredLabels = $labelList + $statusLabelName $createArgs += "--body"
$createArgs += $Body
$labelIds = @() }
foreach ($name in $allRequiredLabels) { $issueUrl = & gh @createArgs 2>&1
$found = $allLabels | Where-Object { $_.name -eq $name } | Select-Object -First 1 if ($LASTEXITCODE -ne 0) {
if ($found) { $labelIds += $found.id } Write-Error "Fehler beim Anlegen des Issues: $issueUrl"
else { Write-Warning "Label '$name' nicht gefunden wird übersprungen." } exit 1
} }
# --- 3. Issue anlegen --- $issueNumber = ($issueUrl -split '/')[-1]
$issueBody = @{ title = $Title; labels = $labelIds }
if ($Body) { $issueBody["body"] = $Body }
$issueJson = $issueBody | ConvertTo-Json
$issueResp = Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/issues" -Method Post -Headers $headers -Body $issueJson -UseBasicParsing # --- 3. Zum Board hinzufügen ---
$issue = $issueResp.Content | ConvertFrom-Json $addResult = gh project item-add 2 --owner jreinemann-euris --url $issueUrl --format json 2>&1
$issueNumber = $issue.number if ($LASTEXITCODE -ne 0) {
$issueUrl = $issue.html_url Write-Error "Fehler beim Hinzufügen zum Board: $addResult"
exit 1
}
$itemId = ($addResult | ConvertFrom-Json).id
Write-Host "#$issueNumber $Title (Status: $Status)" # --- 4. Order-Wert ermitteln ---
Write-Host "URL: $issueUrl" $raw = gh project item-list 2 --owner jreinemann-euris --format json --limit 200 | ConvertFrom-Json
$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,58 +1,56 @@
<# <#
.SYNOPSIS .SYNOPSIS
Ermittelt das nächste offene Ticket aus Forgejo (via status:todo Label). Ermittelt das nächste offene Ticket aus dem GitHub Project Board.
.DESCRIPTION .DESCRIPTION
Sortierung: priority:high (0) normal (1) priority:low (2), dann Issue-Nummer aufsteigend. Sortierung: priority:high (0) normal (1) priority:low (2), dann Order aufsteigend.
Gibt Nummer, Typ-Label, Titel und Order (= Issue-Nummer) aus. Gibt Nummer, Typ-Label, Titel und Order aus.
.PARAMETER IssueNumber .PARAMETER IssueNumber
Optional: Direkt eine Issue-Nummer abfragen statt automatischer Suche. Optional: Direkt eine Issue-Nummer abfragen statt Board-Suche.
#> #>
param([int]$IssueNumber) param([int]$IssueNumber)
$forgejoBase = "https://git.bollwerk.online/api/v1" $repo = "jreinemann-euris/bollwerk"
$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 → Forgejo REST API # Variante A: Explizite Issue-Nummer
$url = "$forgejoBase/repos/$repo/issues/$IssueNumber" $json = gh issue view $IssueNumber --repo $repo --json number,title,labels | ConvertFrom-Json
$issue = (Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json $labels = $json.labels | ForEach-Object { $_.name }
$labelNames = $issue.labels | ForEach-Object { $_.name } $type = if ($labels -contains "block-planning") { "[B]" }
$type = Get-IssueType $labelNames elseif ($labels -contains "migration") { "[M]" }
Write-Host "#$($issue.number) $type $($issue.title)" 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 { "[?]" }
Write-Host "#$($json.number) $type $($json.title)"
} }
else { else {
# Variante B: Nächstes Ticket per Forgejo-Label status:todo # Variante B: Nächstes Ticket per Board-Query (client-seitig auf Todo gefiltert)
$url = "$forgejoBase/repos/$repo/issues?state=open&type=issues&labels=status%3Atodo&limit=50&sort=oldest" $raw = gh project item-list 2 --owner jreinemann-euris --format json --limit 200 | ConvertFrom-Json
$issues = (Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json if ($null -eq $raw -or $null -eq $raw.items) {
if ($null -eq $issues -or $issues.Count -eq 0) { Write-Host "Keine offenen Tickets gefunden."
Write-Host "Keine offenen Tickets mit 'status:todo' gefunden."
return return
} }
$todos = $issues | ForEach-Object { $todos = $raw.items | Where-Object { $_.status -eq "Todo" } | ForEach-Object {
$labelNames = $_.labels | ForEach-Object { $_.name } $labels = $_.labels
$prio = if ($labelNames -contains "priority:high") { 0 } $prio = if ($labels -contains "priority:high") { 0 }
elseif ($labelNames -contains "priority:low") { 2 } elseif ($labels -contains "priority:low") { 2 }
else { 1 } else { 1 }
$type = Get-IssueType $labelNames $type = if ($labels -contains "block-planning") { "[B]" }
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 = $_.number Number = $_.content.number
Title = $_.title Title = $_.content.title
Order = $_.number # Issue-Nummer als Tiebreaker (aufsteigend = älteste zuerst) Order = if ($null -ne $_.order) { $_.order } else { 999999 }
Prio = $prio Prio = $prio
Type = $type Type = $type
} }

View file

@ -1,62 +1,61 @@
<# <#
.SYNOPSIS .SYNOPSIS
Setzt den Status eines Issues in Forgejo via Label (status:todo / status:in-progress / status:done / status:backlog). Setzt den Board-Status eines Issues im GitHub Project Board.
.DESCRIPTION .DESCRIPTION
Entfernt alle status:*-Labels und setzt das neue Status-Label. Ermittelt die Item-ID des Issues im Board und setzt den Status
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", "Done" oder "Backlog". Der Zielstatus: "Todo", "InProgress", "In Progress" oder "Done".
#> #>
param( param(
[Parameter(Mandatory)][int]$IssueNumber, [Parameter(Mandatory)][int]$IssueNumber,
[Parameter(Mandatory)][string]$Status [Parameter(Mandatory)][string]$Status
) )
$forgejoBase = "https://git.bollwerk.online/api/v1" $projectId = "PVT_kwHOCFqiJ84BXk9U"
$repo = "bollwerkadmin/bollwerk" $statusFieldId = "PVTSSF_lAHOCFqiJ84BXk9UzhSw4es"
$tokenFile = Join-Path $PSScriptRoot "forgejo-token.txt"
$token = (Get-Content $tokenFile -Raw -ErrorAction Stop).Trim()
$headers = @{ Authorization = "token $token"; "Content-Type" = "application/json" }
# Normalisierung # Normalisierung: "In Progress" → "InProgress" (robust gegen Agent-Varianten)
$normalizedStatus = $Status.Trim() -replace '\s+', '' $normalizedStatus = $Status.Trim() -replace '\s+', ''
# "InProgress" nach Kleinschreibung angleichen an Map-Schlüssel
$labelMap = @{ $statusMap = @{
"Todo" = "status:todo" "Todo" = "f75ad846"
"InProgress" = "status:in-progress" "InProgress" = "47fc9ee4"
"Done" = "status:done" "Done" = "98236657"
"Backlog" = "status:backlog" "Backlog" = "4ce6ee37"
} }
if (-not $labelMap.ContainsKey($normalizedStatus)) { if (-not $statusMap.ContainsKey($normalizedStatus)) {
$valid = $labelMap.Keys -join ", " $valid = $statusMap.Keys -join ", "
Write-Error "Ungültiger Status '$Status'. Gültige Werte: $valid (auch 'In Progress' für InProgress)." Write-Error "Ungültiger Status '$Status'. Gültige Werte: $valid (auch 'In Progress' für InProgress, 'Backlog' für Backlog)."
exit 1 exit 1
} }
$newLabel = $labelMap[$normalizedStatus] $optionId = $statusMap[$normalizedStatus]
# Alle Labels des Issues abrufen # Item-ID im Board finden
$issueUrl = "$forgejoBase/repos/$repo/issues/$IssueNumber" $raw = gh project item-list 2 --owner jreinemann-euris --format json --limit 200 2>&1
$issue = (Invoke-WebRequest -Uri $issueUrl -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json if ($LASTEXITCODE -ne 0) {
Write-Error "Fehler beim Abrufen des Project Boards: $raw"
# Alte status:*-Labels entfernen exit 1
$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
# Neues Status-Label-ID ermitteln if (-not $item) {
$allLabels = (Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/labels?limit=50" -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json Write-Error "Issue #$IssueNumber nicht im Project Board gefunden. Bitte zuerst mit 'gh project item-add' hinzufügen."
$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
} }
# Neues Status-Label setzen $itemId = $item.id
$body = "{`"labels`":[" + $targetLabel.id + "]}"
Invoke-WebRequest -Uri "$forgejoBase/repos/$repo/issues/$IssueNumber/labels" -Method Post -Headers $headers -Body $body -UseBasicParsing | Out-Null
Write-Host "Issue #$IssueNumber$normalizedStatus" $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"
}
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 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'." 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'."
--- ---
# 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 zum Push nach Forgejo (`git.bollwerk.online`). Dieser Skill kapselt Tools und Konventionen für den Ship-Workflow den Weg vom lokalen Commit bis zur grünen CI-Pipeline.
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 Forgejo Actions Run bis zum Abschluss. Gibt Status, Dauer und Warnungen aus. Beobachtet den neuesten GitHub 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. Forgejo Actions Pipeline ergänzen. - **Kein CI/CD:** Deployment ist manuell (JAR bauen → scp → docker compose up). Ggf. GitHub 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,6 +11,7 @@ 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
@ -82,10 +83,8 @@ 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)
}, },
@ -98,23 +97,3 @@ 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,13 +5,10 @@ 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
@ -32,30 +29,8 @@ 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() {
@ -74,9 +49,4 @@ 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,14 +25,9 @@ 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 { user -> .map { UserListItemDto(id = it.id, username = it.username) }
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,22 +13,17 @@ 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.computeIfAbsent(userId) { CopyOnWriteArraySet() }.add(session) sessions.getOrPut(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,6 +5,5 @@ 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
) )