Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
||||
@@ -106,3 +106,6 @@ All filenames must be OneDrive-safe:
|
||||
- No single quotes or backticks
|
||||
- Replace umlauts in filenames: ä→ae, ö→oe, ü→ue, ß→ss
|
||||
- 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
|
||||
|
||||
- **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.
|
||||
- **NoteGraph** needs a complete redo — superseded by the monorepo knowledge store.
|
||||
- **symphony** is a read-only reference (pinned).
|
||||
- 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
|
||||
|
||||
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",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"start:server": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22,6 +23,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@@ -29,6 +32,8 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"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,
|
||||
},
|
||||
})
|
||||
@@ -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 == "main"'
|
||||
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 == "main"'
|
||||
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!"
|
||||
@@ -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"
|
||||
|
||||
def test_unicode_replaced(self):
|
||||
assert sanitize_workspace_key("café-123") == "caf__123"
|
||||
assert sanitize_workspace_key("café-123") == "caf_-123"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
url: "https://gitlab.dhive.io/team/dhive-mono.git"
|
||||
url: "https://git.d-hive.de/team/dhive-mono.git"
|
||||
branch: main
|
||||
sync_direction: bidirectional
|
||||
sync_frequency: "on-push"
|
||||
shared_mirror:
|
||||
enabled: true
|
||||
paths:
|
||||
- "privat/"
|
||||
- "shared/AI-Orchestrator/"
|
||||
- "shared/OrgMyLife/"
|
||||
- "shared/config/"
|
||||
- "shared/tools/"
|
||||
- "shared/powers/db-dxp-platform/"
|
||||
- "shared/mcp-servers/"
|
||||
mode: read-only
|
||||
mode: bidirectional
|
||||
|
||||
- context: bahn
|
||||
url: "https://gitlab.2700.2db.it/team/bahn-workspace.git"
|
||||
|
||||
@@ -218,7 +218,7 @@ class SharedMirrorConfig:
|
||||
|
||||
enabled: bool
|
||||
paths: list[str] = field(default_factory=list)
|
||||
mode: Literal["read-only"] = "read-only"
|
||||
mode: Literal["read-only", "bidirectional"] = "read-only"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Reference in New Issue
Block a user