From e409c11d5cad258a0d7c5e1aa8c183aefb2339e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:16:03 +0000 Subject: [PATCH] =?UTF-8?q?Push-Historie=20aus=20den=20Gitea-Aktivit=C3=A4?= =?UTF-8?q?ten=20lesen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/pushHistory.js | 87 ++++++++++++++++++++++++ test/pushHistory.test.js | 120 +++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 server/services/pushHistory.js create mode 100644 test/pushHistory.test.js diff --git a/server/services/pushHistory.js b/server/services/pushHistory.js new file mode 100644 index 0000000..cba3d59 --- /dev/null +++ b/server/services/pushHistory.js @@ -0,0 +1,87 @@ +import { config } from '../config.js' +import { giteaFetch, splitRepo } from './giteaClient.js' + +const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * Baut aus Gitea-1.25-Aktivitaeten-Feed-Eintraegen (`GET .../activities/feeds`) Push-Objekte + * im selben Zuschnitt wie der Push-Webhook (siehe `server/routes/webhook/gitea.js` und + * `createGitPushWorksheet`). Nur `op_type === 'commit_repo'`-Eintraege ab `since` mit + * mindestens einem Commit zaehlen. Der erste Push auf einen neuen Branch (Eintrag mit leerem + * `content`) und andere Aktivitaeten (z. B. `create_repo`) werden uebersprungen; ein nicht + * parsbarer `content` ebenso. Ergebnis aufsteigend nach `pushedAt` sortiert (aelteste + * zuerst), unabhaengig von der Reihenfolge im Feed. + * + * @param {object[]} feed Feed-Eintraege wie von Gitea geliefert. + * @param {{since: Date}} options + * @returns {Array<{branch: string, commitSha: string, pushedAt: Date, + * pusher: {login: string, username: string, full_name: string}, + * commits: Array<{id: string, message: string, author: {name: string}}>}>} + */ +export function pushesFromFeed(feed, { since } = {}) { + const pushes = [] + + for (const entry of feed || []) { + if (entry?.op_type !== 'commit_repo') continue + if (!(new Date(entry.created) >= since)) continue + + let parsed + try { + parsed = JSON.parse(entry.content) + } catch { + continue + } + + const rawCommits = parsed?.Commits + if (!Array.isArray(rawCommits) || rawCommits.length === 0) continue + + const actUser = entry.act_user || {} + + pushes.push({ + branch: String(entry.ref_name || '').replace('refs/heads/', ''), + commitSha: parsed.HeadCommit?.Sha1 || rawCommits[0].Sha1, + pushedAt: new Date(entry.created), + pusher: { + login: actUser.login, + username: actUser.login, + full_name: actUser.full_name || '', + }, + commits: rawCommits.map((c) => ({ + id: c.Sha1, + message: c.Message, + author: { name: c.AuthorName }, + })), + }) + } + + return pushes.sort((a, b) => a.pushedAt - b.pushedAt) +} + +/** + * Liest die Push-Historie eines Repos der letzten `days` Tage aus dem Gitea-Aktivitaeten-Feed + * (`GET /repos/{owner}/{repo}/activities/feeds`, seitenweise mit 50 pro Seite, neueste zuerst). + * Blaettert nur so weit wie noetig: stoppt bei einer leeren Seite, einer Seite mit weniger als + * 50 Eintraegen, oder sobald der aelteste Eintrag einer Seite vor `since` liegt. + * + * @param {string} repoFullName "owner/repo" + * @param {{days?: number, now?: Date}} [options] + * @returns {Promise>} + */ +export async function listRecentPushes(repoFullName, { days = config.gitea.backfillDays, now = new Date() } = {}) { + const [owner, repo] = splitRepo(repoFullName) + const since = new Date(now - days * DAY_MS) + const limit = 50 + const collected = [] + let page = 1 + + while (true) { + const items = await giteaFetch(`/repos/${owner}/${repo}/activities/feeds?limit=${limit}&page=${page}`) + if (!Array.isArray(items) || items.length === 0) break + collected.push(...items) + if (items.length < limit) break + if (new Date(items[items.length - 1].created) < since) break + page += 1 + } + + return pushesFromFeed(collected, { since }) +} diff --git a/test/pushHistory.test.js b/test/pushHistory.test.js new file mode 100644 index 0000000..cda78cf --- /dev/null +++ b/test/pushHistory.test.js @@ -0,0 +1,120 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeGitea } from './helpers/fakeGitea.js' + +let fake +let pushesFromFeed +let listRecentPushes + +const now = new Date('2026-09-27T12:00:00Z') + +let nextFeedId = 1 + +/** + * Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach: `{ op_type, act_user, + * ref_name, created, content }`, wobei `content` das JSON `{Commits:[...], HeadCommit:{...}}` + * als String enthaelt (siehe Task-Brief). + * + * Optionen: `sha` (ein Commit) oder `commits` (mehrere Shas, in Feed-Reihenfolge), `head` + * (HeadCommit-Sha; `null` fuer "kein HeadCommit"; Voreinstellung: erster Commit), `at` + * (created/Timestamp, ISO-String), `user` (act_user.login), `fullName` (act_user.full_name), + * `msg` (Commit-Message, sonst generisch). + */ +function pushEntry({ sha, commits, head, at, user, fullName, msg } = {}) { + const shas = commits || [sha ?? 'c0'] + const login = user || 'JUSN' + const created = at || '2026-09-27T00:00:00Z' + + const builtCommits = shas.map((s) => ({ + Sha1: s, + Message: msg || `Commit ${s}`, + AuthorEmail: `${login.toLowerCase()}@example.com`, + AuthorName: login, + CommitterEmail: `${login.toLowerCase()}@example.com`, + CommitterName: login, + Timestamp: created, + })) + const headSha = head === undefined ? shas[0] : head + + return { + id: nextFeedId++, + op_type: 'commit_repo', + act_user: { login, full_name: fullName || '' }, + ref_name: 'refs/heads/main', + created, + content: JSON.stringify({ + Commits: builtCommits, + HeadCommit: headSha ? { Sha1: headSha } : null, + CompareURL: '', + Len: builtCommits.length, + }), + } +} + +/** Push-Feed-Eintrag `days` Tage vor `base`, ein Commit mit Sha `sha`. */ +function daysAgo(base, days, sha) { + return pushEntry({ sha, at: new Date(base.getTime() - days * 864e5).toISOString() }) +} + +/** 120 Eintraege, 3 pro Tag ueber 40 Tage, neueste zuerst (fuer die Blaetter-Grenze). */ +function manyPushes(base) { + const entries = [] + for (let d = 0; d < 40; d++) { + for (let k = 0; k < 3; k++) { + const at = new Date(base.getTime() - d * 864e5 - (1 + 2 * k) * 36e5).toISOString() + entries.push(pushEntry({ sha: `d${d}k${k}`, at })) + } + } + return entries +} + +before(async () => { + fake = await startFakeGitea({ + repos: [{ full_name: 'knso/viel' }], + feeds: { 'knso/viel': manyPushes(now) }, + }) + Object.assign(process.env, { + GITEA_BASE_URL: fake.baseUrl, + GITEA_API_TOKEN: 'test-token', + }) + // config.js liest die Umgebung beim Import + ;({ pushesFromFeed, listRecentPushes } = await import('../server/services/pushHistory.js')) +}) + +after(async () => { + await fake.close() +}) + +test('nur commit_repo mit Commits', () => { + const feed = [pushEntry({ sha: 'c1', at: '2026-09-27T14:46:34Z' }), { ...pushEntry({ at: '2026-09-27T14:46:33Z' }), content: '' }, { op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } }] + assert.equal(pushesFromFeed(feed, { since: new Date('2026-09-01') }).length, 1) +}) +test('Felder wie beim Webhook', () => { + const [p] = pushesFromFeed([pushEntry({ sha: 'c1', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Döner Nation Website hinzufügen' })], { since: new Date('2026-09-01') }) + assert.deepEqual(p, { branch: 'main', commitSha: 'c1', pushedAt: new Date('2026-09-27T14:46:34Z'), + pusher: { login: 'JUSN', username: 'JUSN', full_name: '' }, + commits: [{ id: 'c1', message: 'Döner Nation Website hinzufügen', author: { name: 'JUSN' } }] }) +}) +test('HeadCommit bestimmt commitSha', () => { + const [p] = pushesFromFeed([pushEntry({ commits: ['c2', 'c1'], head: 'c2' })], { since: new Date(0) }) + assert.equal(p.commitSha, 'c2'); assert.equal(p.commits.length, 2) +}) +test('ohne HeadCommit gilt der erste Commit', () => { + const [p] = pushesFromFeed([pushEntry({ commits: ['c2', 'c1'], head: null })], { since: new Date(0) }) + assert.equal(p.commitSha, 'c2') +}) +test('Anzeigename wird übernommen', () => { + const [p] = pushesFromFeed([pushEntry({ user: 'knso', fullName: 'Kenso Grimm' })], { since: new Date(0) }) + assert.equal(p.pusher.full_name, 'Kenso Grimm') +}) +test('älteste zuerst und 30-Tage-Grenze', () => { + const now = new Date('2026-09-27T12:00:00Z') + const feed = [daysAgo(now, 1, 'n1'), daysAgo(now, 2, 'n2'), daysAgo(now, 31, 'alt')] + assert.deepEqual(pushesFromFeed(feed, { since: new Date(now - 30 * 864e5) }).map((p) => p.commitSha), ['n2', 'n1']) +}) +test('listRecentPushes blättert nur so weit wie nötig', async () => { + // 120 Einträge, 3 pro Tag über 40 Tage, neueste zuerst; limit 50 + const r = await listRecentPushes('knso/viel', { now }) + assert.equal(r.length, 90) + assert.equal(fake.calls.filter((c) => c.path.includes('/activities/feeds')).length, 2) +})