feat: send daily repository status to Discord

This commit is contained in:
alexpolo1
2026-08-18 10:07:59 +02:00
parent 77cddbf704
commit 8d2db6281c
11 changed files with 491 additions and 5 deletions
@@ -0,0 +1,71 @@
const {
buildDiscordPayload,
normalizeGitHubUrl,
parsePorcelain,
postDiscordStatus,
summarizeChecks
} = require('../src/services/repositoryStatusDiscordService');
describe('Repository Discord status', () => {
test('normalizes GitHub remotes without leaking transport details', () => {
expect(normalizeGitHubUrl('[email protected]:alexpolo1/tilbudgivern.git'))
.toBe('https://github.com/alexpolo1/tilbudgivern');
expect(normalizeGitHubUrl('https://github.com/alexpolo1/tilbudgivern.git'))
.toBe('https://github.com/alexpolo1/tilbudgivern');
});
test('counts staged, modified and untracked files', () => {
expect(parsePorcelain('M staged.js\n M modified.js\n?? new.js\nMM both.js\n')).toEqual({
changed: 4,
staged: 2,
modified: 2,
untracked: 1
});
});
test('summarizes pull request checks', () => {
expect(summarizeChecks([{ statusCheckRollup: [
{ status: 'COMPLETED', conclusion: 'SUCCESS' },
{ status: 'IN_PROGRESS', conclusion: '' },
{ status: 'COMPLETED', conclusion: 'FAILURE' }
] }])).toEqual({ passed: 1, pending: 1, failed: 1 });
});
test('builds a red, mention-safe Discord card when drift needs action', () => {
const payload = buildDiscordPayload({
repositoryUrl: 'https://github.com/alexpolo1/tilbudgivern',
branch: 'main',
upstream: 'origin/main',
ahead: 0,
behind: 1,
worktree: { changed: 0, staged: 0, modified: 0, untracked: 0 },
latestCommit: { hash: 'abc1234', subject: 'fix: test' },
pullRequests: [],
issues: [],
checks: { passed: 1, pending: 0, failed: 1 },
health: { ok: false, label: 'Utilgængelig' }
}, new Date('2026-08-18T06:30:00.000Z'));
expect(payload.allowed_mentions).toEqual({ parse: [] });
expect(payload.embeds[0]).toMatchObject({ color: 15548997 });
expect(payload.embeds[0].title).toContain('Kræver handling');
expect(JSON.stringify(payload)).not.toContain('webhook');
});
test('posts JSON with wait enabled without embedding the webhook in payload', async () => {
const fetchImpl = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 'message-1' })
});
const payload = { allowed_mentions: { parse: [] }, embeds: [] };
await expect(postDiscordStatus({
webhookUrl: 'https://discord.example/webhook-id/token',
payload,
fetchImpl
})).resolves.toEqual({ id: 'message-1' });
expect(fetchImpl.mock.calls[0][0].searchParams.get('wait')).toBe('true');
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual(payload);
});
});
@@ -0,0 +1,237 @@
const { execFileSync } = require('child_process');
const DISCORD_LIMITS = {
fieldValue: 1024,
description: 4096
};
const truncate = (value, maxLength) => {
const text = String(value || '');
if (text.length <= maxLength) return text;
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
};
const normalizeGitHubUrl = (remoteUrl = '') => {
const value = String(remoteUrl).trim();
const sshMatch = value.match(/^git@github\.com:(.+?)(?:\.git)?$/);
if (sshMatch) return `https://github.com/${sshMatch[1]}`;
const httpsMatch = value.match(/^https:\/\/github\.com\/(.+?)(?:\.git)?$/);
if (httpsMatch) return `https://github.com/${httpsMatch[1]}`;
return '';
};
const parsePorcelain = (output = '') => {
const lines = String(output).split('\n').filter(Boolean);
return lines.reduce((summary, line) => {
summary.changed += 1;
if (line.startsWith('??')) {
summary.untracked += 1;
return summary;
}
if (line[0] && line[0] !== ' ') summary.staged += 1;
if (line[1] && line[1] !== ' ') summary.modified += 1;
return summary;
}, { changed: 0, staged: 0, modified: 0, untracked: 0 });
};
const summarizeChecks = (pullRequests = []) => {
const summary = { passed: 0, pending: 0, failed: 0 };
pullRequests.forEach((pullRequest) => {
(pullRequest.statusCheckRollup || []).forEach((check) => {
const conclusion = String(check.conclusion || '').toUpperCase();
const status = String(check.status || '').toUpperCase();
if (['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED'].includes(conclusion)) {
summary.failed += 1;
} else if (status !== 'COMPLETED' || !conclusion) {
summary.pending += 1;
} else if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(conclusion)) {
summary.passed += 1;
}
});
});
return summary;
};
const safeJson = (value, fallback) => {
try {
return JSON.parse(value);
} catch (_error) {
return fallback;
}
};
const createCommandRunner = (repoDir) => (command, args, options = {}) => {
try {
return execFileSync(command, args, {
cwd: repoDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: options.timeout || 15000
}).trim();
} catch (_error) {
return options.fallback ?? '';
}
};
const collectRepositoryStatus = async ({
repoDir,
runCommand = createCommandRunner(repoDir),
fetchImpl = global.fetch,
healthUrl = 'http://127.0.0.1:4032/api/health'
}) => {
// Refresh remote refs so ahead/behind is based on today's GitHub state.
runCommand('git', ['fetch', '--quiet', '--prune', 'origin'], { timeout: 30000 });
const branch = runCommand('git', ['branch', '--show-current'], { fallback: 'detached HEAD' }) || 'detached HEAD';
const remoteUrl = runCommand('git', ['remote', 'get-url', 'origin']);
const repositoryUrl = normalizeGitHubUrl(remoteUrl);
const worktree = parsePorcelain(runCommand('git', ['status', '--porcelain=v1']));
const latestCommitRaw = runCommand('git', ['log', '-1', '--pretty=format:%h%x09%s%x09%cI']);
const [commitHash = 'ukendt', commitSubject = 'Ingen commits', commitDate = ''] = latestCommitRaw.split('\t');
const upstream = runCommand('git', ['rev-parse', '--abbrev-ref', '@{upstream}']);
let ahead = 0;
let behind = 0;
if (upstream) {
const counts = runCommand('git', ['rev-list', '--left-right', '--count', `HEAD...${upstream}`]);
[ahead, behind] = counts.split(/\s+/).map((value) => Number(value) || 0);
}
const pullRequests = safeJson(runCommand('gh', [
'pr', 'list', '--state', 'open', '--limit', '20',
'--json', 'number,title,url,isDraft,reviewDecision,statusCheckRollup'
], { fallback: '[]', timeout: 20000 }), []);
const issues = safeJson(runCommand('gh', [
'issue', 'list', '--state', 'open', '--limit', '20',
'--json', 'number,title,url,labels'
], { fallback: '[]', timeout: 20000 }), []);
const checks = summarizeChecks(pullRequests);
let health = { ok: false, label: 'Utilgængelig' };
if (typeof fetchImpl === 'function') {
try {
const response = await fetchImpl(healthUrl, { signal: AbortSignal.timeout(5000) });
const payload = response.ok ? await response.json() : null;
health = {
ok: response.ok && payload?.status === 'ok',
label: response.ok && payload?.status === 'ok' ? 'Online' : `HTTP ${response.status}`
};
} catch (_error) {
health = { ok: false, label: 'Utilgængelig' };
}
}
return {
repositoryUrl,
branch,
upstream,
ahead,
behind,
worktree,
latestCommit: { hash: commitHash, subject: commitSubject, date: commitDate },
pullRequests,
issues,
checks,
health
};
};
const formatPullRequests = (pullRequests = []) => {
if (!pullRequests.length) return 'Ingen åbne pull requests';
return pullRequests.slice(0, 5).map((pr) => (
`[#${pr.number}](${pr.url}) ${truncate(pr.title, 90)}${pr.isDraft ? ' · kladde' : ''}`
)).join('\n');
};
const formatIssues = (issues = []) => {
if (!issues.length) return 'Ingen åbne issues';
return issues.slice(0, 5).map((issue) => (
`[#${issue.number}](${issue.url}) ${truncate(issue.title, 90)}`
)).join('\n');
};
const buildDiscordPayload = (status, now = new Date()) => {
const hasFailures = !status.health.ok || status.checks.failed > 0;
const needsAttention = status.worktree.changed > 0 || status.behind > 0 || status.checks.pending > 0;
const color = hasFailures ? 15548997 : needsAttention ? 16753920 : 5763719;
const state = hasFailures ? 'Kræver handling' : needsAttention ? 'Kræver opmærksomhed' : 'Alt ser grønt ud';
const encodedBranch = String(status.branch).split('/').map(encodeURIComponent).join('/');
const branchValue = status.repositoryUrl
? `[${status.branch}](${status.repositoryUrl}/tree/${encodedBranch})`
: status.branch;
const payload = {
username: 'Tilbudsgivern Repo Status',
allowed_mentions: { parse: [] },
embeds: [{
title: `🔨 Daglig repo-status · ${state}`,
description: truncate('Automatisk status for Tilbudsgivern. Ingen kundeoplysninger eller secrets indgår.', DISCORD_LIMITS.description),
color,
fields: [
{ name: 'Branch', value: branchValue, inline: true },
{ name: 'Drift', value: status.health.label, inline: true },
{
name: 'Arbejdsmappe',
value: `${status.worktree.changed} ændret · ${status.worktree.staged} staged · ${status.worktree.modified} modified · ${status.worktree.untracked} untracked`,
inline: false
},
{
name: 'Upstream',
value: status.upstream ? `${status.ahead} ahead · ${status.behind} behind ${status.upstream}` : 'Ingen upstream',
inline: false
},
{
name: 'Seneste commit',
value: `\`${status.latestCommit.hash}\` ${truncate(status.latestCommit.subject, 180)}`,
inline: false
},
{
name: `Pull requests (${status.pullRequests.length})`,
value: truncate(formatPullRequests(status.pullRequests), DISCORD_LIMITS.fieldValue),
inline: false
},
{
name: `Checks · ${status.checks.passed} grønne / ${status.checks.pending} afventer / ${status.checks.failed} fejler`,
value: status.checks.failed > 0
? 'Mindst ét PR-check fejler og bør undersøges.'
: status.checks.pending > 0 ? 'Der er checks i gang eller uden resultat.' : 'Ingen kendte fejlende checks.',
inline: false
},
{
name: `Issues (${status.issues.length})`,
value: truncate(formatIssues(status.issues), DISCORD_LIMITS.fieldValue),
inline: false
}
],
footer: { text: 'Tilbudsgivern · daglig repository-kontrol' },
timestamp: now.toISOString()
}]
};
return payload;
};
const postDiscordStatus = async ({ webhookUrl, payload, fetchImpl = global.fetch }) => {
if (!webhookUrl) throw new Error('DISCORD_REPO_STATUS_WEBHOOK_URL mangler');
const url = new URL(webhookUrl);
url.searchParams.set('wait', 'true');
const response = await fetchImpl(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
throw new Error(`Discord svarede HTTP ${response.status}`);
}
return response.json();
};
module.exports = {
buildDiscordPayload,
collectRepositoryStatus,
normalizeGitHubUrl,
parsePorcelain,
postDiscordStatus,
summarizeChecks,
truncate
};
+59
View File
@@ -0,0 +1,59 @@
# Daglig repository-status på Discord
## Formål
Tilbudsgivern sender hver morgen en kompakt drifts- og Git-status til Discord. Beskeden er beregnet som dagens tekniske startpunkt og indeholder ikke kundeoplysninger, fritekst fra tilbud eller secrets.
## Tidspunkt og drift
- Tidspunkt: dagligt kl. 08:30.
- Tidszone: `Europe/Copenhagen` via brugerens eksisterende crontab.
- Kommando: `node scripts/send-daily-repo-status.js --send`.
- Log: `logs/daily-repo-status.log` (ignoreret af Git).
- Webhook-konfiguration: `/mnt/HC_Volume_103713257/tilbudsgivern-data/secrets/discord.env`.
- Kompatibilitet: `/home/alex/.config/codex-weekly-discord.env` er et symlink til samme eksterne secret-fil, så den eksisterende Codex-status fortsætter.
Cronlinjen er:
```cron
30 8 * * * /home/alex/.nvm/versions/node/v22.22.0/bin/node /mnt/HC_Volume_103713257/tilbudgivern/scripts/send-daily-repo-status.js --send >> /mnt/HC_Volume_103713257/tilbudgivern/logs/daily-repo-status.log 2>&1
```
## Indhold i beskeden
- Aktiv branch med GitHub-link.
- Seneste commit.
- Antal staged, modificerede og untracked filer.
- Ahead/behind i forhold til upstream efter `git fetch --prune origin`.
- Op til fem åbne pull requests og issues med links.
- Samlet antal grønne, afventende og fejlende PR-checks.
- Lokal sundhedsstatus fra `http://127.0.0.1:4032/api/health`.
Kortet er grønt, når alt er rent og online, orange ved fx lokale ændringer, manglende upstream-synkronisering eller afventende checks og rødt ved offline drift eller fejlende checks. Discord-mentions er deaktiveret i payloaden.
## Manuel kontrol
Vis payload uden at sende:
```bash
npm run repo-status:dry
```
Send en rigtig status:
```bash
npm run repo-status:send
```
Kontrollér cronjobbet:
```bash
crontab -l | grep -A1 "Tilbudgivern daily repo status"
```
## Fejlfinding
1. Kontrollér `logs/daily-repo-status.log`.
2. Kør dry-run og kontrollér, at GitHub CLI kan læse PR’er og issues.
3. Kontrollér kun variabelnavnet i secret-filen; webhookværdien må aldrig kopieres til kode, dokumentation eller logs.
4. Kør en manuel sendetest efter webhookrotation.
@@ -4,7 +4,7 @@
> Dette indeks skjuler arkiveret historik. Brug `Dokumentindeks.md` når ældre beslutninger eller fejlforløb skal findes.
Sidst synkroniseret: 2026-08-18 00:48:12 CEST
Sidst synkroniseret: 2026-08-18 10:07:51 CEST
## AI og Codex
@@ -86,6 +86,7 @@ Sidst synkroniseret: 2026-08-18 00:48:12 CEST
- [[10 Dokumenter/Vejledninger/AI_TILBUDS_PROMPT_GUIDE|AI_TILBUDS_PROMPT_GUIDE]] — `docs/guides/AI_TILBUDS_PROMPT_GUIDE.md`
- [[10 Dokumenter/Vejledninger/CARPENTER_5_HOUR_EXECUTION_LOOP|CARPENTER_5_HOUR_EXECUTION_LOOP]] — `docs/guides/CARPENTER_5_HOUR_EXECUTION_LOOP.md`
- [[10 Dokumenter/Vejledninger/CODEX_SUPPORT_WORKLIST|CODEX_SUPPORT_WORKLIST]] — `docs/guides/CODEX_SUPPORT_WORKLIST.md`
- [[10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS|DAILY_DISCORD_REPO_STATUS]] — `docs/guides/DAILY_DISCORD_REPO_STATUS.md`
- [[10 Dokumenter/Vejledninger/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG|MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG]] — `docs/guides/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG.md`
- [[10 Dokumenter/Vejledninger/MANUAL_SCRAPER_README|MANUAL_SCRAPER_README]] — `docs/guides/MANUAL_SCRAPER_README.md`
- [[10 Dokumenter/Vejledninger/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS|ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS]] — `docs/guides/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS.md`
@@ -162,4 +163,4 @@ Sidst synkroniseret: 2026-08-18 00:48:12 CEST
---
**Aktive dokumenter: 120. Arkiverede dokumenter: 200.**
**Aktive dokumenter: 121. Arkiverede dokumenter: 200.**
+3 -2
View File
@@ -4,7 +4,7 @@
> Kør `./scripts/sync-obsidian-vault.sh` fra projektroden for at opdatere kopier og indeks.
Sidst synkroniseret: 2026-08-18 00:48:12 CEST
Sidst synkroniseret: 2026-08-18 10:07:51 CEST
## AI og Codex
@@ -86,6 +86,7 @@ Sidst synkroniseret: 2026-08-18 00:48:12 CEST
- [[10 Dokumenter/Vejledninger/AI_TILBUDS_PROMPT_GUIDE|AI_TILBUDS_PROMPT_GUIDE]] — `docs/guides/AI_TILBUDS_PROMPT_GUIDE.md`
- [[10 Dokumenter/Vejledninger/CARPENTER_5_HOUR_EXECUTION_LOOP|CARPENTER_5_HOUR_EXECUTION_LOOP]] — `docs/guides/CARPENTER_5_HOUR_EXECUTION_LOOP.md`
- [[10 Dokumenter/Vejledninger/CODEX_SUPPORT_WORKLIST|CODEX_SUPPORT_WORKLIST]] — `docs/guides/CODEX_SUPPORT_WORKLIST.md`
- [[10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS|DAILY_DISCORD_REPO_STATUS]] — `docs/guides/DAILY_DISCORD_REPO_STATUS.md`
- [[10 Dokumenter/Vejledninger/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG|MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG]] — `docs/guides/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG.md`
- [[10 Dokumenter/Vejledninger/MANUAL_SCRAPER_README|MANUAL_SCRAPER_README]] — `docs/guides/MANUAL_SCRAPER_README.md`
- [[10 Dokumenter/Vejledninger/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS|ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS]] — `docs/guides/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS.md`
@@ -365,4 +366,4 @@ Sidst synkroniseret: 2026-08-18 00:48:12 CEST
---
**I alt: 320 dokumenter.**
**I alt: 321 dokumenter.**
@@ -291,6 +291,7 @@ docs/features/TOMRER_SMART_PACKAGES_GUIDE.md 10 Dokumenter/Funktioner/TOMRER_SMA
docs/guides/AI_TILBUDS_PROMPT_GUIDE.md 10 Dokumenter/Vejledninger/AI_TILBUDS_PROMPT_GUIDE.md
docs/guides/CARPENTER_5_HOUR_EXECUTION_LOOP.md 10 Dokumenter/Vejledninger/CARPENTER_5_HOUR_EXECUTION_LOOP.md
docs/guides/CODEX_SUPPORT_WORKLIST.md 10 Dokumenter/Vejledninger/CODEX_SUPPORT_WORKLIST.md
docs/guides/DAILY_DISCORD_REPO_STATUS.md 10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS.md
docs/guides/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG.md 10 Dokumenter/Vejledninger/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG.md
docs/guides/MANUAL_SCRAPER_README.md 10 Dokumenter/Vejledninger/MANUAL_SCRAPER_README.md
docs/guides/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS.md 10 Dokumenter/Vejledninger/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS.md
1 source vault_path
291 docs/guides/AI_TILBUDS_PROMPT_GUIDE.md 10 Dokumenter/Vejledninger/AI_TILBUDS_PROMPT_GUIDE.md
292 docs/guides/CARPENTER_5_HOUR_EXECUTION_LOOP.md 10 Dokumenter/Vejledninger/CARPENTER_5_HOUR_EXECUTION_LOOP.md
293 docs/guides/CODEX_SUPPORT_WORKLIST.md 10 Dokumenter/Vejledninger/CODEX_SUPPORT_WORKLIST.md
294 docs/guides/DAILY_DISCORD_REPO_STATUS.md 10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS.md
295 docs/guides/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG.md 10 Dokumenter/Vejledninger/MAANEDLIG_TOEMRER_KVALITETSGENNEMGANG.md
296 docs/guides/MANUAL_SCRAPER_README.md 10 Dokumenter/Vejledninger/MANUAL_SCRAPER_README.md
297 docs/guides/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS.md 10 Dokumenter/Vejledninger/ORDRESTYRING_REFERENCE_CASE_2591_INSTRUKS.md
@@ -29,6 +29,10 @@ En ny tre-profils tilbudsgennemgang er gennemført på mobil: komplet B7-tagskif
Den praktiske browserkontrol viste efter rettelserne 1/4 trin på et nyt projekt, gemt fladtagsgeometri på 0,26 m/48,18 m², projektsøgning fra 20 til 1 resultat og en tydelig blokering øverst på det historiske B7-tilbud. Historiske projekter med materialelinjer uden materiale-id er fortsat med vilje blokeret; de skal relinkes til rigtige materialer eller have en verificeret Smart Pakke anvendt igen og må ikke auto-matches ved gæt. Se [Tre tømrerprofiler: tilbudsgennemgang 18. august 2026](docs/status-reports/THREE_CARPENTER_OFFER_REVIEW_2026-08-18.md).
### Daglig Discord repo-status — 18. august 2026
Repository, GitHub og lokal drift rapporteres automatisk til Discord hver morgen kl. 08:30 dansk tid. Statuskortet viser branch, seneste commit, lokale ændringer, upstream-afvigelse, åbne PR’er/issues, PR-checks og `/api/health`. En rigtig webhooktest blev leveret til Discord, og secret-konfigurationen ligger i den eksterne data-mappe med rettighed `600`. Se [Daglig repository-status på Discord](docs/guides/DAILY_DISCORD_REPO_STATUS.md).
Ved hver fremtidig kontrol opdateres tiderne, enhed/skærmstørrelse, antal dobbeltindtastninger, største flaskehals og resultatet `BESTÅET` eller `IKKE BESTÅET`. Hvis et P2/P3-punkt viser sig at blokere feltflowet, flyttes det straks til P0 eller P1.
### Fast regel for smartpakker
@@ -0,0 +1,59 @@
# Daglig repository-status på Discord
## Formål
Tilbudsgivern sender hver morgen en kompakt drifts- og Git-status til Discord. Beskeden er beregnet som dagens tekniske startpunkt og indeholder ikke kundeoplysninger, fritekst fra tilbud eller secrets.
## Tidspunkt og drift
- Tidspunkt: dagligt kl. 08:30.
- Tidszone: `Europe/Copenhagen` via brugerens eksisterende crontab.
- Kommando: `node scripts/send-daily-repo-status.js --send`.
- Log: `logs/daily-repo-status.log` (ignoreret af Git).
- Webhook-konfiguration: `/mnt/HC_Volume_103713257/tilbudsgivern-data/secrets/discord.env`.
- Kompatibilitet: `/home/alex/.config/codex-weekly-discord.env` er et symlink til samme eksterne secret-fil, så den eksisterende Codex-status fortsætter.
Cronlinjen er:
```cron
30 8 * * * /home/alex/.nvm/versions/node/v22.22.0/bin/node /mnt/HC_Volume_103713257/tilbudgivern/scripts/send-daily-repo-status.js --send >> /mnt/HC_Volume_103713257/tilbudgivern/logs/daily-repo-status.log 2>&1
```
## Indhold i beskeden
- Aktiv branch med GitHub-link.
- Seneste commit.
- Antal staged, modificerede og untracked filer.
- Ahead/behind i forhold til upstream efter `git fetch --prune origin`.
- Op til fem åbne pull requests og issues med links.
- Samlet antal grønne, afventende og fejlende PR-checks.
- Lokal sundhedsstatus fra `http://127.0.0.1:4032/api/health`.
Kortet er grønt, når alt er rent og online, orange ved fx lokale ændringer, manglende upstream-synkronisering eller afventende checks og rødt ved offline drift eller fejlende checks. Discord-mentions er deaktiveret i payloaden.
## Manuel kontrol
Vis payload uden at sende:
```bash
npm run repo-status:dry
```
Send en rigtig status:
```bash
npm run repo-status:send
```
Kontrollér cronjobbet:
```bash
crontab -l | grep -A1 "Tilbudgivern daily repo status"
```
## Fejlfinding
1. Kontrollér `logs/daily-repo-status.log`.
2. Kør dry-run og kontrollér, at GitHub CLI kan læse PR’er og issues.
3. Kontrollér kun variabelnavnet i secret-filen; webhookværdien må aldrig kopieres til kode, dokumentation eller logs.
4. Kør en manuel sendetest efter webhookrotation.
+3 -1
View File
@@ -37,7 +37,9 @@
"test:ui:all": "npm run test:pw && npm run test:selenium",
"carpenter:openai:quotes": "node carpenter-agent-openai.js --generate-quotes=5 --save",
"carpenter:openai:ui": "cd tests && PLAYWRIGHT_BASE_URL=http://localhost:4032 ./node_modules/.bin/playwright test carpenter-agent-openai.spec.js",
"carpenter:openai:ui:report": "cd tests && ./node_modules/.bin/playwright show-report"
"carpenter:openai:ui:report": "cd tests && ./node_modules/.bin/playwright show-report",
"repo-status:dry": "node scripts/send-daily-repo-status.js --dry-run",
"repo-status:send": "node scripts/send-daily-repo-status.js --send"
},
"dependencies": {
"axios": "^1.12.2",
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const {
buildDiscordPayload,
collectRepositoryStatus,
postDiscordStatus
} = require('../backend/src/services/repositoryStatusDiscordService');
const repoDir = path.resolve(__dirname, '..');
const defaultEnvFile = '/mnt/HC_Volume_103713257/tilbudsgivern-data/secrets/discord.env';
const loadEnvFile = (filePath) => {
if (!filePath || !fs.existsSync(filePath)) return;
fs.readFileSync(filePath, 'utf8').split(/\r?\n/).forEach((line) => {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/);
if (!match || process.env[match[1]]) return;
let value = match[2].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
});
};
const main = async () => {
const mode = process.argv.includes('--send') ? 'send' : 'dry-run';
loadEnvFile(process.env.REPO_STATUS_ENV_FILE || defaultEnvFile);
const status = await collectRepositoryStatus({ repoDir });
const payload = buildDiscordPayload(status);
if (mode === 'dry-run') {
process.stdout.write(`${JSON.stringify({ status, payload }, null, 2)}\n`);
return;
}
const webhookUrl = process.env.DISCORD_REPO_STATUS_WEBHOOK_URL || process.env.DISCORD_WEBHOOK_URL;
const result = await postDiscordStatus({ webhookUrl, payload });
process.stdout.write(`Discord repo-status sendt${result?.id ? ` som besked ${result.id}` : ''}.\n`);
};
main().catch((error) => {
process.stderr.write(`Repo-status fejlede: ${error.message}\n`);
process.exitCode = 1;
});
+4
View File
@@ -29,6 +29,10 @@ En ny tre-profils tilbudsgennemgang er gennemført på mobil: komplet B7-tagskif
Den praktiske browserkontrol viste efter rettelserne 1/4 trin på et nyt projekt, gemt fladtagsgeometri på 0,26 m/48,18 m², projektsøgning fra 20 til 1 resultat og en tydelig blokering øverst på det historiske B7-tilbud. Historiske projekter med materialelinjer uden materiale-id er fortsat med vilje blokeret; de skal relinkes til rigtige materialer eller have en verificeret Smart Pakke anvendt igen og må ikke auto-matches ved gæt. Se [Tre tømrerprofiler: tilbudsgennemgang 18. august 2026](docs/status-reports/THREE_CARPENTER_OFFER_REVIEW_2026-08-18.md).
### Daglig Discord repo-status — 18. august 2026
Repository, GitHub og lokal drift rapporteres automatisk til Discord hver morgen kl. 08:30 dansk tid. Statuskortet viser branch, seneste commit, lokale ændringer, upstream-afvigelse, åbne PR’er/issues, PR-checks og `/api/health`. En rigtig webhooktest blev leveret til Discord, og secret-konfigurationen ligger i den eksterne data-mappe med rettighed `600`. Se [Daglig repository-status på Discord](docs/guides/DAILY_DISCORD_REPO_STATUS.md).
Ved hver fremtidig kontrol opdateres tiderne, enhed/skærmstørrelse, antal dobbeltindtastninger, største flaskehals og resultatet `BESTÅET` eller `IKKE BESTÅET`. Hvis et P2/P3-punkt viser sig at blokere feltflowet, flyttes det straks til P0 eller P1.
### Fast regel for smartpakker