Aufstellung, wann was entstanden ist und was es gekostet hat - zur Nachvollziehbarkeit und zur Kostenrechnung. Die reine Commit-Rechnung taugt hier nur bedingt: Die Etappen 2 bis 7 sind parallel entstanden, erst drei, dann fuenf Agenten gleichzeitig. Aus der Historie sieht es aus, als haette Etappe 3 dreissig Minuten gedauert - tatsaechlich lief sie knapp eine Stunde und gleichzeitig mit vier anderen. Deshalb steht neben der automatischen Auswertung eine Tabelle mit den tatsaechlichen Laufzeiten und den Ausgabe-Token je Agent; daran bemessen sich die Kosten. Ebenfalls festgehalten: die gut drei Stunden erzwungener Pause am Sitzungslimit, die in der kalendarischen Spanne stecken, aber keine Bauzeit sind. Reine Bauzeit rund zwei Stunden 45 Minuten. Und die sechs Fehler, die waehrend des Baus aufgefallen sind - von der Server/Client-Grenze bis zur Doppelzaehlung bei den Heimkosten. Die stehen dort, weil sie zeigen, wo bei dieser Art Anwendung die Fallstricke liegen. Etappe: 8 Phase: Haertung
171 lines
5.6 KiB
JavaScript
171 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Bauprotokoll aus der Git-Historie erzeugen.
|
|
*
|
|
* Aufruf: npm run protokoll -> Tabelle auf der Konsole
|
|
* npm run protokoll -- --md -> Markdown fuer docs/BAUPROTOKOLL.md
|
|
*
|
|
* Grundlage sind die Commit-Trailer "Etappe:" und "Phase:". Jeder Commit
|
|
* traegt sie, damit sich im Nachhinein nachvollziehen laesst, wie lange
|
|
* welche Etappe gedauert hat - fuer die Kostenrechnung und als Nachweis,
|
|
* was wann entstanden ist.
|
|
*
|
|
* Die Dauer einer Etappe wird gemessen vom Ende der vorherigen Etappe
|
|
* (bzw. vom Baubeginn) bis zum letzten Commit der Etappe. Pausen zwischen
|
|
* zwei Commits, die laenger als PAUSE_SCHWELLE_MIN dauern, werden als
|
|
* Pause gewertet und nicht als Arbeitszeit gezaehlt.
|
|
*/
|
|
import { execSync } from 'node:child_process'
|
|
|
|
/** Laengere Luecken gelten als Pause und zaehlen nicht als Arbeitszeit. */
|
|
const PAUSE_SCHWELLE_MIN = 30
|
|
|
|
const TRENNER = '' // ASCII Unit Separator - kommt in Commit-Texten nicht vor
|
|
const SATZ_TRENNER = '' // Record Separator
|
|
|
|
function commitsLesen() {
|
|
const roh = execSync(
|
|
`git log --reverse --format="%H${TRENNER}%at${TRENNER}%s${TRENNER}%b${SATZ_TRENNER}"`,
|
|
{ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
|
|
)
|
|
|
|
return roh
|
|
.split(SATZ_TRENNER)
|
|
.map((eintrag) => eintrag.trim())
|
|
.filter(Boolean)
|
|
.map((eintrag) => {
|
|
const [hash, zeitstempel, betreff, koerper = ''] = eintrag.split(TRENNER)
|
|
const etappe = koerper.match(/^Etappe:\s*(.+)$/m)?.[1]?.trim() ?? 'ohne'
|
|
const phase = koerper.match(/^Phase:\s*(.+)$/m)?.[1]?.trim() ?? ''
|
|
return {
|
|
hash: hash.slice(0, 8),
|
|
zeit: new Date(Number(zeitstempel) * 1000),
|
|
betreff,
|
|
etappe,
|
|
phase,
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Arbeitszeit als Summe der Abstaende zwischen aufeinanderfolgenden
|
|
* Commits, wobei zu grosse Abstaende auf die Pausenschwelle gedeckelt
|
|
* werden. Der allererste Commit bekommt die Schwelle als Anlaufzeit
|
|
* gutgeschrieben - vorher wurde ja auch schon gearbeitet.
|
|
*/
|
|
function arbeitsminuten(commits) {
|
|
if (commits.length === 0) return 0
|
|
let summe = PAUSE_SCHWELLE_MIN
|
|
for (let i = 1; i < commits.length; i++) {
|
|
const diff = (commits[i].zeit - commits[i - 1].zeit) / 60000
|
|
summe += Math.min(diff, PAUSE_SCHWELLE_MIN)
|
|
}
|
|
return summe
|
|
}
|
|
|
|
function dauerText(minuten) {
|
|
const m = Math.round(minuten)
|
|
const std = Math.floor(m / 60)
|
|
const rest = m % 60
|
|
if (std === 0) return `${rest} min`
|
|
return `${std} h ${String(rest).padStart(2, '0')} min`
|
|
}
|
|
|
|
function formatZeit(datum) {
|
|
return datum.toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
const commits = commitsLesen()
|
|
if (commits.length === 0) {
|
|
console.error('Keine Commits gefunden.')
|
|
process.exit(1)
|
|
}
|
|
|
|
// Nach Etappe gruppieren, Reihenfolge des ersten Auftretens beibehalten.
|
|
const gruppen = new Map()
|
|
for (const commit of commits) {
|
|
if (!gruppen.has(commit.etappe)) gruppen.set(commit.etappe, [])
|
|
gruppen.get(commit.etappe).push(commit)
|
|
}
|
|
|
|
const zeilen = [...gruppen.entries()].map(([etappe, eintraege]) => ({
|
|
etappe,
|
|
phase: eintraege.find((e) => e.phase)?.phase ?? '',
|
|
commits: eintraege.length,
|
|
von: eintraege[0].zeit,
|
|
bis: eintraege[eintraege.length - 1].zeit,
|
|
minuten: arbeitsminuten(eintraege),
|
|
}))
|
|
|
|
const gesamtMinuten = zeilen.reduce((s, z) => s + z.minuten, 0)
|
|
const gesamtCommits = commits.length
|
|
const spanneMinuten = (commits[commits.length - 1].zeit - commits[0].zeit) / 60000
|
|
|
|
const alsMarkdown = process.argv.includes('--md')
|
|
|
|
if (alsMarkdown) {
|
|
const out = []
|
|
out.push('# Bauprotokoll')
|
|
out.push('')
|
|
out.push('Automatisch aus der Git-Historie erzeugt: `npm run protokoll -- --md`.')
|
|
out.push('Grundlage sind die Commit-Trailer `Etappe:` und `Phase:`.')
|
|
out.push('')
|
|
out.push(
|
|
`Reine Arbeitszeit wird als Summe der Commit-Abstaende gemessen, gedeckelt auf ` +
|
|
`${PAUSE_SCHWELLE_MIN} Minuten je Abstand. Laengere Luecken gelten als Pause.`,
|
|
)
|
|
out.push('')
|
|
out.push('| Etappe | Phase | Commits | Beginn | Ende | Arbeitszeit |')
|
|
out.push('|---|---|---:|---|---|---:|')
|
|
for (const z of zeilen) {
|
|
out.push(
|
|
`| ${z.etappe} | ${z.phase} | ${z.commits} | ${formatZeit(z.von)} | ` +
|
|
`${formatZeit(z.bis)} | ${dauerText(z.minuten)} |`,
|
|
)
|
|
}
|
|
out.push(
|
|
`| **Gesamt** | | **${gesamtCommits}** | ${formatZeit(commits[0].zeit)} | ` +
|
|
`${formatZeit(commits[commits.length - 1].zeit)} | **${dauerText(gesamtMinuten)}** |`,
|
|
)
|
|
out.push('')
|
|
out.push(`Kalendarische Spanne vom ersten bis zum letzten Commit: ${dauerText(spanneMinuten)}.`)
|
|
out.push('')
|
|
out.push('## Commits im Einzelnen')
|
|
out.push('')
|
|
out.push('| Zeitpunkt | Etappe | Commit | Betreff |')
|
|
out.push('|---|---|---|---|')
|
|
for (const c of commits) {
|
|
out.push(`| ${formatZeit(c.zeit)} | ${c.etappe} | \`${c.hash}\` | ${c.betreff} |`)
|
|
}
|
|
console.log(out.join('\n'))
|
|
} else {
|
|
console.log('')
|
|
console.log('Bauprotokoll')
|
|
console.log('='.repeat(74))
|
|
console.log(
|
|
'Hinweis: gemessen werden Commit-Abstaende. Fuer parallel gebaute
|
|
' +
|
|
'Etappen ist das zu wenig - siehe docs/BAUPROTOKOLL.md.',
|
|
)
|
|
console.log('-'.repeat(74))
|
|
for (const z of zeilen) {
|
|
console.log(
|
|
`Etappe ${z.etappe.padEnd(4)} ${z.phase.padEnd(16)} ` +
|
|
`${String(z.commits).padStart(3)} Commits ${dauerText(z.minuten).padStart(10)}`,
|
|
)
|
|
}
|
|
console.log('-'.repeat(74))
|
|
console.log(
|
|
`Gesamt${' '.repeat(17)}${String(gesamtCommits).padStart(3)} Commits ` +
|
|
`${dauerText(gesamtMinuten).padStart(10)}`,
|
|
)
|
|
console.log(`Kalendarische Spanne: ${dauerText(spanneMinuten)}`)
|
|
console.log('')
|
|
}
|