chore: add Forgejo PR workflow

This commit is contained in:
Christopher Mühl 2026-02-26 17:10:07 +01:00
parent 1ffdb937ce
commit cbf70665f8
9 changed files with 498 additions and 0 deletions

54
.claude/hooks/log-request.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Claude Code — UserPromptSubmit hook
#
# Logs each new user prompt to .claude/request-log.jsonl (git-tracked).
# Runs async so it never blocks Claude's response.
#
# Input (stdin): JSON with fields: session_id, cwd, prompt, is_continuation
set -euo pipefail
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
CWD=$(echo "$INPUT" | jq -r '.cwd // "$PWD"')
IS_CONTINUATION=$(echo "$INPUT" | jq -r '.is_continuation // false')
LOG_FILE="${CWD}/.claude/request-log.jsonl"
# Skip: continuation turns, trivially short prompts, no log file dir
if [ "$IS_CONTINUATION" = "true" ]; then
exit 0
fi
if [ "${#PROMPT}" -lt 5 ]; then
exit 0
fi
if [ ! -d "${CWD}/.claude" ]; then
exit 0
fi
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
REQ_ID="req_$(date +%Y%m%d_%H%M%S)_$(LC_ALL=C tr -dc 'a-z0-9' < /dev/urandom | head -c 5)"
BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
jq -n \
--arg id "$REQ_ID" \
--arg ts "$TIMESTAMP" \
--arg prompt "$PROMPT" \
--arg session "$SESSION_ID" \
--arg branch "$BRANCH" \
'{
id: $id,
timestamp: $ts,
prompt: $prompt,
session: $session,
branch: $branch,
pr_number: null,
pr_url: null,
status: "pending"
}' >> "$LOG_FILE"
exit 0

39
.claude/hooks/session-start.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Claude Code — SessionStart hook
#
# Injects recent request history into Claude's context at session start.
# stdout is appended to Claude's context window.
#
# Input (stdin): JSON with fields: session_id, cwd, session_start_source
set -euo pipefail
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd // "$PWD"')
LOG_FILE="${CWD}/.claude/request-log.jsonl"
if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then
exit 0
fi
TOTAL=$(wc -l < "$LOG_FILE" | tr -d ' ')
OPEN=$(jq -r 'select(.status == "open")' "$LOG_FILE" 2>/dev/null | jq -s 'length')
echo "=== Forgejo Workflow: Session Context ==="
echo "Request log: .claude/request-log.jsonl ($TOTAL total, $OPEN open PRs)"
echo ""
# Last 5 entries
echo "Recent requests:"
tail -5 "$LOG_FILE" | jq -r '
" [\(.timestamp[0:16])] \(.prompt | gsub("\n"; " ") | .[0:72])" +
"\n branch: \(.branch)" +
(if .pr_number then " | PR #\(.pr_number) (\(.status))" else " | no PR yet" end)
' 2>/dev/null || true
echo ""
echo "Run '.claude/scripts/show-history.sh' for full history."
echo "========================================="
exit 0

View file

100
.claude/scripts/create-pr.sh Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Create a PR on Forgejo via tea and record it in the request log.
#
# Usage:
# .claude/scripts/create-pr.sh <title> [body] [base]
#
# Environment:
# FORGEJO_BASE_BRANCH — override default base branch (default: main)
set -euo pipefail
TITLE="${1:-}"
BODY="${2:-}"
BASE="${3:-${FORGEJO_BASE_BRANCH:-main}}"
LOG_FILE=".claude/request-log.jsonl"
if [ -z "$TITLE" ]; then
echo "Usage: create-pr.sh <title> [body] [base-branch]" >&2
exit 1
fi
if ! command -v tea &>/dev/null; then
cat >&2 <<'MSG'
Error: 'tea' CLI not found.
Install options:
nix develop path:~/.claude/workflows/forgejo # recommended
brew install gitea/tap/tea # macOS
go install gitea.com/gitea/tea@latest # Go
Then configure: tea login add
MSG
exit 1
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "$BASE" ]; then
echo "Error: currently on base branch '$BASE'. Create a feature branch first." >&2
exit 1
fi
echo "Creating PR: '$TITLE'"
echo " head: $CURRENT_BRANCH → base: $BASE"
echo ""
# Build tea args
TEA_ARGS=(pr create
--title "$TITLE"
--head "$CURRENT_BRANCH"
--base "$BASE"
)
if [ -n "$BODY" ]; then
TEA_ARGS+=(--description "$BODY")
fi
PR_OUTPUT=$(tea "${TEA_ARGS[@]}" 2>&1)
echo "$PR_OUTPUT"
# Extract URL — tea prints something like: "Created pull request #42"
# and/or a URL line
PR_URL=$(echo "$PR_OUTPUT" | grep -oE 'https?://[^[:space:]]+/pulls/[0-9]+' | head -1 || true)
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$' || true)
if [ -z "$PR_NUMBER" ]; then
# Fallback: parse "Created pull request #N"
PR_NUMBER=$(echo "$PR_OUTPUT" | grep -oE '#[0-9]+' | grep -oE '[0-9]+' | head -1 || true)
fi
# Update most recent pending entry in the log
if [ -n "$PR_NUMBER" ] && [ -f "$LOG_FILE" ] && [ -s "$LOG_FILE" ]; then
TMPFILE=$(mktemp)
# Find and update the last entry that matches current branch & is pending
awk -v branch="$CURRENT_BRANCH" -v pr_num="$PR_NUMBER" -v pr_url="$PR_URL" '
BEGIN { updated = 0 }
{
lines[NR] = $0
}
END {
# Walk backwards to find last matching entry
for (i = NR; i >= 1; i--) {
if (!updated && lines[i] ~ ("\"branch\":\"" branch "\"") && lines[i] ~ "\"status\":\"pending\"") {
# Use a simple substitution — jq would be cleaner but we avoid a subshell per line
cmd = "echo " lines[i] " | jq --arg pn \"" pr_num "\" --arg pu \"" pr_url "\" \".pr_number = ($pn | tonumber) | .pr_url = $pu | .status = \\\"open\\\"\""
cmd | getline updated_line
close(cmd)
lines[i] = updated_line
updated = 1
}
}
for (i = 1; i <= NR; i++) print lines[i]
}
' "$LOG_FILE" > "$TMPFILE"
mv "$TMPFILE" "$LOG_FILE"
echo ""
echo "Request log updated with PR #$PR_NUMBER"
fi

84
.claude/scripts/show-history.sh Executable file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Display the Claude request history for this project.
#
# Usage:
# .claude/scripts/show-history.sh [--filter open|merged|pending] [--last N] [--json]
set -euo pipefail
LOG_FILE=".claude/request-log.jsonl"
FILTER=""
LAST=0
JSON_MODE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--filter) FILTER="$2"; shift 2 ;;
--last) LAST="$2"; shift 2 ;;
--json) JSON_MODE=true; shift ;;
-*) echo "Unknown flag: $1" >&2; exit 1 ;;
*) LOG_FILE="$1"; shift ;;
esac
done
if [ ! -f "$LOG_FILE" ]; then
echo "No request log found at $LOG_FILE"
exit 0
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq not found. Run: nix develop path:~/.claude/workflows/forgejo" >&2
exit 1
fi
TOTAL=$(wc -l < "$LOG_FILE" | tr -d ' ')
if [ "$TOTAL" -eq 0 ]; then
echo "No requests logged yet."
exit 0
fi
# Build jq filter
JQ_SELECT='.'
if [ -n "$FILTER" ]; then
JQ_SELECT="select(.status == \"$FILTER\")"
fi
CONTENT=$(jq -r "$JQ_SELECT" "$LOG_FILE" 2>/dev/null | jq -s '.')
if [ "$LAST" -gt 0 ]; then
CONTENT=$(echo "$CONTENT" | jq ".[-${LAST}:]")
fi
if [ "$JSON_MODE" = true ]; then
echo "$CONTENT"
exit 0
fi
COUNT=$(echo "$CONTENT" | jq 'length')
echo "━━━ Request History ($LOG_FILE) ━━━"
echo "Total: $TOTAL entries | Showing: $COUNT${FILTER:+ ($FILTER)}"
echo ""
echo "$CONTENT" | jq -r '.[] |
"[\(.timestamp[0:16])] \(.id)",
" Prompt: \(.prompt | gsub("\n";" ") | .[0:80])\(if (.prompt | length) > 80 then "" else "" end)",
" Branch: \(.branch)",
(if .pr_number then
" PR: #\(.pr_number) [\(.status)] \(.pr_url // "")"
else
" PR: none yet (status: \(.status))"
end),
""
'
# Summary stats
OPEN=$(jq -r 'select(.status == "open")' "$LOG_FILE" | jq -s 'length')
MERGED=$(jq -r 'select(.status == "merged")' "$LOG_FILE" | jq -s 'length')
PENDING=$(jq -r 'select(.status == "pending")' "$LOG_FILE" | jq -s 'length')
echo "━━━ Summary ━━━"
echo " Open PRs: $OPEN"
echo " Merged: $MERGED"
echo " No PR yet: $PENDING"

66
.claude/scripts/update-entry.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Update a specific log entry (e.g. mark a PR as merged).
#
# Usage:
# .claude/scripts/update-entry.sh <req-id> --status merged
# .claude/scripts/update-entry.sh <req-id> --pr-number 42 --pr-url https://...
set -euo pipefail
REQ_ID="${1:-}"
LOG_FILE=".claude/request-log.jsonl"
NEW_STATUS=""
NEW_PR_NUMBER=""
NEW_PR_URL=""
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--status) NEW_STATUS="$2"; shift 2 ;;
--pr-number) NEW_PR_NUMBER="$2"; shift 2 ;;
--pr-url) NEW_PR_URL="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
if [ -z "$REQ_ID" ]; then
echo "Usage: update-entry.sh <req-id> [--status STATUS] [--pr-number N] [--pr-url URL]" >&2
exit 1
fi
if [ ! -f "$LOG_FILE" ]; then
echo "Log file not found: $LOG_FILE" >&2
exit 1
fi
TMPFILE=$(mktemp)
JQ_ARGS=()
JQ_FILTER='.'
if [ -n "$NEW_STATUS" ]; then
JQ_ARGS+=(--arg status "$NEW_STATUS")
JQ_FILTER="$JQ_FILTER | .status = \$status"
fi
if [ -n "$NEW_PR_NUMBER" ]; then
JQ_ARGS+=(--argjson pr_num "$NEW_PR_NUMBER")
JQ_FILTER="$JQ_FILTER | .pr_number = \$pr_num"
fi
if [ -n "$NEW_PR_URL" ]; then
JQ_ARGS+=(--arg pr_url "$NEW_PR_URL")
JQ_FILTER="$JQ_FILTER | .pr_url = \$pr_url"
fi
while IFS= read -r line; do
ID=$(echo "$line" | jq -r '.id // ""')
if [ "$ID" = "$REQ_ID" ]; then
echo "$line" | jq "${JQ_ARGS[@]}" "$JQ_FILTER"
else
echo "$line"
fi
done < "$LOG_FILE" > "$TMPFILE"
mv "$TMPFILE" "$LOG_FILE"
echo "Updated entry $REQ_ID"

29
.claude/settings.json Normal file
View file

@ -0,0 +1,29 @@
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-request.sh",
"timeout": 10,
"async": true
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh",
"timeout": 10
}
]
}
]
}
}

6
.gitignore vendored
View file

@ -9,3 +9,9 @@ ios/
*.local *.local
.DS_Store .DS_Store
.claude/worktrees/ .claude/worktrees/
e2e/test-results/
e2e/report/
e2e/.features-gen/
# Claude Code local settings (secrets, personal config)
.claude/settings.local.json

120
CLAUDE.md
View file

@ -798,3 +798,123 @@ function getItemsInContainer(containerId: string): Item[] {
return allItems.filter(i => i.storageContainerId === containerId); return allItems.filter(i => i.storageContainerId === containerId);
} }
``` ```
---
## Forgejo PR Workflow
Every substantive session is tracked in `.claude/request-log.jsonl` and backed by a Forgejo PR.
### When to create a branch + PR
Create a branch and PR for:
- New features (`feat/`)
- Bug fixes (`fix/`)
- Refactors (`refactor/`)
- Dependency or config changes (`chore/`)
Skip branching/PR for:
- Questions, explanations, code reading
- Trivial one-liner fixes that are best committed directly to the current branch
### Branch naming
```
feat/YYYYMMDD-short-kebab-description
fix/YYYYMMDD-short-kebab-description
chore/YYYYMMDD-short-kebab-description
refactor/YYYYMMDD-short-kebab-description
```
Check the current branch first:
```bash
git rev-parse --abbrev-ref HEAD
```
If already on a feature branch from this session, continue on it. Only create a new branch for a new distinct task.
### Workflow for each task
1. **Create branch** (if needed):
```bash
git checkout -b feat/$(date +%Y%m%d)-short-description
```
2. **Do the work** — write code, run tests, iterate.
3. **Commit** with conventional commit messages:
```bash
git add <files>
git commit -m "feat: short description"
```
4. **Push**:
```bash
git push -u origin HEAD
```
5. **Create PR**:
```bash
bash .claude/scripts/create-pr.sh \
"feat: short description" \
"$(cat <<'EOF'
## Summary
- What was done and why
## Test plan
- [ ] Manual test steps
🤖 Generated with Claude Code
EOF
)"
```
This also updates `.claude/request-log.jsonl` with the PR number.
6. **Commit the updated request log** (as part of the PR or separately):
```bash
git add .claude/request-log.jsonl
git commit -m "chore: log request #<req-id>"
```
### Show history
When the user asks for history, recent work, or what's been done:
```bash
bash .claude/scripts/show-history.sh
bash .claude/scripts/show-history.sh --filter open
bash .claude/scripts/show-history.sh --last 10
```
### tea CLI quick reference
```bash
tea pr list # list open PRs
tea pr view <number> # view a PR
tea pr merge <number> # merge a PR
tea issue create # create an issue
tea repo info # show repo details
tea login list # show configured Forgejo instances
```
### Request log format
`.claude/request-log.jsonl` — one JSON object per line, git-tracked:
```json
{
"id": "req_20260226_143012_a3f9b",
"timestamp": "2026-02-26T14:30:12Z",
"prompt": "Add dark mode support",
"session": "abc123",
"branch": "feat/20260226-dark-mode",
"pr_number": 42,
"pr_url": "https://forge.example.com/user/repo/pulls/42",
"status": "open"
}
```
Possible statuses: `pending` (no PR yet), `open`, `merged`, `closed`.