feat: pro-rata berdasarkan time span commit bukan jumlah commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sas.fajri
2026-05-28 10:22:37 +07:00
parent c9bf004a4f
commit d2e9641739

View File

@@ -3,8 +3,11 @@
Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata, Ambil semua commit hari ini dari semua repo, distribusikan 8 jam pro-rata,
lalu upload ke Odoo timesheet. lalu upload ke Odoo timesheet.
Pro-rata: bobot = jumlah commit per task. Total = 8 jam. Pro-rata: bobot = time span per task (last commit - first commit).
Contoh: Task A 3 commit + Task B 1 commit → A=6h, B=2h. Single commit mendapat minimum 30 menit.
Contoh: Task A span 2 jam + Task B span 1.5 jam + Task C 1 commit →
bobot: 120 + 90 + 30 = 240 menit
Task A = 8 × 120/240 = 4.00h, Task B = 3.00h, Task C = 1.00h
""" """
import json import json
@@ -13,7 +16,7 @@ import subprocess
import urllib.request import urllib.request
import urllib.error import urllib.error
import argparse import argparse
from datetime import date, timedelta from datetime import date, datetime, timedelta
from collections import defaultdict from collections import defaultdict
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
@@ -153,7 +156,7 @@ def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]:
f"--after={today}", f"--after={today}",
f"--before={until}", f"--before={until}",
"--format=%h|%ad|%s", "--format=%h|%ad|%s",
"--date=short", "--date=format:%Y-%m-%d %H:%M",
] ]
try: try:
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True) out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True)
@@ -165,17 +168,34 @@ def get_commits_today(repo_path: str, author: str, today: str) -> list[dict]:
continue continue
parts = line.split("|", 2) parts = line.split("|", 2)
if len(parts) == 3: if len(parts) == 3:
commits.append({"hash": parts[0], "date": parts[1], "message": parts[2]}) dt = datetime.strptime(parts[1], "%Y-%m-%d %H:%M")
commits.append({
"hash": parts[0],
"date": dt.strftime("%Y-%m-%d"),
"time": dt.strftime("%H:%M"),
"dt": dt,
"message": parts[2],
})
return commits return commits
# ── Pro-rata ────────────────────────────────────────────────────────────────── # ── Pro-rata ──────────────────────────────────────────────────────────────────
SINGLE_COMMIT_MINUTES = 30 # bobot minimum untuk task dengan 1 commit
def span_minutes(timestamps: list[datetime]) -> int:
"""Menit antara commit pertama dan terakhir. Minimum 30 menit."""
if len(timestamps) <= 1:
return SINGLE_COMMIT_MINUTES
return max(
int((max(timestamps) - min(timestamps)).total_seconds() / 60),
SINGLE_COMMIT_MINUTES,
)
def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]: def distribute_hours(weights: list[int], total: float = 8.0) -> list[float]:
""" """Pro-rata berdasarkan bobot (menit span). Sum = total. Adjustment di entry terakhir."""
Distribusi jam pro-rata berdasarkan bobot (jumlah commit).
Adjustment di entry terakhir supaya sum persis = total.
"""
total_weight = sum(weights) total_weight = sum(weights)
raw = [Decimal(str(total)) * Decimal(w) / Decimal(total_weight) for w in weights] raw = [Decimal(str(total)) * Decimal(w) / Decimal(total_weight) for w in weights]
rounded = [float(r.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) for r in raw] rounded = [float(r.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) for r in raw]
@@ -221,6 +241,8 @@ def main():
"code": m.group(1), "code": m.group(1),
"description": m.group(2), "description": m.group(2),
"date": c["date"], "date": c["date"],
"time": c["time"],
"dt": c["dt"],
"hash": c["hash"], "hash": c["hash"],
"repo": repo_name, "repo": repo_name,
"project": repo["project"], "project": repo["project"],
@@ -238,9 +260,11 @@ def main():
task_cache = {} # (code, project_id) → (task_id, task_name) | None task_cache = {} # (code, project_id) → (task_id, task_name) | None
# Group: key = (task_id, project_id), value = list of commit descriptions & count # Group: key = (task_id, project_id)
groups = defaultdict(lambda: {"descriptions": [], "count": 0, "project_id": None, groups = defaultdict(lambda: {
"project": None, "task_name": None, "date": None}) "descriptions": [], "timestamps": [], "count": 0,
"project_id": None, "project": None, "task_name": None, "date": None,
})
not_found = [] not_found = []
for c in raw_commits: for c in raw_commits:
@@ -257,6 +281,7 @@ def main():
task_id, task_name = task task_id, task_name = task
key = (task_id, c["project_id"]) key = (task_id, c["project_id"])
groups[key]["descriptions"].append(c["description"]) groups[key]["descriptions"].append(c["description"])
groups[key]["timestamps"].append(c["dt"])
groups[key]["count"] += 1 groups[key]["count"] += 1
groups[key]["project_id"] = c["project_id"] groups[key]["project_id"] = c["project_id"]
groups[key]["project"] = c["project"] groups[key]["project"] = c["project"]
@@ -268,22 +293,27 @@ def main():
print("\nTidak ada task yang ditemukan di Odoo.") print("\nTidak ada task yang ditemukan di Odoo.")
return return
# 3. Hitung pro-rata jam # 3. Hitung pro-rata berdasarkan time span
keys = list(groups.keys()) keys = list(groups.keys())
weights = [groups[k]["count"] for k in keys] spans = [span_minutes(groups[k]["timestamps"]) for k in keys]
hours = distribute_hours(weights, total=float(TOTAL_HOURS)) hours = distribute_hours(spans, total=float(TOTAL_HOURS))
# 4. Tampilkan preview # 4. Tampilkan preview
print(f"\n{'' * 65}") print(f"\n{'' * 75}")
print(f" {'PROJECT':<20} {'TASK':<10} {'JAM':>5} {'COMMIT':>6} DESKRIPSI") print(f" {'PROJECT':<20} {'TASK':<10} {'WAKTU':<13} {'SPAN':>6} {'JAM':>5} DESKRIPSI")
print(f"{'' * 65}") print(f"{'' * 75}")
entries = [] entries = []
for key, h in zip(keys, hours): for key, h, span in zip(keys, hours, spans):
g = groups[key] g = groups[key]
desc = g["descriptions"][0] if len(g["descriptions"]) == 1 else \ ts = sorted(g["timestamps"])
"; ".join(dict.fromkeys(g["descriptions"])) # deduplicate, preserve order if len(ts) == 1:
print(f" {g['project']:<20} {str(g['task_id']):<10} {h:>5.2f} {g['count']:>6}x {desc[:35]}") waktu = ts[0].strftime("%H:%M") + " (1 commit)"
else:
waktu = f"{ts[0].strftime('%H:%M')}{ts[-1].strftime('%H:%M')}"
span_label = f"{span}m" if span > SINGLE_COMMIT_MINUTES else "30m*"
desc = "; ".join(dict.fromkeys(g["descriptions"]))
print(f" {g['project']:<20} {str(g['task_id']):<10} {waktu:<13} {span_label:>6} {h:>5.2f} {desc[:30]}")
entries.append({ entries.append({
"odoo": { "odoo": {
"name": desc, "name": desc,
@@ -297,8 +327,10 @@ def main():
"display": g, "display": g,
}) })
print(f"{'' * 65}") print(f"{'' * 75}")
print(f" {'TOTAL':<20} {'':<10} {sum(hours):>5.2f} {sum(weights):>6}x") total_span = sum(spans)
print(f" {'TOTAL':<20} {'':<10} {'':<13} {total_span:>5}m {sum(hours):>5.2f}")
print(f" * single commit → minimum {SINGLE_COMMIT_MINUTES} menit")
if not_found: if not_found:
print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)") print(f"\n {len(not_found)} commit diabaikan (task tidak ditemukan di Odoo)")