Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68f854aa2 | ||
|
|
18a911cd4e | ||
|
|
9c8728bd5b | ||
|
|
8db07e5d21 | ||
|
|
38a9a11772 | ||
|
|
042d83f6d6 | ||
|
|
6d063caada | ||
|
|
043efc0dd3 | ||
|
|
429e3d9785 | ||
|
|
ad6ba2199a | ||
|
|
59e501cda8 | ||
|
|
18f6e2732e | ||
|
|
6ae0c91539 |
@@ -0,0 +1,24 @@
|
|||||||
|
# Agent Rules
|
||||||
|
|
||||||
|
## Model Selection Advisor
|
||||||
|
|
||||||
|
Whenever you receive a new user request or begin a new task, you MUST invoke the `model_advisor` skill to evaluate the optimal model and tool strategy before proceeding with any significant planning or code modifications.
|
||||||
|
## OKF Knowledge Base Access
|
||||||
|
|
||||||
|
Whenever the user asks you to search, query, access, or update their "knowledge", "OKF", or "Wissensdatenbank", you MUST use the custom `ctx-guard` CLI tool.
|
||||||
|
The tool is executed from the Orchestrator's virtual environment: `/home/andre/coden/Orchestrator/.venv/bin/ctx-guard knowledge`.
|
||||||
|
|
||||||
|
**Available commands:**
|
||||||
|
- `search`: e.g., `/home/andre/coden/Orchestrator/.venv/bin/ctx-guard knowledge search "query" --limit 5`
|
||||||
|
- `capture`: e.g., `/home/andre/coden/Orchestrator/.venv/bin/ctx-guard knowledge capture "Note" --context privat`
|
||||||
|
- `status`: e.g., `/home/andre/coden/Orchestrator/.venv/bin/ctx-guard knowledge status`
|
||||||
|
- `ingest`: e.g., `/home/andre/coden/Orchestrator/.venv/bin/ctx-guard knowledge ingest`
|
||||||
|
|
||||||
|
Always run this command directly using your `run_command` tool to retrieve or update knowledge before answering the user's queries.
|
||||||
|
|
||||||
|
## PR Workflow Rule
|
||||||
|
|
||||||
|
All code changes or configurations made by agents MUST go through a Pull Request / Merge Request.
|
||||||
|
- You are not allowed to do quicksaves directly to the main branch.
|
||||||
|
- You must create a new branch, commit your changes, push to the remote, and open a PR.
|
||||||
|
- After creating the PR, you must review it for viability and only merge it once confirmed.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"last_updated": "2026-07-16T20:12:30Z",
|
||||||
|
"providers": {
|
||||||
|
"codex": {
|
||||||
|
"models": []
|
||||||
|
},
|
||||||
|
"antigravity": {
|
||||||
|
"models": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# check_quotas.sh - Modular script to check quotas
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
check_codex_quota() {
|
||||||
|
if command -v codex &> /dev/null; then
|
||||||
|
echo "[Codex Quota]"
|
||||||
|
codex quota || echo "Failed to fetch Codex quota"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_agy_quota() {
|
||||||
|
if command -v agy &> /dev/null; then
|
||||||
|
echo "[Antigravity Quota]"
|
||||||
|
agy quota || echo "Failed to fetch Antigravity quota"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "--- Current Quotas ---"
|
||||||
|
check_codex_quota
|
||||||
|
check_agy_quota
|
||||||
|
echo "----------------------"
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install_clis.sh - Install configured CLIs
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting CLI installations..."
|
||||||
|
|
||||||
|
echo "Checking codex..."
|
||||||
|
if command -v codex &> /dev/null; then
|
||||||
|
echo "codex is already installed. Version: $(codex --version 2>/dev/null || echo 'unknown')"
|
||||||
|
else
|
||||||
|
echo "Installing codex via curl..."
|
||||||
|
curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||||
|
echo "codex installed successfully."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking agy (antigravity)..."
|
||||||
|
if command -v agy &> /dev/null; then
|
||||||
|
echo "agy is already installed."
|
||||||
|
else
|
||||||
|
echo "Warning: agy is not installed. Please install it manually."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "All CLIs are set up."
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# setup_apis.sh - Trigger browser login flows
|
||||||
|
set -e
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
CLIS=("codex" "agy")
|
||||||
|
|
||||||
|
echo "Starting API Configuration via Browser Login..."
|
||||||
|
|
||||||
|
for cli in "${CLIS[@]}"; do
|
||||||
|
echo "Configuring $cli..."
|
||||||
|
if command -v "$cli" &> /dev/null; then
|
||||||
|
if [ "$cli" == "agy" ]; then
|
||||||
|
"$cli" auth
|
||||||
|
else
|
||||||
|
"$cli" login
|
||||||
|
fi
|
||||||
|
echo "$cli configured."
|
||||||
|
else
|
||||||
|
echo "Warning: $cli not installed. Please run install_clis.sh first."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "API setup complete."
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# update_models_config.sh - Fetches latest models and quotas
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG_FILE="$DIR/../models_config.json"
|
||||||
|
|
||||||
|
echo "{" > "$CONFIG_FILE"
|
||||||
|
echo " \"last_updated\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# Logic to update config
|
||||||
|
echo " \"providers\": {" >> "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# Codex Models
|
||||||
|
echo " \"codex\": {" >> "$CONFIG_FILE"
|
||||||
|
if command -v codex &> /dev/null; then
|
||||||
|
echo " \"models\": $(codex models --json 2>/dev/null || echo '[]')" >> "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
echo " \"models\": []" >> "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
echo " }," >> "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# Antigravity Models
|
||||||
|
echo " \"antigravity\": {" >> "$CONFIG_FILE"
|
||||||
|
if command -v agy &> /dev/null; then
|
||||||
|
echo " \"models\": $(agy models --json 2>/dev/null || echo '[]')" >> "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
echo " \"models\": []" >> "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
echo " }" >> "$CONFIG_FILE"
|
||||||
|
|
||||||
|
echo " }" >> "$CONFIG_FILE"
|
||||||
|
echo "}" >> "$CONFIG_FILE"
|
||||||
|
|
||||||
|
echo "Models config updated at $CONFIG_FILE"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: model_advisor
|
||||||
|
description: Evaluates a task and orchestrates execution across available CLI models based on cost, quotas, and strengths.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
|
||||||
|
You have been invoked to act as a central orchestrator. You must evaluate the task, check available models and quotas, and delegate the work via CLI.
|
||||||
|
|
||||||
|
## Step 1: Check Quotas & Models
|
||||||
|
Run the following scripts from your workspace:
|
||||||
|
- `.agents/scripts/check_quotas.sh`
|
||||||
|
- Review `.agents/models_config.json` to see currently available models.
|
||||||
|
|
||||||
|
## Step 2: Evaluate the Task
|
||||||
|
Consider the following criteria:
|
||||||
|
1. **Complexity & Context**: Does it require massive context (Gemini Pro) or deep nuance (Claude Sonnet)?
|
||||||
|
2. **Cost & Quota**: Are quotas low? Use cheaper models if the task is simple.
|
||||||
|
|
||||||
|
## Step 3: Output a Recommendation & Delegate
|
||||||
|
If the task is best suited for a CLI-driven model (e.g., Codex or Antigravity with a specific model), you MUST proactively write and execute the terminal command to delegate the task.
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
> [!TIP]
|
||||||
|
> **Model Advisor Recommendation**: The quota for Antigravity is healthy. I am delegating this refactoring task to Claude 3.5 Sonnet via the CLI.
|
||||||
|
|
||||||
|
Example command you might run:
|
||||||
|
```bash
|
||||||
|
agy run --model claude-3-5-sonnet "refactor the payment gateway"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the task is best suited for the *current* IDE model, advise the user to proceed normally without CLI delegation. If it's suited for an IDE model you are *not* currently using, advise the user to switch models manually in the IDE.
|
||||||
|
|
||||||
|
## Step 4: Proceed
|
||||||
|
Once the CLI command completes, or if you decided to handle it locally, complete the user's request.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: Deploy andreknie.de
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- feat/federation-setup
|
||||||
|
paths:
|
||||||
|
- ".gitea/workflows/deploy-andreknie.yml"
|
||||||
|
- "privat/CV/andreknie.de/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20-bookworm
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: "217.160.174.2"
|
||||||
|
DEPLOY_USER: "root"
|
||||||
|
DEPLOY_PATH: "/opt/andreknie"
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install deploy tools
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y openssh-client rsync
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
cd privat/CV/andreknie.de
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
install -d -m 700 ~/.ssh
|
||||||
|
printf '%s\n' "$VPS_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" > ~/.ssh/known_hosts
|
||||||
|
chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Write production environment
|
||||||
|
working-directory: privat/CV/andreknie.de
|
||||||
|
env:
|
||||||
|
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||||
|
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||||
|
SMTP_PASS: ${{ secrets.SMTP_PASS }}
|
||||||
|
run: |
|
||||||
|
cat > .env <<EOF
|
||||||
|
PORT=3003
|
||||||
|
NODE_ENV=production
|
||||||
|
BASE_URL=https://andreknie.de
|
||||||
|
SMTP_HOST=$SMTP_HOST
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=$SMTP_USER
|
||||||
|
SMTP_PASS=$SMTP_PASS
|
||||||
|
STAKEHOLDER_EMAIL=kontakt@d-hive.de
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Deploy to VPS
|
||||||
|
working-directory: privat/CV/andreknie.de
|
||||||
|
run: |
|
||||||
|
SSH_OPTS="-i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
|
||||||
|
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" "install -d -m 755 ${DEPLOY_PATH}/site ${DEPLOY_PATH}/server ${DEPLOY_PATH}/server/data"
|
||||||
|
rsync -az --delete -e "ssh $SSH_OPTS" dist/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/site/"
|
||||||
|
rsync -az --delete -e "ssh $SSH_OPTS" server/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/server/"
|
||||||
|
rsync -az -e "ssh $SSH_OPTS" package.json package-lock.json Dockerfile docker-compose.yml Caddyfile .env "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
|
||||||
|
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" "cd ${DEPLOY_PATH} && docker compose up -d --build"
|
||||||
|
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" "if command -v caddy >/dev/null 2>&1; then caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy; fi"
|
||||||
@@ -106,3 +106,6 @@ All filenames must be OneDrive-safe:
|
|||||||
- No single quotes or backticks
|
- No single quotes or backticks
|
||||||
- Replace umlauts in filenames: ä→ae, ö→oe, ü→ue, ß→ss
|
- Replace umlauts in filenames: ä→ae, ö→oe, ü→ue, ß→ss
|
||||||
- Use hyphens instead of special characters
|
- Use hyphens instead of special characters
|
||||||
|
|
||||||
|
## Agent Restrictions
|
||||||
|
- ONLY the human user is allowed to use `sudo`. Agents MUST NOT attempt to use `sudo` or request `sudo` access.
|
||||||
|
|||||||
@@ -28,11 +28,28 @@ On this machine, PowerShell is the shell. Use `;` to chain commands, NOT `&&` (w
|
|||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
- **Bahn repos stay untouched** as independent remotes. The monorepo organizes them but doesn't own them.
|
- **Bahn repos stay untouched** as independent remotes. The monorepo organizes them but doesn't own them.
|
||||||
|
- **External repos are READ-ONLY in the monorepo.** Never modify code, add tests, or create MRs for: aisupport, Confluence_Bot, O2C-Harness, db-planet-mcp-server, wissensdatenbank. Only COMPONENT.md (monorepo wrapper metadata) is allowed.
|
||||||
- **Jury-Voting** is dhive (not privat). Will be transferred to dhive GitLab later.
|
- **Jury-Voting** is dhive (not privat). Will be transferred to dhive GitLab later.
|
||||||
- **NoteGraph** needs a complete redo — superseded by the monorepo knowledge store.
|
- **NoteGraph** needs a complete redo — superseded by the monorepo knowledge store.
|
||||||
- **symphony** is a read-only reference (pinned).
|
- **symphony** is a read-only reference (pinned).
|
||||||
- Secrets go in `.secrets` (gitignored) or encrypted via git-crypt per context.
|
- Secrets go in `.secrets` (gitignored) or encrypted via git-crypt per context.
|
||||||
|
|
||||||
|
## Ownership Rules
|
||||||
|
|
||||||
|
| Repo | Owner | Editable? |
|
||||||
|
|------|-------|-----------|
|
||||||
|
| bahn/aisupport | DB GitLab (Team) | ❌ read-only |
|
||||||
|
| bahn/Analyse-O2C-C2S | Andre (DB GitLab) | ✅ |
|
||||||
|
| bahn/awesome-bahn-mcp-servers | monorepo-internal | ✅ |
|
||||||
|
| bahn/Confluence_Bot | DB GitLab (Team) | ❌ read-only |
|
||||||
|
| bahn/db-planet-mcp-server | DB GitLab (Team) | ❌ read-only |
|
||||||
|
| bahn/O2C-Harness | DB GitLab (Team) | ❌ read-only |
|
||||||
|
| bahn/project-audit | Andre (DB GitLab) | ✅ |
|
||||||
|
| bahn/wissensdatenbank | DB GitLab (Team) | ❌ read-only (subtree) |
|
||||||
|
| dhive/* | Andre (GitHub) | ✅ |
|
||||||
|
| privat/* | Andre (GitHub) | ✅ |
|
||||||
|
| shared/* | Andre (GitHub/internal) | ✅ |
|
||||||
|
|
||||||
## Python Environment
|
## Python Environment
|
||||||
|
|
||||||
The monorepo CLI tool is at `shared/tools/monorepo-cli/`. Tests run with:
|
The monorepo CLI tool is at `shared/tools/monorepo-cli/`. Tests run with:
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# New Device Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to get your full Monorepo, AI Orchestrator, CLI agents, and Knowledge Base (`ctx-guard`) up and running on a brand new device.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Ensure the following tools are installed on your new device:
|
||||||
|
- `git`
|
||||||
|
- `python3.12` or newer
|
||||||
|
- `pipx`
|
||||||
|
- `curl`
|
||||||
|
|
||||||
|
## Step 1: Clone the Monorepo
|
||||||
|
|
||||||
|
Clone the Orchestrator to your preferred workspace directory (e.g., `~/coden`):
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/coden
|
||||||
|
cd ~/coden
|
||||||
|
git clone https://git.d-hive.de/ankn/Orchestrator.git
|
||||||
|
cd Orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Set up the Knowledge CLI (`ctx-guard`)
|
||||||
|
|
||||||
|
The `ctx-guard` tool requires a Python virtual environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/coden/Orchestrator
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install the monorepo-cli package in editable mode
|
||||||
|
cd shared/tools/monorepo-cli
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
To register the new machine to your federation topology, run the built-in onboard command:
|
||||||
|
```bash
|
||||||
|
# Replace <machine_name> with your device name
|
||||||
|
ctx-guard onboard <machine_name> "privat,dhive,bahn,shared"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Install the AI CLIs
|
||||||
|
|
||||||
|
Install the Codex CLI:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the Antigravity CLI:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `codex login` and `agy login` in your terminal to authenticate via the browser.
|
||||||
|
|
||||||
|
## Step 4: AI Agent Customizations
|
||||||
|
|
||||||
|
Because the `.agents` folder is now version-controlled inside the Orchestrator monorepo (`~/coden/Orchestrator/.agents`), your AI agents will *automatically* discover your custom skills (like `model_advisor`) and rules (like strictly using `ctx-guard` for knowledge tasks).
|
||||||
|
|
||||||
|
To ensure your models catalog is kept up to date, you can manually run the updater script:
|
||||||
|
```bash
|
||||||
|
cd ~/coden/Orchestrator
|
||||||
|
./.agents/scripts/update_models_config.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
*(Note: In your primary environment, an agent has scheduled this as a daily cron job. You can instruct the agent to set up the same schedule on the new device by typing `/schedule daily run .agents/scripts/update_models_config.sh` in the chat).*
|
||||||
|
|
||||||
|
## Step 5: Sync Federation Repositories
|
||||||
|
|
||||||
|
Finally, pull down the rest of your private and shared federation repositories from the `dhive` GitLab:
|
||||||
|
```bash
|
||||||
|
ctx-guard fed-sync --context dhive
|
||||||
|
```
|
||||||
|
|
||||||
|
Your environment is now fully identical to your original machine!
|
||||||
Binary file not shown.
Generated
+1141
-1
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
"start:server": "node server/index.js"
|
"start:server": "node server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@@ -29,6 +32,8 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"vite": "^8.0.4"
|
"jsdom": "^29.1.1",
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const TEST_DATA_DIR = join(__dirname, '..', 'data-test');
|
||||||
|
const TEST_DATA_FILE = join(TEST_DATA_DIR, 'access-requests.json');
|
||||||
|
|
||||||
|
function createTestApp() {
|
||||||
|
mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||||
|
writeFileSync(TEST_DATA_FILE, '[]', 'utf-8');
|
||||||
|
|
||||||
|
const sessions = new Map();
|
||||||
|
const ADMIN_USER = 'admin';
|
||||||
|
const ADMIN_PASSWORD = 'testpass';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser('test-secret'));
|
||||||
|
|
||||||
|
function createSession(user) {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
sessions.set(token, { user, createdAt: Date.now() });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(token) {
|
||||||
|
return sessions.get(token) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const token = req.cookies?.kiq_session;
|
||||||
|
if (!token) return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||||
|
const session = getSession(token);
|
||||||
|
if (!session) return res.status(401).json({ error: 'Sitzung abgelaufen' });
|
||||||
|
req.user = session.user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', time: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (username === ADMIN_USER && password === ADMIN_PASSWORD) {
|
||||||
|
const token = createSession({ username, role: 'admin' });
|
||||||
|
res.cookie('kiq_session', token, { httpOnly: true, sameSite: 'strict' });
|
||||||
|
return res.json({ user: { username, role: 'admin' } });
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
const token = req.cookies?.kiq_session;
|
||||||
|
if (token) sessions.delete(token);
|
||||||
|
res.clearCookie('kiq_session');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/me', (req, res) => {
|
||||||
|
const token = req.cookies?.kiq_session;
|
||||||
|
if (!token) return res.json({ user: null });
|
||||||
|
const session = getSession(token);
|
||||||
|
if (!session) return res.json({ user: null });
|
||||||
|
res.json({ user: session.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/access-requests', (req, res) => {
|
||||||
|
const { user_data } = req.body;
|
||||||
|
if (!user_data || !user_data.name || !user_data.email || !user_data.company) {
|
||||||
|
return res.status(400).json({ error: 'Pflichtfelder fehlen' });
|
||||||
|
}
|
||||||
|
const requests = JSON.parse(readFileSync(TEST_DATA_FILE, 'utf-8'));
|
||||||
|
const newRequest = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
user_data,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
requests.push(newRequest);
|
||||||
|
writeFileSync(TEST_DATA_FILE, JSON.stringify(requests, null, 2), 'utf-8');
|
||||||
|
res.status(201).json({ ok: true, id: newRequest.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/access-requests', requireAuth, (_req, res) => {
|
||||||
|
const requests = JSON.parse(readFileSync(TEST_DATA_FILE, 'utf-8'));
|
||||||
|
requests.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
res.json(requests);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/access-requests/:id', requireAuth, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!['pending', 'approved', 'rejected'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Ungültiger Status' });
|
||||||
|
}
|
||||||
|
const requests = JSON.parse(readFileSync(TEST_DATA_FILE, 'utf-8'));
|
||||||
|
const idx = requests.findIndex(r => r.id === id);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'Anfrage nicht gefunden' });
|
||||||
|
requests[idx].status = status;
|
||||||
|
writeFileSync(TEST_DATA_FILE, JSON.stringify(requests, null, 2), 'utf-8');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test Setup ---
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const app = createTestApp();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server = app.listen(0, () => {
|
||||||
|
const addr = server.address();
|
||||||
|
baseUrl = `http://localhost:${addr.port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (server) server.close();
|
||||||
|
if (existsSync(TEST_DATA_DIR)) rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
writeFileSync(TEST_DATA_FILE, '[]', 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe('KIQ Server API', () => {
|
||||||
|
describe('GET /api/health', () => {
|
||||||
|
it('returns ok status', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/health`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.status).toBe('ok');
|
||||||
|
expect(data.time).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/login', () => {
|
||||||
|
it('accepts valid credentials', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'testpass' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.user.username).toBe('admin');
|
||||||
|
expect(data.user.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid credentials', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'wrong' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets session cookie on success', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'testpass' }),
|
||||||
|
});
|
||||||
|
const setCookie = res.headers.get('set-cookie');
|
||||||
|
expect(setCookie).toContain('kiq_session');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/me', () => {
|
||||||
|
it('returns null user without session', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/me`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/access-requests', () => {
|
||||||
|
it('creates a new access request', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_data: {
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
email: 'max@example.com',
|
||||||
|
company: 'Test GmbH',
|
||||||
|
role: 'Developer',
|
||||||
|
purpose: 'Testing',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when name is missing', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_data: { email: 'x@x.com', company: 'X' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when email is missing', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_data: { name: 'Max', company: 'X' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when company is missing', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_data: { name: 'Max', email: 'x@x.com' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty body', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/access-requests (admin)', () => {
|
||||||
|
it('rejects without auth', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/access-requests/:id (admin)', () => {
|
||||||
|
it('rejects without auth', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/access-requests/fake-id`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'approved' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -5,12 +5,12 @@ Personal knowledge graph and CV/profile generation system (TypeScript/Node)
|
|||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
- **Deployment Target:** local-cli
|
- **Deployment Target:** local-cli
|
||||||
- **Upstream URL:** https://github.com/DoctoDre/CV
|
- **Upstream URL:** https://git.d-hive.de/ankn/Orchestrator.git
|
||||||
- **Status:** active
|
- **Status:** active
|
||||||
|
|
||||||
## Interconnections
|
## Interconnections
|
||||||
- (to be documented)
|
- (to be documented)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Part of privat context in the Orchestrator monorepo
|
- Part of privat context in the Orchestrator monorepo at `privat/CV`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DEPLOY_HOST: "217.160.174.2"
|
||||||
|
DEPLOY_USER: "root"
|
||||||
|
DEPLOY_PATH: "/opt/andreknie"
|
||||||
|
|
||||||
|
.ssh_setup: &ssh_setup
|
||||||
|
before_script:
|
||||||
|
- apt-get update -qq && apt-get install -y openssh-client rsync
|
||||||
|
- install -d -m 700 ~/.ssh
|
||||||
|
- echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
- chmod 600 ~/.ssh/deploy_key
|
||||||
|
- ssh-keyscan -H "${DEPLOY_HOST}" > ~/.ssh/known_hosts
|
||||||
|
- chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
stage: build
|
||||||
|
image: node:20
|
||||||
|
script:
|
||||||
|
- cd privat/CV/andreknie.de
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- privat/CV/andreknie.de/dist/
|
||||||
|
expire_in: 1 day
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
changes:
|
||||||
|
- "privat/CV/andreknie.de/**/*"
|
||||||
|
|
||||||
|
deploy-andreknie:
|
||||||
|
stage: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
<<: *ssh_setup
|
||||||
|
dependencies:
|
||||||
|
- build-frontend
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
changes:
|
||||||
|
- "privat/CV/andreknie.de/**/*"
|
||||||
|
script:
|
||||||
|
- ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "install -d -m 755 ${DEPLOY_PATH}/site ${DEPLOY_PATH}/server ${DEPLOY_PATH}/server/data"
|
||||||
|
- cd privat/CV/andreknie.de
|
||||||
|
- |
|
||||||
|
cat <<EOF > .env
|
||||||
|
PORT=3003
|
||||||
|
NODE_ENV=production
|
||||||
|
BASE_URL=https://andreknie.de
|
||||||
|
SMTP_HOST=$SMTP_HOST
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=$SMTP_USER
|
||||||
|
SMTP_PASS=$SMTP_PASS
|
||||||
|
STAKEHOLDER_EMAIL=kontakt@d-hive.de
|
||||||
|
EOF
|
||||||
|
- rsync -az --delete -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" dist/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/site/"
|
||||||
|
- rsync -az --delete -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" server/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/server/"
|
||||||
|
- rsync -az -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" package.json package-lock.json Dockerfile docker-compose.yml Caddyfile .env "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
|
||||||
|
- ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "cd ${DEPLOY_PATH} && docker compose up -d --build"
|
||||||
|
# Note: Caddy configuration needs to be reloaded manually if it's the first time.
|
||||||
|
- echo "Deployment finished. Remember to include ${DEPLOY_PATH}/Caddyfile in your global /etc/caddy/Caddyfile and reload caddy if you haven't yet!"
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
www.andreknie.de {
|
||||||
|
redir https://andreknie.de{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
andreknie.de {
|
andreknie.de {
|
||||||
root * /opt/andreknie/site
|
root * /opt/andreknie/site
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
@@ -8,6 +12,7 @@ andreknie.de {
|
|||||||
X-Frame-Options "DENY"
|
X-Frame-Options "DENY"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Permissions-Policy "camera=(), geolocation=(), microphone=()"
|
Permissions-Policy "camera=(), geolocation=(), microphone=()"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self' https://stats.andreknie.de; connect-src 'self' https://stats.andreknie.de; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
|
||||||
}
|
}
|
||||||
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Dr. André Knie — KI, Transformation & Leadership</title>
|
<title>Dr. André Knie — KI, Transformation & Leadership</title>
|
||||||
<meta name="description" content="Nüchterne KI-Expertise für Mittelstand, Verwaltung und Hochschulen. Souverän, praktisch, evidenzbasiert." />
|
<meta name="description" content="Nüchterne KI-Expertise für Mittelstand, Verwaltung und Hochschulen. Souverän, praktisch, evidenzbasiert." />
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://stats.andreknie.de/script.js"
|
||||||
|
data-website-id="8a8bc632-eb3d-433c-8724-10f8ebc771bc"
|
||||||
|
data-domains="andreknie.de,www.andreknie.de"
|
||||||
|
data-exclude-search="true"
|
||||||
|
data-exclude-hash="true"
|
||||||
|
data-do-not-track="true"
|
||||||
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ export default function Datenschutz() {
|
|||||||
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
|
||||||
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
|
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
|
||||||
|
|
||||||
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies</strong></p>
|
<p><strong style={{ color: 'var(--text-primary)' }}>10. Reichweitenmessung mit Umami</strong></p>
|
||||||
<p>Diese Webseite verwendet keine Tracking-Cookies und keine Analyse-Tools von Drittanbietern. Es werden ausschließlich technisch notwendige Funktionen genutzt.</p>
|
<p>Diese Webseite nutzt die selbst gehostete Webanalyse-Software Umami unter stats.andreknie.de. Umami wird ohne Tracking-Cookies eingesetzt. Die Einbindung ist auf andreknie.de und www.andreknie.de beschränkt, Suchparameter und URL-Fragmente werden nicht erfasst und die Do-Not-Track-Einstellung Ihres Browsers wird respektiert. Die Auswertung dient ausschließlich dazu, die Nutzung der Webseite besser zu verstehen und Inhalte technisch sowie redaktionell zu verbessern.</p>
|
||||||
|
|
||||||
|
<p><strong style={{ color: 'var(--text-primary)' }}>11. Cookies</strong></p>
|
||||||
|
<p>Diese Webseite verwendet keine Tracking-Cookies. Es werden ausschließlich technisch notwendige Funktionen genutzt.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
validateEmail,
|
||||||
|
validateContact,
|
||||||
|
validateTalkRequest,
|
||||||
|
validateNewsletter,
|
||||||
|
validateResourceDownload,
|
||||||
|
} from './validation.js'
|
||||||
|
|
||||||
|
describe('validateEmail', () => {
|
||||||
|
it('accepts valid emails', () => {
|
||||||
|
expect(validateEmail('user@example.com')).toBe(true)
|
||||||
|
expect(validateEmail('a.b@sub.domain.org')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid emails', () => {
|
||||||
|
expect(validateEmail('')).toBe(false)
|
||||||
|
expect(validateEmail('no-at-sign')).toBe(false)
|
||||||
|
expect(validateEmail('@missing-local.com')).toBe(false)
|
||||||
|
expect(validateEmail('spaces in@email.com')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateContact', () => {
|
||||||
|
const valid = { name: 'Max', email: 'max@example.com', message: 'Hello' }
|
||||||
|
|
||||||
|
it('passes with valid data', () => {
|
||||||
|
const result = validateContact(valid)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.errors).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without name', () => {
|
||||||
|
const result = validateContact({ ...valid, name: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.name).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without email', () => {
|
||||||
|
const result = validateContact({ ...valid, email: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.email).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without message', () => {
|
||||||
|
const result = validateContact({ ...valid, message: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.message).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails with name over 100 chars', () => {
|
||||||
|
const result = validateContact({ ...valid, name: 'x'.repeat(101) })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.name).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails with message over 2000 chars', () => {
|
||||||
|
const result = validateContact({ ...valid, message: 'x'.repeat(2001) })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.message).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateTalkRequest', () => {
|
||||||
|
const valid = {
|
||||||
|
name: 'Max',
|
||||||
|
email: 'max@example.com',
|
||||||
|
event_name: 'Conference 2026',
|
||||||
|
topic: 'KI im Mittelstand',
|
||||||
|
message: 'Interested in a keynote',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('passes with valid data', () => {
|
||||||
|
const result = validateTalkRequest(valid)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without event_name', () => {
|
||||||
|
const result = validateTalkRequest({ ...valid, event_name: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.event_name).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without topic', () => {
|
||||||
|
const result = validateTalkRequest({ ...valid, topic: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.topic).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateNewsletter', () => {
|
||||||
|
it('passes with valid email', () => {
|
||||||
|
const result = validateNewsletter({ email: 'user@test.com' })
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails with invalid email', () => {
|
||||||
|
const result = validateNewsletter({ email: 'bad' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.email).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateResourceDownload', () => {
|
||||||
|
const valid = {
|
||||||
|
name: 'Max',
|
||||||
|
email: 'max@example.com',
|
||||||
|
company: 'Test GmbH',
|
||||||
|
resource_id: 'speaker-cv',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('passes with valid data', () => {
|
||||||
|
const result = validateResourceDownload(valid)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without company', () => {
|
||||||
|
const result = validateResourceDownload({ ...valid, company: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.company).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails without resource_id', () => {
|
||||||
|
const result = validateResourceDownload({ ...valid, resource_id: '' })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.resource_id).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,7 +23,7 @@ class TestSanitizeWorkspaceKey:
|
|||||||
assert sanitize_workspace_key("v1.0_release") == "v1.0_release"
|
assert sanitize_workspace_key("v1.0_release") == "v1.0_release"
|
||||||
|
|
||||||
def test_unicode_replaced(self):
|
def test_unicode_replaced(self):
|
||||||
assert sanitize_workspace_key("café-123") == "caf__123"
|
assert sanitize_workspace_key("café-123") == "caf_-123"
|
||||||
|
|
||||||
|
|
||||||
class TestGetWorkspacePath:
|
class TestGetWorkspacePath:
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Backup Dismissed Tasks
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 */6 * * *' # Every 6 hours
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: repo-file-writes
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backup:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch dismissed task IDs from OrgMyLife
|
|
||||||
run: |
|
|
||||||
# Get all dismissed and completed tasks (their source_ids)
|
|
||||||
RESPONSE=$(curl -s -u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
|
||||||
"https://api.andreknie.de/api/tasks/dismissed")
|
|
||||||
echo "$RESPONSE" > dismissed_tasks.json
|
|
||||||
cat dismissed_tasks.json | head -c 200
|
|
||||||
|
|
||||||
- name: Commit if changed
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add dismissed_tasks.json
|
|
||||||
git diff --cached --quiet && echo "No changes" && exit 0
|
|
||||||
git commit -m "chore: backup dismissed task IDs [skip ci]"
|
|
||||||
git pull --rebase origin main || true
|
|
||||||
git push
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
name: Mark Issues for Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'DONE_ISSUES.md'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: repo-file-writes
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mark-review:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Mark issues as ready for review
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const content = fs.readFileSync('DONE_ISSUES.md', 'utf8');
|
|
||||||
const issueNumbers = [...content.matchAll(/#(\d+)/g)].map(m => parseInt(m[1]));
|
|
||||||
|
|
||||||
if (issueNumbers.length === 0) {
|
|
||||||
console.log('No issue numbers found in DONE_ISSUES.md');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const num of issueNumbers) {
|
|
||||||
try {
|
|
||||||
const issue = await github.rest.issues.get({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: num,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (issue.data.state === 'open') {
|
|
||||||
// Add review label
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: num,
|
|
||||||
labels: ['ready-for-review'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a comment
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: num,
|
|
||||||
body: '✅ Implementation complete and deployed. Ready for your review.\n\nClose this issue when you\'re satisfied with the result.',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Marked #${num} as ready-for-review`);
|
|
||||||
} else {
|
|
||||||
console.log(`Issue #${num} already closed.`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`Error processing #${num}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the file after processing
|
|
||||||
fs.writeFileSync('DONE_ISSUES.md', '# Done Issues\n\n_Empty — no issues to process._\n');
|
|
||||||
|
|
||||||
- name: Commit cleared file
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add DONE_ISSUES.md
|
|
||||||
git diff --cached --quiet && exit 0
|
|
||||||
git commit -m "chore: clear DONE_ISSUES.md after processing [skip ci]"
|
|
||||||
git pull --rebase origin main || true
|
|
||||||
git push
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
name: Collect AI Tasks
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '*/30 * * * *'
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync-tasks:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fetch agent-ready tasks from OrgMyLife
|
|
||||||
id: fetch
|
|
||||||
run: |
|
|
||||||
curl -s -u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
|
||||||
"https://api.andreknie.de/api/orchestrator/tasks" > /tmp/tasks.json
|
|
||||||
cat /tmp/tasks.json | head -c 500
|
|
||||||
|
|
||||||
- name: Create GitHub Issues for new tasks
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const data = JSON.parse(fs.readFileSync('/tmp/tasks.json', 'utf8'));
|
|
||||||
const tasks = data.tasks || [];
|
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
|
||||||
console.log('No agent-ready tasks found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIssues = await github.rest.issues.listForRepo({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
state: 'open',
|
|
||||||
labels: 'ai-task',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingTitles = new Set(
|
|
||||||
existingIssues.data.map(i => i.title)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const task of tasks) {
|
|
||||||
const issueTitle = `[AI] ${task.identifier}: ${task.title}`;
|
|
||||||
|
|
||||||
if (existingTitles.has(issueTitle)) {
|
|
||||||
console.log(`Skipping (already exists): ${issueTitle}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
`**Task ID:** ${task.identifier}`,
|
|
||||||
`**Priority:** ${task.priority || 'unset'}`,
|
|
||||||
`**State:** ${task.state}`,
|
|
||||||
task.due_date ? `**Due:** ${task.due_date}` : '',
|
|
||||||
'',
|
|
||||||
'## Description',
|
|
||||||
task.description || '_No description provided._',
|
|
||||||
'',
|
|
||||||
'---',
|
|
||||||
'_Auto-created from OrgMyLife task board._',
|
|
||||||
].filter(Boolean).join('\n');
|
|
||||||
|
|
||||||
await github.rest.issues.create({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
title: issueTitle,
|
|
||||||
body: body,
|
|
||||||
labels: ['ai-task'],
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Created issue: ${issueTitle}`);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
name: Update Task State on Issue Events
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [closed, labeled]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mark-review:
|
|
||||||
if: |
|
|
||||||
github.event.action == 'labeled' &&
|
|
||||||
github.event.label.name == 'ready-for-review' &&
|
|
||||||
contains(github.event.issue.labels.*.name, 'ai-task')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Mark task as review in OrgMyLife
|
|
||||||
run: |
|
|
||||||
TITLE="${{ github.event.issue.title }}"
|
|
||||||
TASK_ID=$(echo "$TITLE" | grep -oP 'OML-\K[0-9]+')
|
|
||||||
|
|
||||||
if [ -z "$TASK_ID" ]; then
|
|
||||||
echo "Could not extract task ID from: $TITLE"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Marking task $TASK_ID as review..."
|
|
||||||
|
|
||||||
curl -s -X POST \
|
|
||||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"status": "review"}' \
|
|
||||||
"https://api.andreknie.de/api/orchestrator/tasks/${TASK_ID}/state"
|
|
||||||
|
|
||||||
mark-done:
|
|
||||||
if: |
|
|
||||||
github.event.action == 'closed' &&
|
|
||||||
contains(github.event.issue.labels.*.name, 'ai-task')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Mark task as completed in OrgMyLife
|
|
||||||
run: |
|
|
||||||
TITLE="${{ github.event.issue.title }}"
|
|
||||||
TASK_ID=$(echo "$TITLE" | grep -oP 'OML-\K[0-9]+')
|
|
||||||
|
|
||||||
if [ -z "$TASK_ID" ]; then
|
|
||||||
echo "Could not extract task ID from: $TITLE"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Marking task $TASK_ID as completed..."
|
|
||||||
|
|
||||||
curl -s -X POST \
|
|
||||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"status": "completed"}' \
|
|
||||||
"https://api.andreknie.de/api/orchestrator/tasks/${TASK_ID}/state"
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
name: Deploy to VPS
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Write .env file to VPS
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
env:
|
|
||||||
ENV_CONTENT: |
|
|
||||||
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
|
|
||||||
API_SECRET=${{ secrets.ENV_API_SECRET }}
|
|
||||||
APP_USERNAME=${{ secrets.ENV_APP_USERNAME }}
|
|
||||||
APP_PASSWORD=${{ secrets.ENV_APP_PASSWORD }}
|
|
||||||
NEXTCLOUD_URL=${{ secrets.ENV_NEXTCLOUD_URL }}
|
|
||||||
NEXTCLOUD_USERNAME=${{ secrets.ENV_NEXTCLOUD_USERNAME }}
|
|
||||||
NEXTCLOUD_PASSWORD=${{ secrets.ENV_NEXTCLOUD_PASSWORD }}
|
|
||||||
IMAP_SERVER=${{ secrets.ENV_IMAP_SERVER }}
|
|
||||||
IMAP_PORT=993
|
|
||||||
EMAIL_USERNAME=${{ secrets.ENV_EMAIL_USERNAME }}
|
|
||||||
EMAIL_PASSWORD=${{ secrets.ENV_EMAIL_PASSWORD }}
|
|
||||||
EMAIL_WEBMAIL_URL=${{ secrets.ENV_EMAIL_WEBMAIL_URL }}
|
|
||||||
GMAIL_USERNAME=${{ secrets.ENV_GMAIL_USERNAME }}
|
|
||||||
GMAIL_PASSWORD=${{ secrets.ENV_GMAIL_PASSWORD }}
|
|
||||||
with:
|
|
||||||
host: 217.160.174.2
|
|
||||||
username: root
|
|
||||||
key: ${{ secrets.VPS_SSH_KEY }}
|
|
||||||
envs: ENV_CONTENT
|
|
||||||
script: |
|
|
||||||
# Ensure directory exists
|
|
||||||
if [ ! -d /opt/OrgMyLife ]; then
|
|
||||||
cd /opt
|
|
||||||
git clone https://github.com/DoctoDre/OrgMyLife.git || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd /opt/OrgMyLife
|
|
||||||
git pull origin main || true
|
|
||||||
|
|
||||||
# Write .env from environment variable (safe for special chars)
|
|
||||||
printf '%s\n' "$ENV_CONTENT" > .env
|
|
||||||
|
|
||||||
docker compose up -d --build
|
|
||||||
echo "Deploy complete"
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
name: Sync Issues to File
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '*/30 * * * *'
|
|
||||||
workflow_dispatch: {}
|
|
||||||
issues:
|
|
||||||
types: [opened, closed, reopened, edited, labeled, unlabeled]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: repo-file-writes
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Write open AI issues to file
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const issues = await github.rest.issues.listForRepo({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
state: 'open',
|
|
||||||
labels: 'ai-task',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
let content = '# AI Tasks (auto-generated)\n\n';
|
|
||||||
content += `_Last updated: ${new Date().toISOString()}_\n\n`;
|
|
||||||
|
|
||||||
if (issues.data.length === 0) {
|
|
||||||
content += 'No open AI tasks.\n';
|
|
||||||
} else {
|
|
||||||
content += `${issues.data.length} open task(s):\n\n`;
|
|
||||||
for (const issue of issues.data) {
|
|
||||||
content += `---\n\n`;
|
|
||||||
content += `## #${issue.number}: ${issue.title}\n\n`;
|
|
||||||
content += `- **State:** open\n`;
|
|
||||||
content += `- **Created:** ${issue.created_at}\n`;
|
|
||||||
content += `- **URL:** ${issue.html_url}\n\n`;
|
|
||||||
content += `### Description\n\n`;
|
|
||||||
content += `${issue.body || '_No description._'}\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync('AI_TASKS.md', content);
|
|
||||||
console.log(`Wrote ${issues.data.length} issues to AI_TASKS.md`);
|
|
||||||
|
|
||||||
- name: Commit and push if changed
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add AI_TASKS.md
|
|
||||||
git diff --cached --quiet && echo "No changes" && exit 0
|
|
||||||
git commit -m "chore: update AI_TASKS.md [skip ci]"
|
|
||||||
git pull --rebase origin main || true
|
|
||||||
git push
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Sync OrgMyLife Plan to Projects Board
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths: ['PLAN.md']
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Parse open tasks and sync to Projects board
|
|
||||||
run: |
|
|
||||||
REPO_NAME="OrgMyLife"
|
|
||||||
|
|
||||||
TASKS="[]"
|
|
||||||
if [ -f "PLAN.md" ]; then
|
|
||||||
ITEMS=$(grep -E '^\s*- \[ \]' PLAN.md | sed 's/.*- \[ \] //' | head -50)
|
|
||||||
if [ -n "$ITEMS" ]; then
|
|
||||||
TASKS=$(echo "$ITEMS" | jq -R -s 'split("\n") | map(select(length > 0)) | map({"title": ., "status": "backlog"})')
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Found tasks: $TASKS"
|
|
||||||
|
|
||||||
PAYLOAD=$(jq -n --arg repo "$REPO_NAME" --argjson tasks "$TASKS" '{"repo": $repo, "tasks": $tasks}')
|
|
||||||
|
|
||||||
curl -s -X POST \
|
|
||||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
|
||||||
-H "X-API-Key: ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"https://api.andreknie.de/api/projects/sync"
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
stages:
|
||||||
|
- sync
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DEPLOY_HOST: "217.160.174.2"
|
||||||
|
DEPLOY_USER: "root"
|
||||||
|
DEPLOY_PATH: "/opt/OrgMyLife"
|
||||||
|
|
||||||
|
.ssh_setup: &ssh_setup
|
||||||
|
before_script:
|
||||||
|
- apt-get update -qq && apt-get install -y openssh-client rsync curl
|
||||||
|
- install -d -m 700 ~/.ssh
|
||||||
|
- echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
- chmod 600 ~/.ssh/deploy_key
|
||||||
|
- ssh-keyscan -H "${DEPLOY_HOST}" > ~/.ssh/known_hosts
|
||||||
|
- chmod 600 ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
deploy-vps:
|
||||||
|
stage: deploy
|
||||||
|
image: node:20
|
||||||
|
<<: *ssh_setup
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
||||||
|
changes:
|
||||||
|
- "shared/OrgMyLife/**/*"
|
||||||
|
script:
|
||||||
|
- ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "install -d -m 755 ${DEPLOY_PATH}"
|
||||||
|
- |
|
||||||
|
cat <<EOF > .env
|
||||||
|
POSTGRES_PASSWORD=$ENV_POSTGRES_PASSWORD
|
||||||
|
API_SECRET=$ENV_API_SECRET
|
||||||
|
APP_USERNAME=$ENV_APP_USERNAME
|
||||||
|
APP_PASSWORD=$ENV_APP_PASSWORD
|
||||||
|
NEXTCLOUD_URL=$ENV_NEXTCLOUD_URL
|
||||||
|
NEXTCLOUD_USERNAME=$ENV_NEXTCLOUD_USERNAME
|
||||||
|
NEXTCLOUD_PASSWORD=$ENV_NEXTCLOUD_PASSWORD
|
||||||
|
IMAP_SERVER=$ENV_IMAP_SERVER
|
||||||
|
IMAP_PORT=993
|
||||||
|
EMAIL_USERNAME=$ENV_EMAIL_USERNAME
|
||||||
|
EMAIL_PASSWORD=$ENV_EMAIL_PASSWORD
|
||||||
|
EMAIL_WEBMAIL_URL=$ENV_EMAIL_WEBMAIL_URL
|
||||||
|
GMAIL_USERNAME=$ENV_GMAIL_USERNAME
|
||||||
|
GMAIL_PASSWORD=$ENV_GMAIL_PASSWORD
|
||||||
|
EOF
|
||||||
|
- rsync -az --delete -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" shared/OrgMyLife/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
|
||||||
|
- rsync -az -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" .env "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/.env"
|
||||||
|
- ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "cd ${DEPLOY_PATH} && docker compose up -d --build"
|
||||||
|
|
||||||
|
collect-tasks:
|
||||||
|
stage: sync
|
||||||
|
image: alpine:latest
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE_TARGET == "collect-tasks"'
|
||||||
|
script:
|
||||||
|
- apk add --no-cache curl jq
|
||||||
|
- |
|
||||||
|
curl -s -u "${ORGMYLIFE_USER}:${ORGMYLIFE_PASS}" \
|
||||||
|
-H "Authorization: Bearer ${ORGMYLIFE_API_SECRET}" \
|
||||||
|
"https://api.andreknie.de/api/orchestrator/tasks" > /tmp/tasks.json
|
||||||
|
- echo "Tasks fetched. Since GitLab handles issues differently, issue creation logic needs to be adapted for GitLab API."
|
||||||
|
|
||||||
|
backup-dismissed:
|
||||||
|
stage: sync
|
||||||
|
image: alpine:latest
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE_TARGET == "backup-dismissed"'
|
||||||
|
script:
|
||||||
|
- apk add --no-cache curl git
|
||||||
|
- |
|
||||||
|
curl -s -u "${ORGMYLIFE_USER}:${ORGMYLIFE_PASS}" \
|
||||||
|
-H "Authorization: Bearer ${ORGMYLIFE_API_SECRET}" \
|
||||||
|
"https://api.andreknie.de/api/tasks/dismissed" > shared/OrgMyLife/dismissed_tasks.json
|
||||||
|
- git config user.name "gitlab-ci[bot]"
|
||||||
|
- git config user.email "gitlab-ci[bot]@gitlab.dhive.io"
|
||||||
|
- git add shared/OrgMyLife/dismissed_tasks.json
|
||||||
|
- git diff --cached --quiet || (git commit -m "chore: backup dismissed task IDs [skip ci]" && git push "https://oauth2:${PROJECT_ACCESS_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:$CI_COMMIT_BRANCH)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# GitLab Migration Plan & Status
|
||||||
|
|
||||||
|
## What has been done
|
||||||
|
- The scheduled GitHub workflows have been disabled (`Collect AI Tasks`, `Sync Issues to File`, `Backup Dismissed Tasks`) because of GitHub billing limits that were causing pipeline failures and costing money.
|
||||||
|
- This repository is being prepared for migration to GitLab.
|
||||||
|
|
||||||
|
## What needs to be done
|
||||||
|
In order to get the CI/CD and automation processes running again on GitLab, the following tasks must be completed:
|
||||||
|
|
||||||
|
1. **Import the repository to GitLab**:
|
||||||
|
- Migrate the source code.
|
||||||
|
- Migrate GitHub Issues, labels, and Projects.
|
||||||
|
|
||||||
|
2. **Translate GitHub Actions to GitLab CI/CD (`.gitlab-ci.yml`)**:
|
||||||
|
- `Backup Dismissed Tasks`: Implement as a GitLab Scheduled Pipeline running every 6 hours.
|
||||||
|
- `Mark Issues for Review`: Migrate to a standard GitLab CI pipeline triggered by pushes changing `DONE_ISSUES.md`.
|
||||||
|
- `Collect AI Tasks`: Implement as a GitLab Scheduled Pipeline running every 30 minutes.
|
||||||
|
- `Update Task State on Issue Events`: Create a GitLab webhook responding to issue events, or use GitLab CI pipelines with issue event triggers.
|
||||||
|
- `Deploy to VPS`: Migrate to a GitLab CI pipeline triggered on branch pushes to `main`.
|
||||||
|
- `Sync Issues to File`: Migrate to a GitLab Scheduled Pipeline (every 30 minutes) and/or use GitLab Webhooks for issue event triggers.
|
||||||
|
- `Sync OrgMyLife Plan to Projects Board`: Migrate to a GitLab CI pipeline triggered by pushes to `PLAN.md`.
|
||||||
|
|
||||||
|
3. **Configure CI/CD Variables**:
|
||||||
|
- Transfer any repository secrets or environment variables (such as deployment keys, SSH credentials, API tokens) into GitLab CI/CD Settings under Variables.
|
||||||
|
|
||||||
|
4. **Runner Configuration**:
|
||||||
|
- Ensure the GitLab Runners have the necessary capabilities (e.g., Node.js environments) by utilizing standard Docker images in the `.gitlab-ci.yml` (e.g., `image: node:20`).
|
||||||
|
|
||||||
|
5. **Optimize Pipeline Compute Efficiency**:
|
||||||
|
- Analyze and optimize the pipeline execution to drastically reduce compute time.
|
||||||
|
- **Context:** The previous runner consumed ~2000 minutes of compute time in just 10 days, which is highly inefficient for a small application. Strategies like reducing cron frequencies, utilizing caching, and conditionally running jobs should be implemented.
|
||||||
@@ -5,30 +5,20 @@ federation:
|
|||||||
conflict_strategy: "team-wins"
|
conflict_strategy: "team-wins"
|
||||||
|
|
||||||
team_repos:
|
team_repos:
|
||||||
- context: privat
|
|
||||||
url: "https://github.com/andreknie/privat-team.git"
|
|
||||||
branch: main
|
|
||||||
sync_direction: bidirectional
|
|
||||||
sync_frequency: "on-push"
|
|
||||||
shared_mirror:
|
|
||||||
enabled: true
|
|
||||||
paths:
|
|
||||||
- "shared/tools/common-scripts/"
|
|
||||||
- "shared/config/base-config.yaml"
|
|
||||||
mode: read-only
|
|
||||||
|
|
||||||
- context: dhive
|
- context: dhive
|
||||||
url: "https://gitlab.dhive.io/team/dhive-mono.git"
|
url: "https://git.d-hive.de/team/dhive-mono.git"
|
||||||
branch: main
|
branch: main
|
||||||
sync_direction: bidirectional
|
sync_direction: bidirectional
|
||||||
sync_frequency: "on-push"
|
sync_frequency: "on-push"
|
||||||
shared_mirror:
|
shared_mirror:
|
||||||
enabled: true
|
enabled: true
|
||||||
paths:
|
paths:
|
||||||
|
- "privat/"
|
||||||
|
- "shared/AI-Orchestrator/"
|
||||||
|
- "shared/OrgMyLife/"
|
||||||
|
- "shared/config/"
|
||||||
- "shared/tools/"
|
- "shared/tools/"
|
||||||
- "shared/powers/db-dxp-platform/"
|
mode: bidirectional
|
||||||
- "shared/mcp-servers/"
|
|
||||||
mode: read-only
|
|
||||||
|
|
||||||
- context: bahn
|
- context: bahn
|
||||||
url: "https://gitlab.2700.2db.it/team/bahn-workspace.git"
|
url: "https://gitlab.2700.2db.it/team/bahn-workspace.git"
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ class SharedMirrorConfig:
|
|||||||
|
|
||||||
enabled: bool
|
enabled: bool
|
||||||
paths: list[str] = field(default_factory=list)
|
paths: list[str] = field(default_factory=list)
|
||||||
mode: Literal["read-only"] = "read-only"
|
mode: Literal["read-only", "bidirectional"] = "read-only"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
Reference in New Issue
Block a user