diff --git a/backend/__tests__/repositoryStatusDiscordService.test.js b/backend/__tests__/repositoryStatusDiscordService.test.js new file mode 100644 index 0000000..1b18aaf --- /dev/null +++ b/backend/__tests__/repositoryStatusDiscordService.test.js @@ -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('git@github.com: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); + }); +}); diff --git a/backend/src/services/repositoryStatusDiscordService.js b/backend/src/services/repositoryStatusDiscordService.js new file mode 100644 index 0000000..ce35f60 --- /dev/null +++ b/backend/src/services/repositoryStatusDiscordService.js @@ -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 +}; diff --git a/docs/guides/DAILY_DISCORD_REPO_STATUS.md b/docs/guides/DAILY_DISCORD_REPO_STATUS.md new file mode 100644 index 0000000..e6209ce --- /dev/null +++ b/docs/guides/DAILY_DISCORD_REPO_STATUS.md @@ -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. diff --git a/obsidian-vault/00 Start/Aktiv dokumentation.md b/obsidian-vault/00 Start/Aktiv dokumentation.md index 520d506..0ae220f 100644 --- a/obsidian-vault/00 Start/Aktiv dokumentation.md +++ b/obsidian-vault/00 Start/Aktiv dokumentation.md @@ -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.** diff --git a/obsidian-vault/00 Start/Dokumentindeks.md b/obsidian-vault/00 Start/Dokumentindeks.md index 831671b..d8b617d 100644 --- a/obsidian-vault/00 Start/Dokumentindeks.md +++ b/obsidian-vault/00 Start/Dokumentindeks.md @@ -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.** diff --git a/obsidian-vault/00 Start/Kildemanifest.tsv b/obsidian-vault/00 Start/Kildemanifest.tsv index f50f298..796785f 100644 --- a/obsidian-vault/00 Start/Kildemanifest.tsv +++ b/obsidian-vault/00 Start/Kildemanifest.tsv @@ -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 diff --git a/obsidian-vault/10 Dokumenter/Status/Rapporter/status.md b/obsidian-vault/10 Dokumenter/Status/Rapporter/status.md index d9082fb..73d1c06 100644 --- a/obsidian-vault/10 Dokumenter/Status/Rapporter/status.md +++ b/obsidian-vault/10 Dokumenter/Status/Rapporter/status.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 diff --git a/obsidian-vault/10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS.md b/obsidian-vault/10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS.md new file mode 100644 index 0000000..e6209ce --- /dev/null +++ b/obsidian-vault/10 Dokumenter/Vejledninger/DAILY_DISCORD_REPO_STATUS.md @@ -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. diff --git a/package.json b/package.json index 239e186..b26efc5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/send-daily-repo-status.js b/scripts/send-daily-repo-status.js new file mode 100755 index 0000000..b40bad5 --- /dev/null +++ b/scripts/send-daily-repo-status.js @@ -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; +}); diff --git a/status.md b/status.md index d9082fb..73d1c06 100644 --- a/status.md +++ b/status.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