Compare commits
2 commits
c16c9fff97
...
2af82d60d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2af82d60d0 | ||
|
|
00b28d2f58 |
11 changed files with 227 additions and 208 deletions
95
.github/skills/gh-tickets/SKILL.md
vendored
95
.github/skills/gh-tickets/SKILL.md
vendored
|
|
@ -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
|
||||||
|
|
||||||
| Eigenschaft | Wert |
|
| Label | Bedeutung |
|
||||||
| ------------ | ---------------------------------------------------- |
|
| ------------------- | ------------------------------------------------- |
|
||||||
| Projekt-Name | `Bollwerk` |
|
| `status:todo` | Nächste offene Aufgabe (wird von next-ticket.ps1 gefunden) |
|
||||||
| Projekt-Nr | `2` |
|
| `status:in-progress`| Aktuell in Bearbeitung |
|
||||||
| Owner | `jreinemann-euris` |
|
| `status:done` | Abgeschlossen |
|
||||||
| Sortierfeld | `Order` (Number-Feld) |
|
| `status:backlog` | Im Backlog – wird erst nach todo-Items bearbeitet |
|
||||||
| URL | https://github.com/users/jreinemann-euris/projects/2 |
|
|
||||||
|
|
||||||
### Order-Feld Konventionen
|
### Repository
|
||||||
|
|
||||||
- **Kleinere Zahl = höhere Priorität** (wird zuerst abgearbeitet)
|
| Eigenschaft | Wert |
|
||||||
- **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)
|
| URL | https://git.bollwerk.online/bollwerkadmin/bollwerk |
|
||||||
- Ein dringend vorgezogenes Ticket bekommt einen Wert **zwischen** den bestehenden (z.B. 15 zwischen 10 und 20, oder 5 vor 10)
|
| API-Base | https://git.bollwerk.online/api/v1 |
|
||||||
- Wenn keine bestimmte Position vorgegeben ist, erhält das Ticket den **höchsten bestehenden Order-Wert + 10** (= wird am Ende eingefügt)
|
| Token | `.github/skills/gh-tickets/forgejo-token.txt` (lokal, nicht committed) |
|
||||||
- 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.
|
||||||
|
|
|
||||||
111
.github/skills/gh-tickets/create-next-ticket.ps1
vendored
111
.github/skills/gh-tickets/create-next-ticket.ps1
vendored
|
|
@ -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)"
|
|
||||||
|
|
|
||||||
80
.github/skills/gh-tickets/next-ticket.ps1
vendored
80
.github/skills/gh-tickets/next-ticket.ps1
vendored
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
.github/skills/gh-tickets/set-board-status.ps1
vendored
71
.github/skills/gh-tickets/set-board-status.ps1
vendored
|
|
@ -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
|
Write-Host "Issue #$IssueNumber → $normalizedStatus"
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
Write-Host "Issue #$IssueNumber → $normalizedStatus"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Error "Fehler beim Setzen des Status für Issue #${IssueNumber}: $editOutput"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
|
||||||
6
.github/skills/ship/SKILL.md
vendored
6
.github/skills/ship/SKILL.md
vendored
|
|
@ -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:**
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/skills/vps-deploy/SKILL.md
vendored
2
.github/skills/vps-deploy/SKILL.md
vendored
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue