Add ivona and timesheet plugins, MIT LICENSE, and invoice-prep-test
Adds ivona (invoice-checker, timesheet-checker) and timesheet (check-my-timesheet, invoice-prep) as new plugins, plus invoice-prep-test — a one-off experiment that requests the Tempo API token via plugin userConfig at install time instead of a .env file, to check whether that value actually reaches the skill's script. Also adds the root LICENSE (MIT) referenced from the README.
This commit is contained in:
parent
4e11c144c7
commit
576a14c82f
32 changed files with 4804 additions and 8 deletions
|
|
@ -11,6 +11,18 @@
|
|||
"description": "Rewrite and humanize text to remove AI writing patterns and match your own voice.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "invoice-prep-test",
|
||||
"source": "./plugins/invoice-prep-test",
|
||||
"description": "TEST: copy of invoice-prep that asks for the Tempo API token via plugin userConfig at install time.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "ivona",
|
||||
"source": "./plugins/ivona",
|
||||
"description": "Check KSeF invoices against contractors/white-list and audit team timesheet completion via Tempo.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "hr-manager",
|
||||
"source": "./plugins/hr-manager",
|
||||
|
|
@ -46,6 +58,12 @@
|
|||
"source": "./plugins/resolve-todos",
|
||||
"description": "Find and resolve TODO items in a document, asking for input when instructions are unclear.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "timesheet",
|
||||
"source": "./plugins/timesheet",
|
||||
"description": "Check and log your own Tempo timesheet, and see what to put on your invoice for a given month.",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.claude
|
||||
.env
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Michal Kopec
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
39
README.md
39
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# Skills Marketplace
|
||||
|
||||
A Claude Code plugin marketplace bundling personal skills for interviews, meetings,
|
||||
and writing. Each skill is packaged as its own installable plugin.
|
||||
writing, and invoice/timesheet checks. Each skill is packaged as its own installable plugin.
|
||||
|
||||
## Plugins
|
||||
|
||||
|
|
@ -10,10 +10,13 @@ and writing. Each skill is packaged as its own installable plugin.
|
|||
| `ghost-writer` | Rewrite and humanize text to remove AI writing patterns and match your own voice. |
|
||||
| `hr-manager` | Run mock job interviews and produce a scored assessment with a prioritised learning plan. |
|
||||
| `interview` | Run a structured interview and save the captured knowledge to a markdown file. |
|
||||
| `invoice-prep-test` | **Test plugin.** Copy of `invoice-prep` that asks for the Tempo API token via plugin `userConfig` at install time instead of `.env`. |
|
||||
| `ivona` | Check KSeF invoices against contractors/white-list and audit team timesheet completion via Tempo. |
|
||||
| `meeting-notes` | Interview a participant about a meeting and produce structured meeting notes. |
|
||||
| `meeting-prep` | Generate a prioritised list of questions for an upcoming meeting based on wiki knowledge gaps. |
|
||||
| `quiz` | Quiz the user on knowledge captured in the wiki, with scoring and immediate feedback. |
|
||||
| `resolve-todos` | Find and resolve TODO items in a document, asking for input when instructions are unclear. |
|
||||
| `timesheet` | Check and log your own Tempo timesheet, and see what to put on your invoice for a given month. |
|
||||
|
||||
## Installing
|
||||
|
||||
|
|
@ -24,16 +27,36 @@ Add this marketplace locally and install a plugin:
|
|||
/plugin install ghost-writer@skills-marketplace
|
||||
```
|
||||
|
||||
Once published to a Git host, others can add it with:
|
||||
This marketplace is published at `git.wierzbowa.cloud`. Since that's not GitHub, the
|
||||
bare `owner/repo` shorthand won't resolve — use the full clone URL instead:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add <owner>/<repo>
|
||||
/plugin marketplace add https://git.wierzbowa.cloud/Cloud_Drift/skills-marketplace.git
|
||||
```
|
||||
|
||||
## Credential setup — under test
|
||||
|
||||
Most plugins that call an external API (Tempo, Jira, KSeF) currently read credentials
|
||||
from a `.env` file the user creates from `.env.example`. `invoice-prep-test` is a
|
||||
one-off experiment to see whether Claude Code's plugin `userConfig` mechanism — which
|
||||
prompts for a value at install/enable time and can mark it `sensitive` — actually
|
||||
reaches a skill's script as an environment variable
|
||||
(`CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN`) when that script runs via the Bash tool.
|
||||
|
||||
To test:
|
||||
```bash
|
||||
/plugin install invoice-prep-test@skills-marketplace
|
||||
```
|
||||
You should be prompted for a "Tempo API Token" during install. Then ask Claude
|
||||
something like "what should I put on my invoice for last month" and check the
|
||||
script's printed "Tempo token source" line — it reports whether it used the
|
||||
plugin-provided value or fell back to `.env`/plain env. Once we know which
|
||||
mechanism works, we'll standardize the rest of the credential-needing plugins
|
||||
(`ivona`, `timesheet`) on it.
|
||||
|
||||
## Licensing
|
||||
|
||||
- All plugins (`ghost-writer`, `hr-manager`, `interview`, `meeting-notes`,
|
||||
`meeting-prep`, `quiz`, `resolve-todos`) are original work.
|
||||
- Anthropic's built-in `docx`, `pdf`, `pptx`, and `xlsx` skills are **not** included:
|
||||
their license prohibits extracting, copying, or redistributing them outside
|
||||
Claude's Services.
|
||||
All plugins (`ghost-writer`, `hr-manager`, `interview`, `ivona`, `meeting-notes`,
|
||||
`meeting-prep`, `quiz`, `resolve-todos`, `timesheet`) are licensed under the MIT
|
||||
License — see [LICENSE](LICENSE).
|
||||
|
||||
|
|
|
|||
13
plugins/invoice-prep-test/.claude-plugin/plugin.json
Normal file
13
plugins/invoice-prep-test/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "invoice-prep-test",
|
||||
"version": "1.0.0",
|
||||
"description": "TEST plugin: copy of invoice-prep that asks for the Tempo API token via plugin userConfig at install time, instead of a .env file.",
|
||||
"userConfig": {
|
||||
"tempo_api_token": {
|
||||
"type": "string",
|
||||
"title": "Tempo API Token",
|
||||
"description": "Tempo read token, used to fetch your worklogs. Generate at Tempo → Settings → API Integration.",
|
||||
"sensitive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Invoice Prep (test) — credentials template
|
||||
# Copy this file to .env and fill in the values.
|
||||
# .env is gitignored and never committed.
|
||||
#
|
||||
# NOTE: the Tempo API token is intentionally NOT here for this test — it's
|
||||
# meant to be supplied via the plugin's install-time configuration prompt
|
||||
# (userConfig.tempo_api_token in plugin.json), so we can verify whether that
|
||||
# reaches this skill's script as CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN.
|
||||
|
||||
# Your Atlassian / Jira Cloud URL
|
||||
JIRA_BASE_URL=https://your-company.atlassian.net
|
||||
|
||||
# The email address you use to log into Jira
|
||||
JIRA_EMAIL=your.email@company.com
|
||||
|
||||
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
|
||||
JIRA_API_TOKEN=
|
||||
1
plugins/invoice-prep-test/skills/invoice-prep-test/.gitignore
vendored
Normal file
1
plugins/invoice-prep-test/skills/invoice-prep-test/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
94
plugins/invoice-prep-test/skills/invoice-prep-test/SKILL.md
Normal file
94
plugins/invoice-prep-test/skills/invoice-prep-test/SKILL.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
name: invoice-prep-test
|
||||
description: >
|
||||
TEST VERSION of invoice-prep — show what to put on an invoice for a selected
|
||||
month by checking the user's own Tempo timesheets. Lists every Jira project
|
||||
code and the total hours logged against it. Warns if there are missing hours
|
||||
or days with no logged time. This variant requests the Tempo API token via
|
||||
the plugin's install-time configuration instead of a .env file.
|
||||
TRIGGER when user asks what to put on an invoice, what to invoice, invoice
|
||||
preparation, timesheet summary for invoicing, or similar phrases.
|
||||
Trigger phrases: "what should I put on an invoice", "prepare my invoice",
|
||||
"invoice for [month]", "what to invoice for", "check my TS for invoice".
|
||||
---
|
||||
|
||||
# Invoice Preparation (test — plugin userConfig for Tempo token)
|
||||
|
||||
This is a test copy of the `invoice-prep` skill. It exists to check whether a
|
||||
credential declared in the plugin's `userConfig` (prompted for when the plugin
|
||||
is installed/enabled) actually reaches this skill's script as an environment
|
||||
variable — `CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN` — when the script is run via
|
||||
the Bash tool. Everything else about the skill is unchanged from `invoice-prep`.
|
||||
|
||||
Use this skill when someone wants to know what project codes and hours to put on their invoice for a given month. It reads their own Tempo worklogs, groups hours by Jira project, and warns about any missing time.
|
||||
|
||||
**In Claude Code, you run the pipeline automatically.** The user provides a month — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Variable | Purpose | Where it comes from |
|
||||
|---|---|---|
|
||||
| Tempo API Token | Fetch worklogs from Tempo | Prompted at plugin install/enable time (`userConfig.tempo_api_token`). Falls back to `TEMPO_API_TOKEN` in `.env` if the plugin-provided value isn't available. |
|
||||
| `JIRA_BASE_URL` | Resolve the user's account ID | `.env` file in the project root |
|
||||
| `JIRA_EMAIL` | Identifies whose timesheets to read | `.env` file in the project root |
|
||||
| `JIRA_API_TOKEN` | Authenticate with Jira | `.env` file in the project root |
|
||||
|
||||
Copy `.env.example` to `.env` in this skill folder (or the project root) and fill in the three Jira values. The Tempo token should NOT go in `.env` for this test — it's meant to come from the plugin's install-time prompt, so we can see whether that mechanism actually works.
|
||||
|
||||
The script prints which source it used for the Tempo token (`CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN` vs `.env` fallback) — check that line in the output to see the test result.
|
||||
|
||||
## Extracting the month
|
||||
|
||||
Convert natural language to `YYYY-MM` before running:
|
||||
|
||||
| User says | CLI value |
|
||||
|---|---|
|
||||
| "last month" | Previous calendar month |
|
||||
| "this month" | Current calendar month |
|
||||
| "June", "June 2026" | `2026-06` |
|
||||
| "May" | `2026-05` (current year) |
|
||||
|
||||
If no month is specified, ask: "Which month should I check? (e.g. last month, June 2026)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 — Run the script:**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/run.py \
|
||||
--month {YYYY-MM} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This resolves the user's Jira account, fetches their Tempo worklogs for the full calendar month, groups hours by Jira project code, checks completeness against the Polish working calendar, and writes `invoice_prep_{YYYY-MM}.json` to `./timesheet-output/`.
|
||||
|
||||
Report the printed "Tempo token source" line back to the user along with the results — that's the point of this test.
|
||||
|
||||
**Step 2 — Present results.** Read the output JSON and show the user:
|
||||
|
||||
1. A table of project codes and hours — formatted ready to copy onto an invoice.
|
||||
Include **every project in the `projects` array**. Do NOT label any project as "non-billable" or omit it from the table — the script already excludes ignored issues; everything remaining is billable:
|
||||
|
||||
```
|
||||
| Project | Hours |
|
||||
| IAA | 45.5 |
|
||||
| PROJ | 22.0 |
|
||||
```
|
||||
|
||||
Below the table, note ignored issues and their hours as a parenthetical: `(INTERNAL-1: 40h ignored per config.)` — do not mix them into the billable table.
|
||||
|
||||
2. The **total billable hours** from `total_billable_hours` (not `total_logged_hours`).
|
||||
|
||||
3. If `missing_hours > 0`: a clear warning showing:
|
||||
- How many hours are missing vs the expected total for the month
|
||||
- Which specific dates have no logged time (up to 10; if more, show count)
|
||||
- Reminder to complete the timesheet before issuing the invoice
|
||||
|
||||
4. If `total_logged_hours == 0`: warn that no hours were found at all for the month.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never fabricate or estimate hours. All data comes from Tempo.
|
||||
- If `JIRA_EMAIL` is not set, stop and tell the user.
|
||||
- If any script step fails, show the error and stop.
|
||||
- Always remind the user that incomplete timesheets will cause Jira compliance failures when the invoice is checked.
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate invoice line items from the user's own Tempo worklogs for a given month.
|
||||
|
||||
Groups hours by Jira project code and reports missing time vs the Polish
|
||||
working calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import calendar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Period helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_month(month_str: str) -> tuple[date, date]:
|
||||
"""Parse 'YYYY-MM' into (first_day, last_day) of that month."""
|
||||
s = month_str.strip()
|
||||
if not re.fullmatch(r"\d{4}-\d{2}", s):
|
||||
raise ValueError(f"Invalid month '{month_str}'. Use YYYY-MM.")
|
||||
year, month = int(s[:4]), int(s[5:7])
|
||||
first = date(year, month, 1)
|
||||
last = date(year, month, calendar.monthrange(year, month)[1])
|
||||
return first, last
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_public_holidays(country: str, year: int) -> set[date]:
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "invoice-prep/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return {date.fromisoformat(item["date"]) for item in json.loads(resp.read())}
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
|
||||
return set()
|
||||
|
||||
|
||||
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
result, cur = [], start
|
||||
while cur <= end:
|
||||
if cur.weekday() < 5 and cur not in holidays:
|
||||
result.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Jira account lookup ───────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
users = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira API returned 401. Check JIRA_EMAIL and JIRA_API_TOKEN.",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Jira API {exc.code}") from exc
|
||||
match = next(
|
||||
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
|
||||
None,
|
||||
)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
# ── Tempo worklogs ────────────────────────────────────────────────────────────
|
||||
|
||||
_PROJECT_CODE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})-\d+\b")
|
||||
|
||||
|
||||
def load_jira_ignore(path: Path) -> set[str]:
|
||||
"""Load issue keys to ignore from a markdown table in jira_ignore.md."""
|
||||
if not path.exists():
|
||||
return set()
|
||||
ignored: set[str] = set()
|
||||
in_table = False
|
||||
header_seen = False
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not (line.startswith("|") and line.endswith("|")):
|
||||
in_table = False
|
||||
header_seen = False
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
if not header_seen:
|
||||
# First row is header or separator
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
|
||||
continue # separator row
|
||||
header_seen = True
|
||||
in_table = True
|
||||
continue
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
|
||||
continue # separator row
|
||||
if in_table and cells and cells[0]:
|
||||
ignored.add(cells[0].strip().upper())
|
||||
return ignored
|
||||
|
||||
|
||||
def extract_project_code(issue_key: str) -> str | None:
|
||||
"""Extract project code from a Jira issue key, e.g. 'IAA' from 'IAA-42'."""
|
||||
m = _PROJECT_CODE_RE.match(issue_key.strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def fetch_worklogs(account_id: str, start: date, end: date,
|
||||
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
|
||||
"""Return all Tempo worklog entries for the account in [start, end]."""
|
||||
results: list[dict] = []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = (
|
||||
f"{base_url}/worklogs/user/{account_id}"
|
||||
f"?from={start}&to={end}&limit=1000"
|
||||
)
|
||||
while next_url:
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo API returned 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
|
||||
results.extend(data.get("results", []))
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
return results
|
||||
|
||||
|
||||
def resolve_issue_keys(worklogs: list[dict], jira_email: str,
|
||||
jira_token: str) -> dict[int, str]:
|
||||
"""
|
||||
Resolve Jira issue IDs → keys by calling each issue's self URL directly.
|
||||
Uses the self URL already present in each Tempo worklog entry.
|
||||
"""
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
seen: dict[int, str] = {}
|
||||
for wl in worklogs:
|
||||
issue = wl.get("issue", {})
|
||||
issue_id = issue.get("id")
|
||||
if not issue_id or int(issue_id) in seen:
|
||||
continue
|
||||
self_url = issue.get("self", "")
|
||||
if not self_url:
|
||||
continue
|
||||
# Swap api/2 → api/3 if needed; append fields filter
|
||||
url = re.sub(r"/rest/api/\d+/", "/rest/api/3/", self_url) + "?fields=key"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
seen[int(issue_id)] = data.get("key", "")
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not resolve issue {issue_id}: {exc}", file=sys.stderr)
|
||||
return seen
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
# TEST: tempo_api_token is requested via plugin.json userConfig at install
|
||||
# time. Claude Code is expected to inject it as CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN.
|
||||
# Fall back to a plain TEMPO_API_TOKEN (env/.env) so this still works if that
|
||||
# injection doesn't reach this script.
|
||||
plugin_option_token = os.environ.get("CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN", "")
|
||||
env_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
tempo_token = plugin_option_token or env_token
|
||||
if plugin_option_token:
|
||||
print("Tempo token source: CLAUDE_PLUGIN_OPTION_TEMPO_API_TOKEN (plugin userConfig)")
|
||||
elif env_token:
|
||||
print("Tempo token source: TEMPO_API_TOKEN (env/.env) — plugin userConfig var was not set")
|
||||
else:
|
||||
print("Tempo token source: none found")
|
||||
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [
|
||||
("TEMPO_API_TOKEN (via userConfig or env)", tempo_token), ("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token),
|
||||
] if not v]
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
start, end = parse_month(args.month)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load ignore list
|
||||
ignore_path = Path.cwd() / "jira_ignore.md"
|
||||
ignored_keys = load_jira_ignore(ignore_path)
|
||||
if ignored_keys:
|
||||
print(f"Ignoring issues: {', '.join(sorted(ignored_keys))}")
|
||||
|
||||
print(f"Month: {args.month} ({start} – {end})")
|
||||
print(f"User: {jira_email}")
|
||||
|
||||
# Resolve account ID
|
||||
print("Resolving Jira account...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
print(f" {jira_email} → {account_id}")
|
||||
|
||||
# Working days (Polish calendar)
|
||||
print("Fetching public holidays...")
|
||||
holidays = get_public_holidays("PL", start.year)
|
||||
wdays = working_days(start, end, holidays)
|
||||
expected_hours = len(wdays) * 8.0
|
||||
print(f" Working days: {len(wdays)} Expected hours: {expected_hours:.0f}h")
|
||||
|
||||
# Fetch Tempo worklogs
|
||||
print("Fetching Tempo worklogs...")
|
||||
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
|
||||
print(f" Worklogs fetched: {len(worklogs)}")
|
||||
|
||||
# Resolve issue IDs → keys via each issue's self URL (Tempo v4 omits the key)
|
||||
unique_ids = {int(wl["issue"]["id"]) for wl in worklogs if wl.get("issue", {}).get("id")}
|
||||
issue_key_map: dict[int, str] = {}
|
||||
if unique_ids:
|
||||
print(f" Resolving {len(unique_ids)} unique issue key(s) from Jira...")
|
||||
issue_key_map = resolve_issue_keys(worklogs, jira_email, jira_token)
|
||||
print(f" Resolved: {len(issue_key_map)}")
|
||||
|
||||
# Group hours by project code and by date, skipping ignored issues
|
||||
project_hours: dict[str, float] = {}
|
||||
hours_by_date: dict[str, float] = {}
|
||||
unmatched_hours = 0.0
|
||||
ignored_hours = 0.0
|
||||
|
||||
for wl in worklogs:
|
||||
issue_id = int(wl.get("issue", {}).get("id", 0))
|
||||
issue_key = issue_key_map.get(issue_id, "")
|
||||
hours = wl.get("timeSpentSeconds", 0) / 3600
|
||||
wl_date = wl.get("startDate", "")
|
||||
|
||||
if issue_key.upper() in ignored_keys:
|
||||
ignored_hours += hours
|
||||
# Still count toward date totals so missing-day detection stays accurate
|
||||
if wl_date:
|
||||
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
|
||||
continue
|
||||
|
||||
code = extract_project_code(issue_key)
|
||||
if code:
|
||||
project_hours[code] = project_hours.get(code, 0.0) + hours
|
||||
else:
|
||||
unmatched_hours += hours
|
||||
if wl_date:
|
||||
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
|
||||
|
||||
# Sort projects by hours descending
|
||||
projects = sorted(
|
||||
[{"code": k, "hours": round(v, 2)} for k, v in project_hours.items()],
|
||||
key=lambda x: x["hours"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
total_logged = round(sum(project_hours.values()) + unmatched_hours + ignored_hours, 2)
|
||||
total_billable = round(sum(project_hours.values()) + unmatched_hours, 2)
|
||||
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
|
||||
|
||||
# Find working days with no logged time
|
||||
days_missing = [
|
||||
d.isoformat() for d in wdays
|
||||
if hours_by_date.get(d.isoformat(), 0.0) == 0.0
|
||||
]
|
||||
|
||||
output = {
|
||||
"month": args.month,
|
||||
"user_email": jira_email,
|
||||
"account_id": account_id,
|
||||
"working_days": len(wdays),
|
||||
"expected_hours": expected_hours,
|
||||
"total_logged_hours": total_logged,
|
||||
"total_billable_hours": total_billable,
|
||||
"ignored_hours": round(ignored_hours, 2),
|
||||
"ignored_keys": sorted(ignored_keys),
|
||||
"missing_hours": missing_hours,
|
||||
"unmatched_hours": round(unmatched_hours, 2),
|
||||
"days_with_no_hours": days_missing,
|
||||
"projects": projects,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / f"invoice_prep_{args.month}.json"
|
||||
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
# Print quick summary
|
||||
print(f"\nProjects:")
|
||||
for p in projects:
|
||||
print(f" {p['code']:12s} {p['hours']:.2f}h")
|
||||
if unmatched_hours:
|
||||
print(f" (no project) {unmatched_hours:.2f}h")
|
||||
if ignored_hours:
|
||||
print(f" (ignored) {ignored_hours:.2f}h ({', '.join(sorted(ignored_keys))})")
|
||||
print(f"\nBillable total: {total_billable:.2f}h")
|
||||
print(f"Total logged: {total_logged:.2f}h / {expected_hours:.0f}h expected")
|
||||
if missing_hours > 0:
|
||||
print(f"⚠ Missing: {missing_hours:.2f}h ({len(days_missing)} day(s) with no time logged)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate invoice line items from Tempo worklogs."
|
||||
)
|
||||
parser.add_argument("--month", required=True, help="Month to check: YYYY-MM")
|
||||
parser.add_argument("--output-dir", required=True,
|
||||
help="Directory for invoice_prep_YYYY-MM.json")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
5
plugins/ivona/.claude-plugin/plugin.json
Normal file
5
plugins/ivona/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "ivona",
|
||||
"version": "1.0.0",
|
||||
"description": "Check KSeF invoices against contractors/white-list and audit team timesheet completion via Tempo."
|
||||
}
|
||||
13
plugins/ivona/skills/invoice-checker/.env.example
Normal file
13
plugins/ivona/skills/invoice-checker/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Invoice Checker — credentials template
|
||||
# Copy this file to .env (in the project root) and fill in the values.
|
||||
# .env is gitignored and never committed.
|
||||
|
||||
# Your company NIP — 10 digits, no dashes or spaces
|
||||
KSEF_NIP=1111111111
|
||||
|
||||
# KSeF authorization token — generate in the KSeF portal under Zarządzanie tokenami
|
||||
# Grant it at minimum: "Odczyt faktur" (invoice read) permissions.
|
||||
KSEF_TOKEN=20260601-EC-25684C5000-A33DFDC343-5F|nip-1111111111|4b6eaeb9027d45428fe714ff2a32a8f75bae7b2b83c14fb1bd64a60543767df5
|
||||
|
||||
# Environment: "test" (sandbox) or "prod" (production)
|
||||
KSEF_ENV=test
|
||||
1
plugins/ivona/skills/invoice-checker/.gitignore
vendored
Normal file
1
plugins/ivona/skills/invoice-checker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
116
plugins/ivona/skills/invoice-checker/SKILL.md
Normal file
116
plugins/ivona/skills/invoice-checker/SKILL.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
name: invoice-checker
|
||||
description: >
|
||||
Pull and check invoices from KSeF (Krajowy System e-Faktur) for a selected period.
|
||||
For every new invoice checks whether the contractor is in the contractors file and
|
||||
whether the contractor's bank account is on the MF white list (Biała lista podatników VAT).
|
||||
TRIGGER this skill automatically whenever the user mentions pulling, checking, reviewing,
|
||||
or auditing invoices from KSeF — especially when they mention a period like last week,
|
||||
last month, or a date range. Trigger phrases include: "check invoices", "pull invoices",
|
||||
"invoice audit", "KSeF invoices", "check KSeF", "white list check", "biała lista".
|
||||
---
|
||||
|
||||
# Invoice Checker
|
||||
|
||||
Use this skill when someone needs to download purchase invoices from KSeF for a given period, verify contractors, and check bank accounts against the MF white list.
|
||||
|
||||
**In Claude Code, you run the full pipeline automatically.** The user provides a period — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This skill reads credentials from the `.env` file in the **project root** (the working directory where Claude Code is open). The same file is shared with the timesheet-checker skill — just add the KSEF variables to it.
|
||||
|
||||
Add these lines to your `.env`:
|
||||
|
||||
```env
|
||||
# KSeF — Invoice Checker
|
||||
KSEF_NIP= # 10-digit company NIP, no dashes
|
||||
KSEF_TOKEN= # Authorization token from KSeF portal → Zarządzanie tokenami
|
||||
KSEF_ENV=prod # "test" for sandbox, "prod" for production
|
||||
```
|
||||
|
||||
The variable reference:
|
||||
|
||||
| Variable | What it is | Where to get it |
|
||||
|---|---|---|
|
||||
| `KSEF_NIP` | Company NIP (10 digits, no dashes) | Your company's tax ID |
|
||||
| `KSEF_TOKEN` | KSeF authorization token | KSeF portal → Zarządzanie tokenami |
|
||||
| `KSEF_ENV` | `test` or `prod` | Use `test` for the sandbox, `prod` for production |
|
||||
|
||||
The script also requires the `cryptography` Python package:
|
||||
|
||||
```bash
|
||||
pip3 install cryptography
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Determine `SKILL_DIR` from the location of this file. Set the output directory to `./invoices-output/` in the current working directory. Create it if it does not exist.
|
||||
|
||||
## Load First
|
||||
|
||||
Read these reference files:
|
||||
|
||||
1. The contractors config — check `./contractors.md` in the current working directory first; if not present, fall back to `{SKILL_DIR}/references/contractors.md`. Tell the user which one is being used.
|
||||
2. `{SKILL_DIR}/references/output-format.md` — the structure of `invoices_data.json` and the final report.
|
||||
|
||||
## Extracting the period
|
||||
|
||||
The user may express the period in various ways. Convert to `YYYY-MM-DD:YYYY-MM-DD` before running:
|
||||
|
||||
| User says | Start | End |
|
||||
|---|---|---|
|
||||
| "last week" | Last Monday | Last Friday |
|
||||
| "last month" | 1st of previous month | Last day of previous month |
|
||||
| "this month" | 1st of current month | Yesterday |
|
||||
| "this year" | 1 January of current year | Yesterday |
|
||||
| "June", "June 2026" | 2026-06-01 | 2026-06-30 |
|
||||
| Explicit dates | As given | As given |
|
||||
|
||||
If the user has not specified a period, ask: "Który okres sprawdzić? (np. zeszły tydzień, zeszły miesiąc, zakres dat)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 — Fetch and check.** Run this command:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/preprocess.py \
|
||||
--period {YYYY-MM-DD:YYYY-MM-DD} \
|
||||
--contractors {contractors_path} \
|
||||
--output-dir ./invoices-output
|
||||
```
|
||||
|
||||
This authenticates with KSeF, downloads all purchase invoices for the period, compares against `invoices_cache.json` to identify new invoices, checks each contractor against `contractors.md`, checks bank accounts against the MF white list, and writes `invoices_data.json` to `./invoices-output/`.
|
||||
|
||||
**Step 2 — Interpret.** Read `./invoices-output/invoices_data.json`. Note:
|
||||
- **New vs known invoices** — only new ones (not in cache) are actively checked.
|
||||
- **Unknown contractors** — contractors not in `contractors.md`. Show these to the user and ask if they should be added.
|
||||
- **White list failures** — bank accounts not found on the white list. These are high priority — flag them prominently.
|
||||
- **White list errors** — API call failed (network issue, etc.). Note that these could not be verified.
|
||||
|
||||
**Step 3 — Generate report.**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/postprocess.py \
|
||||
--data ./invoices-output/invoices_data.json \
|
||||
--output-dir ./invoices-output
|
||||
```
|
||||
|
||||
This writes `invoices_report.md`.
|
||||
|
||||
**Step 4 — Present results.** Show the user:
|
||||
- Summary block: total invoices, new invoices, contractor status, white list status.
|
||||
- Table of new invoices with contractor and white list status.
|
||||
- Any white list failures or unknown contractors — highlighted prominently.
|
||||
- "Full report saved to `./invoices-output/invoices_report.md`."
|
||||
|
||||
**Step 5 — Handle unknown contractors.** If the user confirms any unknown contractors should be added, add them to `contractors.md`. Use the NIP and name from the invoice; leave `bank_accounts` blank if they want to fill it in later, or populate it from the invoice bank account.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never fabricate invoice data. All data comes from KSeF.
|
||||
- White list checks are always performed against the invoice issue date (not today).
|
||||
- If `KSEF_NIP` or `KSEF_TOKEN` are not set, stop and ask the user for them.
|
||||
- If the `cryptography` package is missing, tell the user to run `pip3 install cryptography` and stop.
|
||||
- If any script step fails, show the error and stop. Do not proceed with partial output.
|
||||
- Always terminate the KSeF session even if an error occurs (the script handles this internally).
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Contractors
|
||||
|
||||
Add a row for each contractor. Use their NIP as the primary identifier — the script matches invoices by NIP.
|
||||
|
||||
`bank_accounts` is a semicolon-separated list of IBAN numbers (with or without the PL prefix, spaces ignored).
|
||||
Leave it blank if you do not want to maintain expected accounts — the white list check will still run against the account from the invoice.
|
||||
|
||||
`max_invoice_net_value` — maximum net total (PLN) allowed for a single invoice. Leave blank for no limit.
|
||||
`max_position_net_rate` — maximum unit price (net) allowed on any single line item. Leave blank for no limit.
|
||||
`max_total_quantity` — maximum sum of quantities across all line items on a single invoice. Leave blank for no limit.
|
||||
`email` — contact email address for the contractor (used for notifications).
|
||||
`check_with_jira` — set to `yes` (or `true` / `1`) to indicate invoices from this contractor should be cross-checked with Jira.
|
||||
|
||||
| name | nip | bank_accounts | max_invoice_net_value | max_position_net_rate | max_total_quantity | email | check_with_jira | notes |
|
||||
| -----| ---| --------------| ----------------------| ----------------------| -------------------| ------| ----------------| ------|
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Output Format
|
||||
|
||||
## invoices_data.json (written by preprocess.py)
|
||||
|
||||
```json
|
||||
{
|
||||
"period": {
|
||||
"start": "2026-06-01",
|
||||
"end": "2026-06-30"
|
||||
},
|
||||
"summary": {
|
||||
"total_invoices": 12,
|
||||
"new_invoices": 4,
|
||||
"known_contractors": 3,
|
||||
"unknown_contractors": 1,
|
||||
"whitelist_ok": 3,
|
||||
"whitelist_failed": 1,
|
||||
"whitelist_error": 0,
|
||||
"account_mismatch": 0
|
||||
},
|
||||
"invoices": [
|
||||
{
|
||||
"ksef_reference": "1234567890-20260601-ABC123",
|
||||
"invoice_number": "FV/123/06/2026",
|
||||
"issue_date": "2026-06-01",
|
||||
"seller_nip": "1234567890",
|
||||
"seller_name": "Firma ABC Sp. z o.o.",
|
||||
"gross_amount": 1230.00,
|
||||
"currency": "PLN",
|
||||
"bank_account": "PL61109010140000071219812874",
|
||||
"is_new": true,
|
||||
"contractor_known": true,
|
||||
"account_in_contractors": true,
|
||||
"whitelist_status": "ok",
|
||||
"whitelist_account_assigned": true,
|
||||
"whitelist_error": null
|
||||
}
|
||||
],
|
||||
"known_invoices_count": 8
|
||||
}
|
||||
```
|
||||
|
||||
### Field notes
|
||||
|
||||
- `is_new` — true if the KSeF reference was not in `invoices_cache.json` before this run. The cache is updated at the end of a successful run.
|
||||
- `contractor_known` — true if the seller NIP is in `contractors.md`.
|
||||
- `account_in_contractors` — true if the invoice bank account matches one of the accounts listed in `contractors.md` for this NIP. `null` if the contractor is unknown or has no accounts configured.
|
||||
- `whitelist_status` — `"ok"` (account found on white list), `"failed"` (account NOT on white list), `"no_account"` (invoice has no bank account), `"error"` (API call failed).
|
||||
- `whitelist_account_assigned` — the raw `accountAssigned` value from the MF API (`true`/`false`). `null` if not checked.
|
||||
- `whitelist_error` — error message string if the API call failed, otherwise `null`.
|
||||
|
||||
## invoices_report.md (written by postprocess.py)
|
||||
|
||||
Manager-readable markdown report containing:
|
||||
- Period and run timestamp
|
||||
- Summary block
|
||||
- Table of new invoices with contractor and white list status
|
||||
- Section highlighting white list failures (if any)
|
||||
- Section listing unknown contractors (if any)
|
||||
|
||||
### Status icons used in the report
|
||||
|
||||
| Icon | Meaning |
|
||||
|---|---|
|
||||
| ✓ | White list OK |
|
||||
| ✗ | White list FAILED — do not pay until resolved |
|
||||
| ? | Could not verify (API error) |
|
||||
| — | No bank account on invoice |
|
||||
| ★ | Known contractor |
|
||||
| ✦ | Unknown contractor |
|
||||
421
plugins/ivona/skills/invoice-checker/scripts/postprocess.py
Normal file
421
plugins/ivona/skills/invoice-checker/scripts/postprocess.py
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Format invoice check data into a markdown report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fmt_amount(amount: Any, currency: str = "PLN") -> str:
|
||||
if amount is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(amount):,.2f} {currency}"
|
||||
except (TypeError, ValueError):
|
||||
return str(amount)
|
||||
|
||||
|
||||
def contractor_badge(inv: dict) -> str:
|
||||
if inv.get("contractor_known"):
|
||||
mismatch = inv.get("account_in_contractors") is False
|
||||
return "★" + (" ⚠acc" if mismatch else "")
|
||||
return "✦ NEW"
|
||||
|
||||
|
||||
def whitelist_badge(inv: dict) -> str:
|
||||
status = inv.get("whitelist_status", "")
|
||||
if status == "ok":
|
||||
return "✓"
|
||||
if status == "failed":
|
||||
return "✗ FAILED"
|
||||
if status == "no_account":
|
||||
return "—"
|
||||
if status == "error":
|
||||
return "? err"
|
||||
if status == "no_nip":
|
||||
return "? no NIP"
|
||||
return status
|
||||
|
||||
|
||||
def rules_badge(inv: dict) -> str:
|
||||
violations = inv.get("rule_violations") or []
|
||||
return f"⚠ {len(violations)}" if violations else "✓"
|
||||
|
||||
|
||||
def duplicate_badge(inv: dict) -> str:
|
||||
if inv.get("potential_duplicate"):
|
||||
return "⚠ DUPE?"
|
||||
if inv.get("second_in_month"):
|
||||
return "2nd"
|
||||
return ""
|
||||
|
||||
|
||||
def jira_badge(inv: dict) -> str:
|
||||
if not inv.get("jira_checked"):
|
||||
return ""
|
||||
if inv.get("jira_compliant"):
|
||||
return "✓"
|
||||
return "✗"
|
||||
|
||||
|
||||
def _invoice_table_row(inv: dict) -> str:
|
||||
return (
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('issue_date') or '—'} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} "
|
||||
f"| {contractor_badge(inv)} "
|
||||
f"| {whitelist_badge(inv)} "
|
||||
f"| {rules_badge(inv)} "
|
||||
f"| {duplicate_badge(inv)} "
|
||||
f"| {jira_badge(inv)} |"
|
||||
)
|
||||
|
||||
|
||||
def _invoice_table_header() -> list[str]:
|
||||
return [
|
||||
"| Invoice | Date | Seller | NIP | Amount | Contractor | White list | Rules | Dup. | Jira |",
|
||||
"| ---| ---| ---| ---| ---:| ---| ---| ---| ---| ---|",
|
||||
]
|
||||
|
||||
|
||||
def _legend() -> list[str]:
|
||||
return [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"**Legend:** ★ = known contractor · ✦ NEW = unknown contractor · "
|
||||
"✓ = white list OK · ✗ FAILED = not on white list · "
|
||||
"— = no bank account · ? = could not verify · ⚠acc = account not in contractors.md · "
|
||||
"⚠ N = N rule violation(s) · ✓ = rules OK · "
|
||||
"2nd = 2nd invoice from seller this month · ⚠ DUPE? = same amount as earlier invoice · "
|
||||
"✓/✗ Jira = Jira compliance check result",
|
||||
"",
|
||||
"_White list source: [wl-api.mf.gov.pl](https://wl-api.mf.gov.pl)_",
|
||||
"_Invoice source: KSeF (Ministerstwo Finansów)_",
|
||||
]
|
||||
|
||||
|
||||
def build_report(data: dict[str, Any]) -> str:
|
||||
"""Build the per-run report covering only new invoices."""
|
||||
period = data["period"]
|
||||
summary = data["summary"]
|
||||
all_invoices = data.get("invoices", [])
|
||||
new_invoices = [i for i in all_invoices if i.get("is_new")]
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
env = data.get("ksef_env", "prod")
|
||||
|
||||
lines = [
|
||||
"# Invoice Check Report",
|
||||
"",
|
||||
f"Generated: {generated_at}",
|
||||
f"Period: {period['start']} to {period['end']}",
|
||||
f"KSeF environment: {env}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Total invoices in period: **{summary['total_invoices']}**",
|
||||
f"- New (checked this run): **{summary['new_invoices']}**",
|
||||
f"- Known contractors: {summary['known_contractors']}",
|
||||
f"- Unknown contractors: **{summary['unknown_contractors']}**",
|
||||
f"- White list OK: {summary['whitelist_ok']}",
|
||||
f"- White list FAILED: **{summary['whitelist_failed']}**",
|
||||
f"- White list errors: {summary['whitelist_error']}",
|
||||
f"- Account mismatch (not in contractors.md): {summary['account_mismatch']}",
|
||||
f"- Rule violations (limit exceeded): **{summary.get('rule_violations', 0)}**",
|
||||
f"- 2nd invoice from same seller this month: **{summary.get('second_invoices', 0)}**",
|
||||
f"- Potential duplicates (same seller, same month, same amount): **{summary.get('potential_duplicates', 0)}**",
|
||||
f"- Jira non-compliant: **{summary.get('jira_non_compliant', 0)}**",
|
||||
]
|
||||
|
||||
# Jira non-compliance — prominent
|
||||
jira_bad = [i for i in new_invoices if i.get("jira_checked") and not i.get("jira_compliant")]
|
||||
if jira_bad:
|
||||
lines += [
|
||||
"",
|
||||
"## ✗ Jira Non-Compliant Invoices",
|
||||
"",
|
||||
"> Quantities on these invoices do not match hours logged in Jira/Tempo.",
|
||||
"",
|
||||
]
|
||||
for inv in jira_bad:
|
||||
lines += [
|
||||
f"**{inv.get('invoice_number') or inv.get('ksef_reference', '—')}** "
|
||||
f"— {inv.get('seller_name') or '—'} ({inv.get('issue_date') or '—'})",
|
||||
"",
|
||||
]
|
||||
violations = inv.get("jira_violations") or []
|
||||
no_proj = inv.get("jira_no_project_positions") or []
|
||||
if violations:
|
||||
lines += [
|
||||
"| Project | Invoice qty | Jira hours | Month |",
|
||||
"| ---| ---:| ---:| ---|",
|
||||
]
|
||||
for v in violations:
|
||||
lines.append(
|
||||
f"| {v['project']} | {v['invoice_qty']} | {v['jira_hours']} | {v['month']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if no_proj:
|
||||
lines.append(
|
||||
"Positions without a Jira project code: "
|
||||
+ ", ".join(
|
||||
f"#{p['line_number']} ({p['description'] or 'no description'})"
|
||||
for p in no_proj
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Potential duplicates — prominent
|
||||
dupes = [i for i in new_invoices if i.get("potential_duplicate")]
|
||||
if dupes:
|
||||
lines += [
|
||||
"",
|
||||
"## ⚠ Potential Duplicates",
|
||||
"",
|
||||
"> These invoices have the same seller and gross amount as another invoice in the same month.",
|
||||
"> Verify they are not duplicate payments before approving.",
|
||||
"",
|
||||
"| Invoice | Date | Seller | NIP | Amount |",
|
||||
"| ---| ---| ---| ---| ---:|",
|
||||
]
|
||||
for inv in dupes:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('issue_date') or '—'} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
||||
)
|
||||
|
||||
# Rule violations — prominent
|
||||
rule_violated = [i for i in new_invoices if i.get("rule_violations")]
|
||||
if rule_violated:
|
||||
lines += [
|
||||
"",
|
||||
"## ⚠ Rule Violations",
|
||||
"",
|
||||
"> These invoices exceed limits configured in `contractors.md`.",
|
||||
"",
|
||||
"| Invoice | Seller | Rule | Limit | Actual | Position |",
|
||||
"| ---| ---| ---| ---:| ---:| ---|",
|
||||
]
|
||||
for inv in rule_violated:
|
||||
for v in inv.get("rule_violations", []):
|
||||
rule_label = {
|
||||
"max_invoice_net_value": "Max invoice net",
|
||||
"max_position_net_rate": "Max position rate",
|
||||
"max_total_quantity": "Max total qty",
|
||||
}.get(v["rule"], v["rule"])
|
||||
pos_info = f"#{v['position']} {v.get('description', '')}" if v.get("position") else "—"
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {rule_label} "
|
||||
f"| {v['limit']:,.2f} "
|
||||
f"| {v['actual']:,.2f} "
|
||||
f"| {pos_info} |"
|
||||
)
|
||||
|
||||
# White list failures — prominent
|
||||
failed = [i for i in new_invoices if i.get("whitelist_status") == "failed"]
|
||||
if failed:
|
||||
lines += [
|
||||
"",
|
||||
"## ✗ White List Failures — DO NOT PAY",
|
||||
"",
|
||||
"> These bank accounts are **not registered** on the MF white list for the given NIP.",
|
||||
"> Verify manually before making any payment.",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Bank account | Amount |",
|
||||
"| ---| ---| ---| ---| ---:|",
|
||||
]
|
||||
for inv in failed:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('bank_account') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
||||
)
|
||||
|
||||
# New invoices table
|
||||
if new_invoices:
|
||||
lines += ["", "## New Invoices", ""] + _invoice_table_header()
|
||||
for inv in new_invoices:
|
||||
lines.append(_invoice_table_row(inv))
|
||||
else:
|
||||
lines += ["", "## New Invoices", "", "_No new invoices found for this period._"]
|
||||
|
||||
# Unknown contractors
|
||||
unknown = [i for i in new_invoices if not i.get("contractor_known")]
|
||||
if unknown:
|
||||
lines += [
|
||||
"",
|
||||
"## ✦ Unknown Contractors",
|
||||
"",
|
||||
"These sellers are not in `contractors.md`. Consider adding them.",
|
||||
"",
|
||||
"| Seller name | NIP | Bank account |",
|
||||
"| ---| ---| ---|",
|
||||
]
|
||||
seen_nips: set[str] = set()
|
||||
for inv in unknown:
|
||||
nip = inv.get("seller_nip") or "—"
|
||||
if nip in seen_nips:
|
||||
continue
|
||||
seen_nips.add(nip)
|
||||
lines.append(
|
||||
f"| {inv.get('seller_name') or '—'} | {nip} | {inv.get('bank_account') or '—'} |"
|
||||
)
|
||||
|
||||
# Account mismatches
|
||||
mismatches = [i for i in new_invoices if i.get("account_in_contractors") is False]
|
||||
if mismatches:
|
||||
lines += [
|
||||
"",
|
||||
"## ⚠ Account Mismatches",
|
||||
"",
|
||||
"These invoices carry a bank account **not listed** in `contractors.md` for the seller.",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Invoice account |",
|
||||
"| ---| ---| ---| ---|",
|
||||
]
|
||||
for inv in mismatches:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('bank_account') or '—'} |"
|
||||
)
|
||||
|
||||
# White list errors
|
||||
errors = [i for i in new_invoices if i.get("whitelist_status") == "error"]
|
||||
if errors:
|
||||
lines += [
|
||||
"",
|
||||
"## ? White List Check Errors",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Error |",
|
||||
"| ---| ---| ---| ---|",
|
||||
]
|
||||
for inv in errors:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('whitelist_error') or '—'} |"
|
||||
)
|
||||
|
||||
lines += _legend()
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def build_all_report(all_data: dict[str, Any]) -> str:
|
||||
"""Build the cumulative report covering all invoices ever processed."""
|
||||
updated_at = all_data.get("updated_at", "")[:19].replace("T", " ") + " UTC"
|
||||
invoices_map: dict[str, dict] = all_data.get("invoices", {})
|
||||
|
||||
# Sort by issue_date descending, then by ksef_reference for stability
|
||||
invoices = sorted(
|
||||
invoices_map.values(),
|
||||
key=lambda i: (i.get("issue_date") or "0000-00-00", i.get("ksef_reference", "")),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
total = len(invoices)
|
||||
wl_ok = sum(1 for i in invoices if i.get("whitelist_status") == "ok")
|
||||
wl_failed = sum(1 for i in invoices if i.get("whitelist_status") == "failed")
|
||||
unknown_count = sum(1 for i in invoices if not i.get("contractor_known"))
|
||||
gross_total = sum(float(i["gross_amount"]) for i in invoices if i.get("gross_amount") is not None)
|
||||
|
||||
lines = [
|
||||
"# All Invoices",
|
||||
"",
|
||||
f"Last updated: {updated_at}",
|
||||
f"Total: **{total}** invoice(s)",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Total invoices: **{total}**",
|
||||
f"- Total gross value: **{gross_total:,.2f} PLN**",
|
||||
f"- White list OK: {wl_ok}",
|
||||
f"- White list FAILED: **{wl_failed}**",
|
||||
f"- Unknown contractors: **{unknown_count}**",
|
||||
]
|
||||
|
||||
# White list failures — prominent
|
||||
failed = [i for i in invoices if i.get("whitelist_status") == "failed"]
|
||||
if failed:
|
||||
lines += [
|
||||
"",
|
||||
"## ✗ White List Failures — DO NOT PAY",
|
||||
"",
|
||||
"> These bank accounts are **not registered** on the MF white list.",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Bank account | Amount |",
|
||||
"| ---| ---| ---| ---| ---:|",
|
||||
]
|
||||
for inv in failed:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('bank_account') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
||||
)
|
||||
|
||||
# Full invoices table
|
||||
lines += ["", "## All Invoices (newest first)", ""] + _invoice_table_header()
|
||||
for inv in invoices:
|
||||
lines.append(_invoice_table_row(inv))
|
||||
|
||||
lines += _legend()
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
data_path = Path(args.data)
|
||||
if not data_path.exists():
|
||||
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Per-run report (new invoices only)
|
||||
report_path = output_dir / "invoices_report.md"
|
||||
report_path.write_text(build_report(data), encoding="utf-8")
|
||||
print(f"Wrote {report_path}")
|
||||
|
||||
# Cumulative report (all invoices ever processed)
|
||||
all_data_path = output_dir / "invoices_all.json"
|
||||
if all_data_path.exists():
|
||||
all_data = json.loads(all_data_path.read_text(encoding="utf-8"))
|
||||
all_report_path = output_dir / "invoices_all_report.md"
|
||||
all_report_path.write_text(build_all_report(all_data), encoding="utf-8")
|
||||
print(f"Wrote {all_report_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Format invoice check data into markdown reports."
|
||||
)
|
||||
parser.add_argument("--data", required=True,
|
||||
help="Path to invoices_data.json produced by preprocess.py.")
|
||||
parser.add_argument("--output-dir", required=True,
|
||||
help="Directory for invoices_report.md and invoices_all_report.md.")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
1243
plugins/ivona/skills/invoice-checker/scripts/preprocess.py
Normal file
1243
plugins/ivona/skills/invoice-checker/scripts/preprocess.py
Normal file
File diff suppressed because it is too large
Load diff
15
plugins/ivona/skills/timesheet-checker/.env.example
Normal file
15
plugins/ivona/skills/timesheet-checker/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Timesheet Checker — credentials template
|
||||
# Copy this file to .env and fill in your values.
|
||||
# .env is gitignored and never committed.
|
||||
|
||||
# Tempo API token — generate at: Tempo → Settings → API Integration
|
||||
TEMPO_API_TOKEN=
|
||||
|
||||
# Your Atlassian / Jira Cloud URL
|
||||
JIRA_BASE_URL=https://your-company.atlassian.net
|
||||
|
||||
# The email address you use to log into Jira
|
||||
JIRA_EMAIL=your.email@company.com
|
||||
|
||||
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
|
||||
JIRA_API_TOKEN=
|
||||
1
plugins/ivona/skills/timesheet-checker/.gitignore
vendored
Normal file
1
plugins/ivona/skills/timesheet-checker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
209
plugins/ivona/skills/timesheet-checker/README.md
Normal file
209
plugins/ivona/skills/timesheet-checker/README.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Timesheet Checker
|
||||
|
||||
Checks whether employees have completed their timesheets for a given period by pulling Tempo worklogs. Compares against each employee's country calendar (public holidays fetched from date.nager.at) and a configurable minimum of hours per day. Produces a completion report ranked by missing hours.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The skill needs four credentials. The easiest setup is a `.env` file — copy the template and fill it in once:
|
||||
|
||||
macOS / Linux:
|
||||
```bash
|
||||
cp .claude/skills/timesheet-checker/.env.example .claude/skills/timesheet-checker/.env
|
||||
```
|
||||
Windows (PowerShell):
|
||||
```powershell
|
||||
Copy-Item .claude\skills\timesheet-checker\.env.example .claude\skills\timesheet-checker\.env
|
||||
```
|
||||
Or copy the file manually in Explorer / Finder — rename `.env.example` to `.env` in the skill folder.
|
||||
|
||||
Then open `.env` and fill in your values:
|
||||
|
||||
```
|
||||
TEMPO_API_TOKEN=your_tempo_token
|
||||
JIRA_BASE_URL=https://your-company.atlassian.net
|
||||
JIRA_EMAIL=your.email@company.com
|
||||
JIRA_API_TOKEN=your_jira_token
|
||||
```
|
||||
|
||||
The script looks for `.env` in the **working directory first**, then the skill folder as a fallback. This means you can also place a `.env` in your project root if you prefer one file for all skills.
|
||||
|
||||
Environment variables set via `export` always take priority over `.env` values, so both approaches work side-by-side.
|
||||
|
||||
The `.env` file is gitignored — credentials are never committed.
|
||||
|
||||
| Variable | What it is | Where to get it |
|
||||
|---|---|---|
|
||||
| `TEMPO_API_TOKEN` | Tempo read token | Tempo → Settings → API Integration |
|
||||
| `JIRA_BASE_URL` | Your Atlassian URL | The URL you use to open Jira |
|
||||
| `JIRA_EMAIL` | Your Jira login email | Your Atlassian account email |
|
||||
| `JIRA_API_TOKEN` | Jira personal API token | [id.atlassian.com](https://id.atlassian.com) → Security → API tokens |
|
||||
|
||||
If any credential is missing from both `.env` and the environment, the script stops with a clear error listing exactly what is needed.
|
||||
|
||||
---
|
||||
|
||||
## How to use in Claude Code
|
||||
|
||||
Claude Code runs as a CLI, desktop app, web app (claude.ai/code), or IDE extension. **Claude Code runs the full pipeline automatically — no manual script steps.**
|
||||
|
||||
**Install** — copy the skill folder into your project's Claude skills directory:
|
||||
|
||||
macOS / Linux:
|
||||
```bash
|
||||
cp -r src/timesheet-checker .claude/skills/
|
||||
```
|
||||
Windows (PowerShell):
|
||||
```powershell
|
||||
Copy-Item -Recurse -Path src\timesheet-checker -Destination .claude\skills\
|
||||
```
|
||||
|
||||
**Use:**
|
||||
Just describe what you want — Claude will pick up the skill automatically:
|
||||
```
|
||||
Check if timesheets are complete for last week
|
||||
```
|
||||
```
|
||||
Timesheet completion for last month
|
||||
```
|
||||
```
|
||||
Check logged hours for 2026-06-01 to 2026-06-30
|
||||
```
|
||||
|
||||
Claude fetches Polish public holidays, pulls Tempo worklogs, computes completion per employee, generates the report, and shows you the results. Output files land in `./timesheet-output/`.
|
||||
|
||||
---
|
||||
|
||||
## How to use in the Claude.ai app
|
||||
|
||||
> Full automation (no manual script running) requires Claude Code. In the Claude.ai app, the Python steps must be run locally.
|
||||
|
||||
**Set up once (Project approach):**
|
||||
1. Create a new Project in Claude.ai.
|
||||
2. In **Project Instructions**, paste the full contents of `SKILL.md`.
|
||||
3. Upload `references/employees.md` and `references/output-format.md` to the Project knowledge.
|
||||
|
||||
**Each time you run:**
|
||||
1. Set all four credentials in your shell, then run the pre-processor locally:
|
||||
```bash
|
||||
python3 $SKILL_DIR/scripts/preprocess.py \
|
||||
--period last-week \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
The script will use `./employees.md` if it exists, otherwise `$SKILL_DIR/references/employees.md`.
|
||||
2. Upload `timesheet_data.json` to the Project conversation.
|
||||
3. Ask Claude to summarise and present the results.
|
||||
4. Run the post-processor locally to generate the formatted report:
|
||||
```bash
|
||||
python3 $SKILL_DIR/scripts/postprocess.py \
|
||||
--data ./timesheet-output/timesheet_data.json \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
**Without Projects:** Paste the contents of `SKILL.md` at the start of a new conversation, attach the two reference files, then follow the same steps.
|
||||
|
||||
---
|
||||
|
||||
## What is included
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `SKILL.md` | Orchestrating skill — instructions for Claude |
|
||||
| `references/employees.md` | Editable list of employees and their Tempo account IDs |
|
||||
| `references/output-format.md` | Structure of `timesheet_data.json` and the final report |
|
||||
| `scripts/preprocess.py` | Fetches Tempo worklogs + Polish holidays, computes completion |
|
||||
| `scripts/postprocess.py` | Formats `timesheet_data.json` into the markdown report |
|
||||
| `sample/employees_sample.md` | Sample employees file with placeholder account IDs |
|
||||
|
||||
## Outputs (in `./timesheet-output/`)
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `timesheet_data.json` | Computed stats per employee + period + summary |
|
||||
| `timesheet_report.md` | Manager-readable report ranked by missing hours |
|
||||
|
||||
---
|
||||
|
||||
## Configuring employees
|
||||
|
||||
The script looks for the employees file in this order:
|
||||
|
||||
1. `employees.md` in the **current working directory** — place it here for a project-specific list
|
||||
2. `references/employees.md` in the **skill folder** — the default shipped with the skill
|
||||
|
||||
To use a project-specific list, copy the sample and edit it:
|
||||
```bash
|
||||
cp .claude/skills/timesheet-checker/sample/employees_sample.md ./employees.md
|
||||
```
|
||||
|
||||
The file has two tables:
|
||||
|
||||
**Defaults** — applies to every employee unless overridden:
|
||||
|
||||
| setting | value |
|
||||
|---|---|
|
||||
| `country` | ISO 3166-1 alpha-2 country code (e.g. `PL`, `GB`, `DE`). Used for public holiday lookup. |
|
||||
| `expected_hours_per_day` | Minimum hours per working day |
|
||||
|
||||
**Employees** — one row per person. Leave any column blank to inherit the default.
|
||||
|
||||
| Column | What it controls |
|
||||
|---|---|
|
||||
| `name` | Display name in the report |
|
||||
| `email` | Work email address (used to look up the Jira account ID automatically) |
|
||||
| `country` | Override the default country for this employee |
|
||||
| `expected_hours_per_day` | Override the default hours for this employee |
|
||||
| `start_date` | First working day — leave blank if employed for the full period |
|
||||
|
||||
---
|
||||
|
||||
## Period formats
|
||||
|
||||
| Input | Meaning | End date |
|
||||
|---|---|---|
|
||||
| `last-week` | Previous Monday–Friday | Last Friday |
|
||||
| `last-month` | Previous calendar month | Last day of that month |
|
||||
| `last-year` | Previous calendar year | 31 December |
|
||||
| `yesterday` | Yesterday only | Yesterday |
|
||||
| `current-week` | Monday of this week to yesterday | Yesterday |
|
||||
| `current-month` | 1st of this month to yesterday | Yesterday |
|
||||
| `current-year` | 1st January of this year to yesterday | Yesterday |
|
||||
| `YYYY-MM-DD:YYYY-MM-DD` | Explicit date range | As specified |
|
||||
|
||||
For `current-*` periods the current in-progress day is always excluded — end date is yesterday. If today is the first day of the period (e.g. `current-week` on a Monday), the period contains no past working days and the report will show 0 expected hours.
|
||||
|
||||
In Claude Code, you can also use natural language ("last week", "this month", "yesterday", "June 2026") and Claude converts it automatically.
|
||||
|
||||
---
|
||||
|
||||
## How working days are calculated
|
||||
|
||||
The script fetches Polish public holidays from the [date.nager.at](https://date.nager.at) public API (no authentication needed). It then excludes weekends and those holidays from the period. This runs live each time — holiday data is never hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
This skill connects to **Tempo Cloud** (`api.tempo.io/4`) by default. For self-hosted Tempo (Jira Data Center), pass the correct base URL:
|
||||
|
||||
```bash
|
||||
python3 $SKILL_DIR/scripts/preprocess.py \
|
||||
--tempo-base-url https://your-jira.company.com/rest/tempo-timesheets/4 \
|
||||
...
|
||||
```
|
||||
|
||||
Or in Claude Code:
|
||||
```
|
||||
Check timesheets for last week, Tempo is at https://jira.company.com/rest/tempo-timesheets/4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
- Requires `TEMPO_API_TOKEN` — the script stops immediately with a clear error if it is missing.
|
||||
- Employees with 0 hours may be on leave; the tool does not distinguish between missing logs and approved absence. Review these manually.
|
||||
- If the period end date is today or in the future, the period is still open and data will be partial.
|
||||
- Polish holidays only. If your team uses a different country calendar, the `get_polish_holidays` function in `preprocess.py` needs updating.
|
||||
- Tempo Cloud API v4 only. Tempo Server / Data Center may require a different base URL and authentication method.
|
||||
115
plugins/ivona/skills/timesheet-checker/SKILL.md
Normal file
115
plugins/ivona/skills/timesheet-checker/SKILL.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
---
|
||||
name: timesheet-checker
|
||||
description: >
|
||||
Check timesheet completion for a given period by pulling Tempo worklogs for all reporters
|
||||
found in Tempo (not just those in employees.md). Flags anyone missing from employees.md
|
||||
and asks the user to add them. Requires TEMPO_API_TOKEN. Checks against the Polish working
|
||||
calendar and 8h/day minimum. Produces a completion report ranked by missing hours.
|
||||
TRIGGER this skill automatically whenever the user mentions checking, reviewing, or auditing
|
||||
timesheets or timesheet completion — especially when they mention a period like last week,
|
||||
last month, or a date range. Trigger phrases include: "check timesheets", "timesheet completion",
|
||||
"review timesheets", "who hasn't logged hours", "timesheet audit", "check logged hours",
|
||||
"hours reported", or any message asking about timesheet status or missing hours.
|
||||
---
|
||||
|
||||
# Timesheet Checker
|
||||
|
||||
Use this skill when someone needs to know if employees have completed their timesheets for a given period. It pulls worklogs from Tempo, computes each person's completion against the Polish working calendar, and ranks employees by missing hours.
|
||||
|
||||
**In Claude Code, you run the full pipeline automatically.** The user provides a period — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This skill requires four credentials. The easiest way to provide them is a `.env` file — copy `.env.example` in the skill folder and fill in the values:
|
||||
|
||||
```
|
||||
cp {SKILL_DIR}/.env.example {SKILL_DIR}/.env
|
||||
# then edit {SKILL_DIR}/.env
|
||||
```
|
||||
|
||||
Or place the `.env` file in the working directory (where you run Claude Code) — that takes priority over the skill folder.
|
||||
|
||||
If the user prefers environment variables they can still use `export`, but the `.env` file removes the need to do that every session.
|
||||
|
||||
The four credentials:
|
||||
|
||||
| Variable | What it is | Where to get it |
|
||||
|---|---|---|
|
||||
| `TEMPO_API_TOKEN` | Tempo read token | Tempo → Settings → API Integration |
|
||||
| `JIRA_BASE_URL` | Your Atlassian URL | The URL you use to open Jira |
|
||||
| `JIRA_EMAIL` | Your Jira login email | Your Atlassian account email |
|
||||
| `JIRA_API_TOKEN` | Jira personal API token | [id.atlassian.com](https://id.atlassian.com) → Security → API tokens |
|
||||
|
||||
`JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` are used to resolve employee email addresses to Jira account IDs before querying Tempo. `TEMPO_API_TOKEN` is used to pull the actual worklogs.
|
||||
|
||||
## Setup
|
||||
|
||||
Determine `SKILL_DIR` from the location of this file. Set the output directory to `./timesheet-output/` in the current working directory. Create it if it does not exist.
|
||||
|
||||
## Load First
|
||||
|
||||
Read these reference files:
|
||||
|
||||
1. The employees config — check `./employees.md` in the current working directory first; if not present, fall back to `{SKILL_DIR}/references/employees.md`. Tell the user which one is being used.
|
||||
2. `{SKILL_DIR}/references/output-format.md` — the structure of `timesheet_data.json` and the final report.
|
||||
|
||||
## Extracting the period
|
||||
|
||||
The user may express the period in various ways. Convert to the CLI format before running:
|
||||
|
||||
| User says | CLI value | End date |
|
||||
|---|---|---|
|
||||
| "last week" | `last-week` | Last Friday |
|
||||
| "last month" | `last-month` | Last day of previous month |
|
||||
| "last year" | `last-year` | 31 December of previous year |
|
||||
| "yesterday" | `yesterday` | Yesterday |
|
||||
| "this week", "current week" | `current-week` | Yesterday |
|
||||
| "this month", "current month" | `current-month` | Yesterday |
|
||||
| "this year", "current year" | `current-year` | Yesterday |
|
||||
| "June", "June 2026" | `2026-06-01:2026-06-30` | Explicit |
|
||||
| Explicit dates | `YYYY-MM-DD:YYYY-MM-DD` | Explicit |
|
||||
|
||||
For `current-*` periods the end date is always yesterday — the current in-progress day is never included. If a user asks for "current week" on a Monday (the week only started today), there are no past working days yet; note this to the user.
|
||||
|
||||
If the user has not specified a period, ask: "Which period should I check? (e.g. last week, this month, yesterday, or a date range)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 - Fetch and compute.** Run this command with bash:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/preprocess.py \
|
||||
--period {period} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This fetches Polish public holidays from date.nager.at, computes working days, pulls all Tempo worklogs for the period, and writes `timesheet_data.json` to `./timesheet-output/`.
|
||||
|
||||
**Step 2 - Interpret.** Read `./timesheet-output/timesheet_data.json`. Note:
|
||||
- Any employee with `reported_hours: 0` — this may mean vacation, sick leave, or forgotten logging. Mention these explicitly.
|
||||
- If the period end date is today or in the future, the period is still open — note this so the user knows the data is partial.
|
||||
- The overall completion percentage and how many employees have gaps.
|
||||
- **Unknown reporters:** If `unknown_reporters` is non-empty, show the user a table of those people (name, email, hours logged) and ask them to add the missing employees to `employees.md`. Then continue with Steps 3–4 using only the known employees, noting that the report excludes the unknown reporters until they are added.
|
||||
|
||||
**Step 3 - Generate report.** Run this command with bash:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/postprocess.py \
|
||||
--data ./timesheet-output/timesheet_data.json \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This writes `timesheet_report.md`.
|
||||
|
||||
**Step 4 - Present results.** Show the user:
|
||||
- The summary block (expected / reported / overall completion %).
|
||||
- The full employee table from the report.
|
||||
- Any notable observations from Step 2.
|
||||
- "Full report saved to `./timesheet-output/timesheet_report.md`."
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never invent or estimate hours. All data comes from Tempo.
|
||||
- Working days are computed from the Polish public holidays API — do not hardcode holiday dates.
|
||||
- If `TEMPO_API_TOKEN` is not set, stop and ask the user for it. Do not proceed without it.
|
||||
- If any script step fails, show the error and stop. Do not proceed with partial output.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Employees
|
||||
|
||||
## Defaults
|
||||
|
||||
These values apply to every employee unless overridden in the Employees table.
|
||||
|
||||
| setting | value |
|
||||
| --- | --- |
|
||||
| country | PL |
|
||||
| expected_hours_per_day | 8 |
|
||||
|
||||
## Employees
|
||||
|
||||
Add a row for each employee. Use their work email address — the script looks up the Jira account ID automatically. Leave any column blank to inherit the default.
|
||||
|
||||
`country` is an ISO 3166-1 alpha-2 code (e.g. `PL`, `GB`, `DE`, `FR`). Public holidays for that country are fetched from date.nager.at. `start_date` is the employee's first working day — leave blank if they were employed for the full period.
|
||||
|
||||
| name | email | country | expected_hours_per_day | start_date |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Jan Kowalski | jan.kowalski@company.com | | | |
|
||||
| Anna Nowak | anna.nowak@company.com | | | |
|
||||
| Piotr Wiśniewski | piotr.wisniewski@company.com | | | 2026-05-15 |
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Output Format
|
||||
|
||||
## timesheet_data.json (written by preprocess.py)
|
||||
|
||||
Machine-readable intermediate file with all fetched and computed data.
|
||||
|
||||
```json
|
||||
{
|
||||
"period": {
|
||||
"start": "2026-06-16",
|
||||
"end": "2026-06-20",
|
||||
"label": "last week",
|
||||
"working_days": ["2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19", "2026-06-20"],
|
||||
"working_day_count": 5
|
||||
},
|
||||
"summary": {
|
||||
"total_employees": 3,
|
||||
"total_expected_hours": 120,
|
||||
"total_reported_hours": 96.5,
|
||||
"overall_completion_pct": 80.4,
|
||||
"employees_fully_complete": 1,
|
||||
"employees_with_gaps": 2
|
||||
},
|
||||
"employees": [
|
||||
{
|
||||
"name": "Jan Kowalski",
|
||||
"account_id": "557058:aaaa...",
|
||||
"expected_hours": 40,
|
||||
"reported_hours": 32.5,
|
||||
"missing_hours": 7.5,
|
||||
"completion_pct": 81.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
- `employees` is sorted by `missing_hours` descending — the employee furthest behind appears first.
|
||||
- `completion_pct` = `reported_hours / expected_hours × 100`. Can exceed 100 if overtime was logged.
|
||||
- `missing_hours` = `max(0, expected_hours - reported_hours)`.
|
||||
- Working days exclude weekends and Polish public holidays fetched from date.nager.at.
|
||||
|
||||
## timesheet_report.md (written by postprocess.py)
|
||||
|
||||
Manager-readable markdown report containing:
|
||||
- Period details (dates, working day count)
|
||||
- Summary block: expected total, reported total, overall completion %
|
||||
- Employee table ranked by missing hours (most behind first)
|
||||
- Completion badges: ✓ complete, ⚠ below 75%, ⚠⚠ below 50%
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Employees (sample)
|
||||
|
||||
## Defaults
|
||||
|
||||
| setting | value |
|
||||
| --- | --- |
|
||||
| country | PL |
|
||||
| expected_hours_per_day | 8 |
|
||||
|
||||
## Employees
|
||||
|
||||
Replace email addresses with your team's real work emails.
|
||||
|
||||
| name | email | country | expected_hours_per_day | start_date |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Jan Kowalski | jan.kowalski@company.com | | | |
|
||||
| Anna Nowak | anna.nowak@company.com | | | |
|
||||
| Piotr Wiśniewski | piotr.wisniewski@company.com | | | 2026-05-15 |
|
||||
| Marta Jabłońska | marta.jablonska@company.com | | | |
|
||||
| Tom Smith | tom.smith@company.com | GB | | |
|
||||
156
plugins/ivona/skills/timesheet-checker/scripts/postprocess.py
Normal file
156
plugins/ivona/skills/timesheet-checker/scripts/postprocess.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Format timesheet completion data into a markdown report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fmt_h(hours: float) -> str:
|
||||
"""Format hours with one decimal place, dropping .0 for whole numbers."""
|
||||
if hours == int(hours):
|
||||
return f"{int(hours)}h"
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
|
||||
def completion_badge(pct: float) -> str:
|
||||
if pct >= 100:
|
||||
return "✓"
|
||||
if pct >= 75:
|
||||
return ""
|
||||
if pct >= 50:
|
||||
return "⚠"
|
||||
return "⚠⚠"
|
||||
|
||||
|
||||
def build_report(data: dict[str, Any]) -> str:
|
||||
period = data["period"]
|
||||
summary = data["summary"]
|
||||
employees = data["employees"]
|
||||
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
n = summary["total_employees"]
|
||||
per_person = fmt_h(summary["total_expected_hours"] / n) if n else "0h"
|
||||
|
||||
wdays_by_country: dict[str, int] = period.get("working_days_by_country", {})
|
||||
if len(wdays_by_country) == 1:
|
||||
country, wdays = next(iter(wdays_by_country.items()))
|
||||
wdays_line = f"Working days: {wdays} ({country} calendar, public holidays excluded)"
|
||||
else:
|
||||
parts = ", ".join(f"{c}: {d}d" for c, d in sorted(wdays_by_country.items()))
|
||||
wdays_line = f"Working days by country: {parts} (public holidays excluded)"
|
||||
|
||||
lines = [
|
||||
"# Timesheet Completion Report",
|
||||
"",
|
||||
f"Generated: {generated_at}",
|
||||
f"Period: {period['label'].capitalize()} ({period['start']} to {period['end']})",
|
||||
wdays_line,
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Expected: **{fmt_h(summary['total_expected_hours'])}** ({n} employees × {per_person} avg)",
|
||||
f"- Reported: **{fmt_h(summary['total_reported_hours'])}**",
|
||||
f"- Overall completion: **{summary['overall_completion_pct']}%**",
|
||||
f"- Fully complete: {summary['employees_fully_complete']} of {n} employees",
|
||||
f"- With gaps: {summary['employees_with_gaps']} employees",
|
||||
"",
|
||||
"## Employees (ranked by missing hours)",
|
||||
"",
|
||||
"| Employee | Expected | Reported | Completion | Missing |",
|
||||
"| --- | ---: | ---: | ---: | ---: |",
|
||||
]
|
||||
|
||||
# Determine the most common working-day count to detect deviations
|
||||
max_wdays = max(wdays_by_country.values()) if wdays_by_country else 0
|
||||
for emp in employees:
|
||||
badge = completion_badge(emp["completion_pct"])
|
||||
missing = fmt_h(emp["missing_hours"]) if emp["missing_hours"] > 0 else "—"
|
||||
pct_str = f"{emp['completion_pct']}% {badge}".strip()
|
||||
name = emp["name"]
|
||||
notes = []
|
||||
eff = emp.get("effective_working_days", max_wdays)
|
||||
if emp.get("start_date") and eff < max_wdays:
|
||||
notes.append(f"from {emp['start_date']}")
|
||||
if emp.get("termination_date") and eff < max_wdays:
|
||||
notes.append(f"until {emp['termination_date']}")
|
||||
if len(wdays_by_country) > 1:
|
||||
notes.append(emp.get("country", ""))
|
||||
if notes:
|
||||
name += f" _({', '.join(n for n in notes if n)})_"
|
||||
lines.append(
|
||||
f"| {name} | {fmt_h(emp['expected_hours'])} "
|
||||
f"| {fmt_h(emp['reported_hours'])} | {pct_str} | {missing} |"
|
||||
)
|
||||
|
||||
out_of_range = [
|
||||
emp for emp in employees
|
||||
if emp.get("hours_before_start", 0) > 0 or emp.get("hours_after_termination", 0) > 0
|
||||
]
|
||||
if out_of_range:
|
||||
lines += [
|
||||
"",
|
||||
"## Hours logged outside employment period",
|
||||
"",
|
||||
"These hours are excluded from the completion calculation above.",
|
||||
"",
|
||||
"| Employee | Before start date | After termination date |",
|
||||
"| --- | ---: | ---: |",
|
||||
]
|
||||
for emp in out_of_range:
|
||||
before = fmt_h(emp["hours_before_start"]) if emp.get("hours_before_start", 0) > 0 else "—"
|
||||
after = fmt_h(emp["hours_after_termination"]) if emp.get("hours_after_termination", 0) > 0 else "—"
|
||||
lines.append(f"| {emp['name']} | {before} | {after} |")
|
||||
|
||||
countries_str = ", ".join(sorted(wdays_by_country.keys()))
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
f"_Working days source: [date.nager.at](https://date.nager.at) ({countries_str})._",
|
||||
"_Hours source: Tempo._",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
data_path = Path(args.data)
|
||||
if not data_path.exists():
|
||||
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report = build_report(data)
|
||||
report_path = output_dir / "timesheet_report.md"
|
||||
report_path.write_text(report, encoding="utf-8")
|
||||
print(f"Wrote {report_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Format timesheet completion data into a markdown report."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data",
|
||||
required=True,
|
||||
help="Path to timesheet_data.json produced by preprocess.py.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
required=True,
|
||||
help="Directory for timesheet_report.md.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
649
plugins/ivona/skills/timesheet-checker/scripts/preprocess.py
Normal file
649
plugins/ivona/skills/timesheet-checker/scripts/preprocess.py
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch Tempo worklogs and compute timesheet completion for a given period."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
"""Load the first .env file found from the given paths.
|
||||
|
||||
Sets missing keys into os.environ (existing env vars take priority).
|
||||
Returns the path that was loaded, or None if none were found.
|
||||
"""
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def normalize_key(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", clean_text(value).lower()).strip("_")
|
||||
|
||||
|
||||
# ── Period parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_period(period_str: str, today: date | None = None) -> tuple[date, date, str]:
|
||||
"""Return (start, end, human_label) for the given period string.
|
||||
|
||||
Supported keywords:
|
||||
last-week Previous Monday–Friday
|
||||
last-month Previous calendar month
|
||||
last-year Previous calendar year
|
||||
yesterday Yesterday only
|
||||
current-week Monday of this week to yesterday
|
||||
current-month 1st of this month to yesterday
|
||||
current-year 1st January of this year to yesterday
|
||||
YYYY-MM-DD:YYYY-MM-DD Explicit date range
|
||||
|
||||
For current-* periods the end date is always yesterday so the current
|
||||
in-progress day is excluded. If yesterday falls before the period start
|
||||
(e.g. calling current-week on a Monday), end < start and the period
|
||||
will contain zero working days — a warning is printed by run().
|
||||
"""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
s = period_str.strip().lower()
|
||||
|
||||
if s == "last-week":
|
||||
last_monday = today - timedelta(days=today.weekday() + 7)
|
||||
return last_monday, last_monday + timedelta(days=4), "last week"
|
||||
if s == "last-month":
|
||||
end = today.replace(day=1) - timedelta(days=1)
|
||||
return end.replace(day=1), end, "last month"
|
||||
if s == "last-year":
|
||||
return date(today.year - 1, 1, 1), date(today.year - 1, 12, 31), "last year"
|
||||
if s == "yesterday":
|
||||
return yesterday, yesterday, "yesterday"
|
||||
if s == "current-week":
|
||||
start = today - timedelta(days=today.weekday())
|
||||
return start, yesterday, "current week to date"
|
||||
if s == "current-month":
|
||||
return today.replace(day=1), yesterday, "current month to date"
|
||||
if s == "current-year":
|
||||
return date(today.year, 1, 1), yesterday, "current year to date"
|
||||
if ":" in s:
|
||||
parts = s.split(":", 1)
|
||||
start = date.fromisoformat(parts[0].strip())
|
||||
end = date.fromisoformat(parts[1].strip())
|
||||
return start, end, f"{start} to {end}"
|
||||
raise ValueError(
|
||||
f"Unknown period '{period_str}'. "
|
||||
f"Use last-week, last-month, last-year, yesterday, "
|
||||
f"current-week, current-month, current-year, or YYYY-MM-DD:YYYY-MM-DD."
|
||||
)
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_public_holidays(country: str, years: set[int]) -> set[date]:
|
||||
"""Fetch public holidays from date.nager.at for the given country and years.
|
||||
|
||||
country is an ISO 3166-1 alpha-2 code (e.g. PL, GB, DE, FR).
|
||||
Returns an empty set with a warning if the country is not supported.
|
||||
"""
|
||||
holidays: set[date] = set()
|
||||
for year in sorted(years):
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "timesheet-checker/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
items = json.loads(resp.read())
|
||||
year_holidays = {date.fromisoformat(item["date"]) for item in items}
|
||||
holidays |= year_holidays
|
||||
print(f" {country.upper()} {year}: {len(year_holidays)} public holidays")
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(
|
||||
f"Warning: country '{country.upper()}' not supported by date.nager.at "
|
||||
f"— no holidays will be excluded for {year}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Warning: could not fetch holidays for {country.upper()} {year}: {exc}", file=sys.stderr)
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"Warning: could not fetch holidays for {country.upper()} {year}: {exc}", file=sys.stderr)
|
||||
return holidays
|
||||
|
||||
|
||||
def get_working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
"""Return weekdays (Mon–Fri) in [start, end] that are not public holidays."""
|
||||
result = []
|
||||
current = start
|
||||
while current <= end:
|
||||
if current.weekday() < 5 and current not in holidays:
|
||||
result.append(current)
|
||||
current += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Employees config ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_table_row(line: str) -> bool:
|
||||
s = line.strip()
|
||||
return s.startswith("|") and s.endswith("|")
|
||||
|
||||
|
||||
def split_table_row(line: str) -> list[str]:
|
||||
s = line.strip().lstrip("|").rstrip("|")
|
||||
return [c.strip() for c in s.split("|")]
|
||||
|
||||
|
||||
def is_separator_row(cells: list[str]) -> bool:
|
||||
return all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in cells)
|
||||
|
||||
|
||||
def parse_markdown_tables(path: Path) -> list[dict[str, Any]]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
tables: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if not is_table_row(lines[i]):
|
||||
i += 1
|
||||
continue
|
||||
block: list[str] = []
|
||||
while i < len(lines) and is_table_row(lines[i]):
|
||||
block.append(lines[i])
|
||||
i += 1
|
||||
if len(block) < 3:
|
||||
continue
|
||||
headers = [normalize_key(c) for c in split_table_row(block[0])]
|
||||
if not is_separator_row(split_table_row(block[1])):
|
||||
continue
|
||||
rows = []
|
||||
for line in block[2:]:
|
||||
vals = split_table_row(line)
|
||||
if len(vals) != len(headers):
|
||||
raise ValueError(f"Malformed table row in {path}: {line!r}")
|
||||
rows.append(dict(zip(headers, vals)))
|
||||
tables.append({"headers": headers, "rows": rows})
|
||||
return tables
|
||||
|
||||
|
||||
def find_table(tables: list[dict[str, Any]], required_headers: set[str]) -> list[dict[str, str]]:
|
||||
for t in tables:
|
||||
if required_headers.issubset(set(t["headers"])):
|
||||
return t["rows"]
|
||||
raise ValueError(f"No table with headers {sorted(required_headers)} found in employees file.")
|
||||
|
||||
|
||||
def load_employees(path: Path) -> list[dict[str, Any]]:
|
||||
tables = parse_markdown_tables(path)
|
||||
|
||||
# Load defaults (optional table with setting/value columns).
|
||||
defaults: dict[str, Any] = {"country": "PL", "expected_hours_per_day": 8.0}
|
||||
try:
|
||||
for row in find_table(tables, {"setting", "value"}):
|
||||
key = normalize_key(row["setting"])
|
||||
val = clean_text(row["value"])
|
||||
if not val:
|
||||
continue
|
||||
if key == "country":
|
||||
defaults["country"] = val.upper()
|
||||
elif key == "expected_hours_per_day":
|
||||
try:
|
||||
defaults["expected_hours_per_day"] = float(val)
|
||||
except ValueError:
|
||||
print(f"Warning: invalid default expected_hours_per_day '{val}' — using 8.", file=sys.stderr)
|
||||
except ValueError:
|
||||
pass # no defaults table; built-in defaults apply
|
||||
|
||||
rows = find_table(tables, {"name", "email"})
|
||||
employees = []
|
||||
for row in rows:
|
||||
name = clean_text(row["name"])
|
||||
email = clean_text(row["email"]).lower()
|
||||
if not name or not email:
|
||||
continue
|
||||
|
||||
raw_h = clean_text(row.get("expected_hours_per_day", ""))
|
||||
try:
|
||||
expected_h = float(raw_h) if raw_h else defaults["expected_hours_per_day"]
|
||||
except ValueError:
|
||||
expected_h = defaults["expected_hours_per_day"]
|
||||
|
||||
raw_country = clean_text(row.get("country", "")).upper()
|
||||
country = raw_country if raw_country else defaults["country"]
|
||||
|
||||
raw_start = clean_text(row.get("start_date", ""))
|
||||
start_date: date | None = None
|
||||
if raw_start:
|
||||
try:
|
||||
start_date = date.fromisoformat(raw_start)
|
||||
except ValueError:
|
||||
print(
|
||||
f"Warning: could not parse start_date '{raw_start}' for {name} — ignoring.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
raw_term = clean_text(row.get("termination_date", ""))
|
||||
termination_date: date | None = None
|
||||
if raw_term:
|
||||
try:
|
||||
termination_date = date.fromisoformat(raw_term)
|
||||
except ValueError:
|
||||
print(
|
||||
f"Warning: could not parse termination_date '{raw_term}' for {name} — ignoring.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
employees.append({
|
||||
"name": name,
|
||||
"email": email,
|
||||
"expected_hours_per_day": expected_h,
|
||||
"country": country,
|
||||
"start_date": start_date,
|
||||
"termination_date": termination_date,
|
||||
})
|
||||
|
||||
if not employees:
|
||||
raise ValueError(f"No employees found in {path}. Check the table format.")
|
||||
return employees
|
||||
|
||||
|
||||
# ── Jira user lookup ──────────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_ids(
|
||||
emails: list[str],
|
||||
jira_base_url: str,
|
||||
jira_email: str,
|
||||
jira_token: str,
|
||||
) -> dict[str, str]:
|
||||
"""Return {email: account_id} by searching Jira's user API for each address."""
|
||||
credentials = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"}
|
||||
resolved: dict[str, str] = {}
|
||||
not_found: list[str] = []
|
||||
|
||||
for email in emails:
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=10"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
users = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print(
|
||||
"Error: Jira API returned 401 Unauthorized.\n"
|
||||
"Check JIRA_EMAIL and JIRA_API_TOKEN (generate at id.atlassian.com).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"Error: Jira API returned {exc.code} for {email}: {body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
match = next(
|
||||
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
|
||||
None,
|
||||
)
|
||||
if match:
|
||||
resolved[email] = match["accountId"]
|
||||
print(f" {email} → {match['accountId']}")
|
||||
else:
|
||||
not_found.append(email)
|
||||
|
||||
if not_found:
|
||||
print(
|
||||
f"Error: Could not find Jira account for: {', '.join(not_found)}\n"
|
||||
"Check the email addresses in employees.md or your JIRA_BASE_URL.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
# ── Tempo API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_tempo_worklogs(
|
||||
start: date,
|
||||
end: date,
|
||||
token: str,
|
||||
base_url: str,
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""Return {account_id: {date_str: hours}} for ALL reporters in the period.
|
||||
|
||||
Paginates automatically. Date strings are ISO format (YYYY-MM-DD).
|
||||
"""
|
||||
totals: dict[str, dict[str, float]] = {}
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = f"{base_url}/worklogs?from={start}&to={end}&limit=1000"
|
||||
page = 0
|
||||
while next_url:
|
||||
page += 1
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print(
|
||||
"Error: Tempo API returned 401 Unauthorized. Check your TEMPO_API_TOKEN.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error: Tempo API returned {exc.code}: {body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for worklog in data.get("results", []):
|
||||
aid = worklog.get("author", {}).get("accountId", "")
|
||||
date_str = worklog.get("startDate", "")
|
||||
if aid and date_str:
|
||||
if aid not in totals:
|
||||
totals[aid] = {}
|
||||
totals[aid][date_str] = (
|
||||
totals[aid].get(date_str, 0.0) + worklog.get("timeSpentSeconds", 0) / 3600
|
||||
)
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
print(f"Fetched worklogs (pages: {page})")
|
||||
return totals
|
||||
|
||||
|
||||
def resolve_account_info(
|
||||
account_ids: list[str],
|
||||
jira_base_url: str,
|
||||
jira_email: str,
|
||||
jira_token: str,
|
||||
) -> dict[str, dict]:
|
||||
"""Return {account_id: {display_name, email}} for the given Jira account IDs."""
|
||||
credentials = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"}
|
||||
result: dict[str, dict] = {}
|
||||
for aid in account_ids:
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user?accountId={aid}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
user = json.loads(resp.read())
|
||||
result[aid] = {
|
||||
"display_name": user.get("displayName", ""),
|
||||
"email": user.get("emailAddress", ""),
|
||||
}
|
||||
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||
result[aid] = {"display_name": f"Unknown ({aid})", "email": ""}
|
||||
return result
|
||||
|
||||
|
||||
# ── Stats computation ─────────────────────────────────────────────────────────
|
||||
|
||||
def compute_stats(
|
||||
employees: list[dict[str, Any]],
|
||||
period_start: date,
|
||||
period_end: date,
|
||||
holidays_by_country: dict[str, set[date]],
|
||||
worklogs: dict[str, dict[str, float]],
|
||||
) -> list[dict[str, Any]]:
|
||||
results = []
|
||||
for emp in employees:
|
||||
holidays = holidays_by_country.get(emp["country"], set())
|
||||
all_working_days = get_working_days(period_start, period_end, holidays)
|
||||
emp_start: date | None = emp.get("start_date")
|
||||
emp_term: date | None = emp.get("termination_date")
|
||||
emp_working_days = all_working_days
|
||||
if emp_start:
|
||||
emp_working_days = [d for d in emp_working_days if d >= emp_start]
|
||||
if emp_term:
|
||||
emp_working_days = [d for d in emp_working_days if d <= emp_term]
|
||||
expected_h = emp["expected_hours_per_day"] * len(emp_working_days)
|
||||
|
||||
# Partition logged hours into in-range vs out-of-range
|
||||
date_logs = worklogs.get(emp["account_id"], {})
|
||||
hours_in_range = 0.0
|
||||
hours_before_start = 0.0
|
||||
hours_after_termination = 0.0
|
||||
for date_str, h in date_logs.items():
|
||||
d = date.fromisoformat(date_str)
|
||||
if emp_start and d < emp_start:
|
||||
hours_before_start += h
|
||||
elif emp_term and d > emp_term:
|
||||
hours_after_termination += h
|
||||
else:
|
||||
hours_in_range += h
|
||||
|
||||
reported_h = round(hours_in_range, 2)
|
||||
hours_before_start = round(hours_before_start, 2)
|
||||
hours_after_termination = round(hours_after_termination, 2)
|
||||
missing_h = round(max(0.0, expected_h - reported_h), 2)
|
||||
pct = round((reported_h / expected_h * 100) if expected_h > 0 else 100.0, 1)
|
||||
results.append({
|
||||
"name": emp["name"],
|
||||
"account_id": emp["account_id"],
|
||||
"country": emp["country"],
|
||||
"start_date": emp_start.isoformat() if emp_start else None,
|
||||
"termination_date": emp_term.isoformat() if emp_term else None,
|
||||
"effective_working_days": len(emp_working_days),
|
||||
"expected_hours": expected_h,
|
||||
"reported_hours": reported_h,
|
||||
"hours_before_start": hours_before_start,
|
||||
"hours_after_termination": hours_after_termination,
|
||||
"missing_hours": missing_h,
|
||||
"completion_pct": pct,
|
||||
})
|
||||
return sorted(results, key=lambda r: r["missing_hours"], reverse=True)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_employees_path(cli_value: str | None) -> Path | None:
|
||||
"""Return the employees file to use, or None if not found.
|
||||
|
||||
Search order:
|
||||
1. Explicit --employees argument (if given)
|
||||
2. employees.md in the current working directory
|
||||
3. references/employees.md in the skill folder (next to this script)
|
||||
"""
|
||||
if cli_value:
|
||||
return Path(cli_value)
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
for candidate in (
|
||||
Path.cwd() / "employees.md",
|
||||
skill_dir / "references" / "employees.md",
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
# Load .env from working directory first, then skill directory as fallback.
|
||||
# Existing environment variables always take priority over .env values.
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = args.tempo_token or os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = args.jira_base_url or os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = args.jira_email or os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = args.jira_token or os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [
|
||||
name for name, val in [
|
||||
("TEMPO_API_TOKEN", tempo_token),
|
||||
("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email),
|
||||
("JIRA_API_TOKEN", jira_token),
|
||||
] if not val
|
||||
]
|
||||
if missing:
|
||||
print(
|
||||
f"Error: missing required credentials: {', '.join(missing)}\n"
|
||||
"Set them as environment variables or pass via CLI flags.\n"
|
||||
"See README.md for how to obtain each credential.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
start, end, label = parse_period(args.period)
|
||||
print(f"Period: {label} ({start} to {end})")
|
||||
|
||||
employees_path = _resolve_employees_path(args.employees)
|
||||
if employees_path is None:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
print(
|
||||
"Error: No employees.md found.\n"
|
||||
f"Looked in:\n"
|
||||
f" {Path.cwd() / 'employees.md'}\n"
|
||||
f" {skill_dir / 'references' / 'employees.md'}\n"
|
||||
"Create one in your project directory or pass --employees PATH.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
employees = load_employees(employees_path)
|
||||
print(f"Employees loaded: {len(employees)} (from {employees_path})")
|
||||
|
||||
countries = {e["country"] for e in employees}
|
||||
years = {start.year, end.year}
|
||||
print(f"Fetching public holidays from date.nager.at for: {', '.join(sorted(countries))} ...")
|
||||
holidays_by_country = {c: get_public_holidays(c, years) for c in countries}
|
||||
|
||||
print("Resolving Jira account IDs from email addresses...")
|
||||
email_to_id = resolve_account_ids(
|
||||
[e["email"] for e in employees], jira_base_url, jira_email, jira_token
|
||||
)
|
||||
for emp in employees:
|
||||
emp["account_id"] = email_to_id[emp["email"]]
|
||||
|
||||
known_ids = {e["account_id"] for e in employees}
|
||||
print(f"Fetching Tempo worklogs from {args.tempo_base_url}...")
|
||||
all_worklogs = fetch_tempo_worklogs(start, end, tempo_token, args.tempo_base_url)
|
||||
|
||||
# Detect reporters not in employees.md
|
||||
unknown_ids = [aid for aid in all_worklogs if aid not in known_ids]
|
||||
unknown_reporters: list[dict] = []
|
||||
if unknown_ids:
|
||||
print(f"Resolving {len(unknown_ids)} unknown reporter(s) from Jira...")
|
||||
account_info = resolve_account_info(unknown_ids, jira_base_url, jira_email, jira_token)
|
||||
for aid in unknown_ids:
|
||||
info = account_info[aid]
|
||||
total_h = round(sum(all_worklogs[aid].values()), 2)
|
||||
unknown_reporters.append({
|
||||
"account_id": aid,
|
||||
"display_name": info["display_name"],
|
||||
"email": info["email"],
|
||||
"reported_hours": total_h,
|
||||
})
|
||||
print(f" Not in employees.md: {info['display_name']} ({info['email']}) — {total_h:.1f}h")
|
||||
unknown_reporters.sort(key=lambda r: r["reported_hours"], reverse=True)
|
||||
|
||||
stats = compute_stats(employees, start, end, holidays_by_country, all_worklogs)
|
||||
total_expected = sum(r["expected_hours"] for r in stats)
|
||||
total_reported = round(sum(r["reported_hours"] for r in stats), 2)
|
||||
overall_pct = round((total_reported / total_expected * 100) if total_expected > 0 else 100.0, 1)
|
||||
|
||||
# Annotate with email for readability
|
||||
for stat in stats:
|
||||
emp = next(e for e in employees if e["account_id"] == stat["account_id"])
|
||||
stat["email"] = emp["email"]
|
||||
|
||||
working_days_by_country = {
|
||||
c: len(get_working_days(start, end, holidays_by_country[c]))
|
||||
for c in sorted(countries)
|
||||
}
|
||||
|
||||
output = {
|
||||
"period": {
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
"label": label,
|
||||
"working_days_by_country": working_days_by_country,
|
||||
},
|
||||
"summary": {
|
||||
"total_employees": len(stats),
|
||||
"total_expected_hours": total_expected,
|
||||
"total_reported_hours": total_reported,
|
||||
"overall_completion_pct": overall_pct,
|
||||
"employees_fully_complete": sum(1 for r in stats if r["completion_pct"] >= 100),
|
||||
"employees_with_gaps": sum(1 for r in stats if r["completion_pct"] < 100),
|
||||
},
|
||||
"employees": stats,
|
||||
"unknown_reporters": unknown_reporters,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / "timesheet_data.json"
|
||||
out_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch Tempo worklogs and compute timesheet completion."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--period",
|
||||
required=True,
|
||||
help="Period to check: 'last-week', 'last-month', or 'YYYY-MM-DD:YYYY-MM-DD'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--employees",
|
||||
help=(
|
||||
"Path to the employees markdown config file. "
|
||||
"If omitted, looks for employees.md in the current directory, "
|
||||
"then references/employees.md in the skill folder."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
required=True,
|
||||
help="Directory for timesheet_data.json.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tempo-token",
|
||||
help="Tempo API token. Defaults to TEMPO_API_TOKEN env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tempo-base-url",
|
||||
default="https://api.tempo.io/4",
|
||||
help="Tempo API base URL. Default: https://api.tempo.io/4 (Tempo Cloud).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jira-base-url",
|
||||
help="Jira base URL, e.g. https://company.atlassian.net. Defaults to JIRA_BASE_URL env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jira-email",
|
||||
help="Email address used to authenticate with Jira. Defaults to JIRA_EMAIL env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jira-token",
|
||||
help="Jira personal API token. Defaults to JIRA_API_TOKEN env var.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
5
plugins/timesheet/.claude-plugin/plugin.json
Normal file
5
plugins/timesheet/.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "timesheet",
|
||||
"version": "1.0.0",
|
||||
"description": "Check and log your own Tempo timesheet, and see what to put on your invoice for a given month."
|
||||
}
|
||||
149
plugins/timesheet/skills/check-my-timesheet/SKILL.md
Normal file
149
plugins/timesheet/skills/check-my-timesheet/SKILL.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
name: check-my-timesheet
|
||||
description: >
|
||||
Two behaviours in one skill:
|
||||
(1) CHECK — show the current user's timesheet gaps for a given period. Compares
|
||||
Tempo worklogs against the working calendar and flags missing or partial days.
|
||||
(2) LOG — help the user log time to Tempo. User provides a description or issue key;
|
||||
Claude searches Jira if needed, confirms the entry, then posts it.
|
||||
TRIGGER check when user says: "check my timesheet", "timesheet gaps", "where am I missing
|
||||
hours", "did I log all hours", "show my missing time".
|
||||
TRIGGER log when user says: "log time", "add time entry", "log hours", "report hours",
|
||||
"book time on", "add worklog".
|
||||
---
|
||||
|
||||
# Check My Timesheet
|
||||
|
||||
Personal timesheet checker and time logger for the user identified by `JIRA_EMAIL`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
All configuration lives in the `.env` file in the project root:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `TEMPO_API_TOKEN` | Read and write Tempo worklogs | — |
|
||||
| `JIRA_BASE_URL` | Resolve account ID and search issues | — |
|
||||
| `JIRA_EMAIL` | Identifies whose timesheet to read / who is logging | — |
|
||||
| `JIRA_API_TOKEN` | Authenticate with Jira | — |
|
||||
| `MY_TIMESHEET_COUNTRY` | Country code for public holiday calendar (`PL`, `GB`, `DE`…) | `PL` |
|
||||
| `MY_TIMESHEET_HOURS_PER_DAY` | Expected working hours per day | `8` |
|
||||
|
||||
## Setup
|
||||
|
||||
Determine `SKILL_DIR` from the location of this file.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour 1 — Check timesheet gaps
|
||||
|
||||
### Trigger phrases
|
||||
"check my timesheet", "timesheet gaps", "where am I missing hours", "did I log all hours",
|
||||
"show my time", "show my missing time"
|
||||
|
||||
### Workflow
|
||||
|
||||
**Step 1 — Ask for period.** Always ask — never assume a default:
|
||||
> "Which period should I check? (e.g. this week, last week, June 2026, or a date range)"
|
||||
|
||||
Convert natural language to `YYYY-MM-DD:YYYY-MM-DD`:
|
||||
- "this week" → Monday of current week to yesterday
|
||||
- "last week" → previous Monday–Friday
|
||||
- "June 2026" → `2026-06-01:2026-06-30`
|
||||
- "today" → today:today
|
||||
|
||||
**Step 2 — Run check:**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/check.py \
|
||||
--period {YYYY-MM-DD:YYYY-MM-DD} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
**Step 3 — Present results.** Read the output JSON and show:
|
||||
|
||||
1. Summary: `Logged X h / Y h expected (Z working days)`
|
||||
2. If `missing_hours == 0`: "✓ Timesheet complete for this period."
|
||||
3. Otherwise, a table of all working days — highlight missing and partial:
|
||||
|
||||
```
|
||||
| Date | Logged | Status |
|
||||
| 2026-06-23 | 8.0 h | ✓ OK |
|
||||
| 2026-06-24 | 4.0 h | ⚠ Partial (4h missing) |
|
||||
| 2026-06-25 | 0.0 h | ✗ Missing |
|
||||
```
|
||||
|
||||
4. List only the problem days prominently if there are many OK days.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour 2 — Log time
|
||||
|
||||
### Trigger phrases
|
||||
"log time", "add time entry", "log hours", "report hours", "book time on", "add worklog",
|
||||
"log X hours on", "log X hours for"
|
||||
|
||||
### Workflow
|
||||
|
||||
**Step 1 — Collect what is needed:**
|
||||
|
||||
From the user's message extract or ask for:
|
||||
- **Issue** — Jira issue key (e.g. `IAA-42`) or a description to search by
|
||||
- **Hours** — number of hours (decimals OK: 1.5 = 1h30m)
|
||||
- **Date** — which day to log on (default: today if not mentioned)
|
||||
- **Description** — optional note for the worklog
|
||||
|
||||
If issue key is not provided but a description is, run a search:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/log.py \
|
||||
--mode search \
|
||||
--query "{description}"
|
||||
```
|
||||
|
||||
Present the results (up to 5 issues) and ask the user to pick one or confirm.
|
||||
If no results found, ask the user to provide the issue key directly.
|
||||
|
||||
**Step 2 — Preview (run without --confirmed).** Always run the preview step first:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/log.py \
|
||||
--mode log \
|
||||
--issue {KEY} \
|
||||
--hours {N} \
|
||||
--date {YYYY-MM-DD} \
|
||||
--description "{description}"
|
||||
```
|
||||
|
||||
The script prints the pending worklog and exits without writing anything.
|
||||
If hours exceed `MY_TIMESHEET_HOURS_PER_DAY` it also prints a `⚠ WARNING` line.
|
||||
|
||||
**Step 3 — Show preview to user and ask for confirmation.** Present the full script output
|
||||
including any warnings. Wait for explicit yes/no. Do NOT proceed without explicit confirmation.
|
||||
|
||||
**Step 4 — Log (only after yes, with --confirmed):**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/log.py \
|
||||
--mode log \
|
||||
--issue {KEY} \
|
||||
--hours {N} \
|
||||
--date {YYYY-MM-DD} \
|
||||
--description "{description}" \
|
||||
--confirmed
|
||||
```
|
||||
|
||||
**Step 5 — Report result.** Show the worklog ID and a confirmation message.
|
||||
Offer to check the timesheet again to verify the entry is reflected.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never log time without explicit user confirmation.
|
||||
- Always run the preview step first — the `--confirmed` flag must only be added after the user says yes.
|
||||
- If the preview shows a `⚠ WARNING` (hours exceed daily limit), make sure the user acknowledges it before confirming.
|
||||
- If `my_timesheet.md` is missing, stop and show the user the template.
|
||||
- If `JIRA_EMAIL` is not set, stop and ask the user to set it in `.env`.
|
||||
- If any script step fails, show the error and stop.
|
||||
- Do not guess the issue key — always search or ask if unsure.
|
||||
250
plugins/timesheet/skills/check-my-timesheet/scripts/check.py
Normal file
250
plugins/timesheet/skills/check-my-timesheet/scripts/check.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check the current user's timesheet for gaps against the working calendar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import calendar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Config loading ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
"""Read timesheet config from env vars MY_TIMESHEET_COUNTRY and MY_TIMESHEET_HOURS_PER_DAY."""
|
||||
country = os.environ.get("MY_TIMESHEET_COUNTRY", "PL").strip().upper()
|
||||
raw_hpd = os.environ.get("MY_TIMESHEET_HOURS_PER_DAY", "8").strip()
|
||||
try:
|
||||
hours_per_day = float(raw_hpd)
|
||||
except ValueError:
|
||||
print(f"Warning: invalid MY_TIMESHEET_HOURS_PER_DAY '{raw_hpd}' — using 8.", file=sys.stderr)
|
||||
hours_per_day = 8.0
|
||||
return {"country": country, "hours_per_day": hours_per_day}
|
||||
|
||||
|
||||
# ── Period parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_period(period_str: str) -> tuple[date, date]:
|
||||
s = period_str.strip()
|
||||
if ":" in s:
|
||||
a, b = s.split(":", 1)
|
||||
return date.fromisoformat(a.strip()), date.fromisoformat(b.strip())
|
||||
raise ValueError(f"Invalid period '{period_str}'. Use YYYY-MM-DD:YYYY-MM-DD.")
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_holidays(country: str, years: set[int]) -> set[date]:
|
||||
holidays: set[date] = set()
|
||||
for year in sorted(years):
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "check-my-timesheet/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
holidays |= {date.fromisoformat(i["date"]) for i in json.loads(r.read())}
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
|
||||
return holidays
|
||||
|
||||
|
||||
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
result, cur = [], start
|
||||
while cur <= end:
|
||||
if cur.weekday() < 5 and cur not in holidays:
|
||||
result.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Jira account lookup ───────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
users = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira 401. Check JIRA_EMAIL and JIRA_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise
|
||||
match = next((u for u in users if u.get("emailAddress", "").lower() == email.lower()), None)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
# ── Tempo worklogs ────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_worklogs(account_id: str, start: date, end: date,
|
||||
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
|
||||
results: list[dict] = []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = (
|
||||
f"{base_url}/worklogs/user/{account_id}?from={start}&to={end}&limit=1000"
|
||||
)
|
||||
while next_url:
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
|
||||
results.extend(data.get("results", []))
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
return results
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [("TEMPO_API_TOKEN", tempo_token), ("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token)] if not v]
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cfg = load_config()
|
||||
print(f"Config: country={cfg['country']}, hours_per_day={cfg['hours_per_day']}")
|
||||
|
||||
try:
|
||||
start, end = parse_period(args.period)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Period: {start} – {end}")
|
||||
print(f"User: {jira_email}")
|
||||
|
||||
print("Resolving Jira account...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
print(f" {jira_email} → {account_id}")
|
||||
|
||||
print("Fetching public holidays...")
|
||||
holidays = get_holidays(cfg["country"], {start.year, end.year})
|
||||
wdays = working_days(start, end, holidays)
|
||||
expected_hours = len(wdays) * cfg["hours_per_day"]
|
||||
print(f" Working days: {len(wdays)} Expected: {expected_hours:.0f}h")
|
||||
|
||||
print("Fetching Tempo worklogs...")
|
||||
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
|
||||
print(f" Worklogs: {len(worklogs)}")
|
||||
|
||||
# Aggregate hours per date
|
||||
hours_by_date: dict[str, float] = {}
|
||||
for wl in worklogs:
|
||||
d = wl.get("startDate", "")
|
||||
if d:
|
||||
hours_by_date[d] = hours_by_date.get(d, 0.0) + wl.get("timeSpentSeconds", 0) / 3600
|
||||
|
||||
# Classify each working day
|
||||
hpd = cfg["hours_per_day"]
|
||||
days_out = []
|
||||
for d in wdays:
|
||||
logged = round(hours_by_date.get(d.isoformat(), 0.0), 2)
|
||||
if logged >= hpd:
|
||||
status = "ok"
|
||||
entry = {"date": d.isoformat(), "logged_hours": logged, "status": "ok"}
|
||||
elif logged > 0:
|
||||
missing_h = round(hpd - logged, 2)
|
||||
status = "partial"
|
||||
entry = {"date": d.isoformat(), "logged_hours": logged,
|
||||
"status": "partial", "missing": missing_h}
|
||||
else:
|
||||
status = "missing"
|
||||
entry = {"date": d.isoformat(), "logged_hours": 0.0,
|
||||
"status": "missing", "missing": hpd}
|
||||
days_out.append(entry)
|
||||
|
||||
total_logged = round(sum(hours_by_date.get(d.isoformat(), 0.0) for d in wdays), 2)
|
||||
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
|
||||
|
||||
output = {
|
||||
"period": {"start": str(start), "end": str(end)},
|
||||
"config": cfg,
|
||||
"working_days": len(wdays),
|
||||
"expected_hours": expected_hours,
|
||||
"total_logged_hours": total_logged,
|
||||
"missing_hours": missing_hours,
|
||||
"days": days_out,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / f"my_check_{start}_{end}.json"
|
||||
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
# Quick summary
|
||||
problem_days = [d for d in days_out if d["status"] != "ok"]
|
||||
print(f"\nLogged: {total_logged:.1f}h / {expected_hours:.0f}h expected")
|
||||
if not problem_days:
|
||||
print("✓ Timesheet complete.")
|
||||
else:
|
||||
missing_days = [d for d in problem_days if d["status"] == "missing"]
|
||||
partial_days = [d for d in problem_days if d["status"] == "partial"]
|
||||
if missing_days:
|
||||
print(f"✗ Missing ({len(missing_days)} day(s)): "
|
||||
+ ", ".join(d["date"] for d in missing_days))
|
||||
if partial_days:
|
||||
print(f"⚠ Partial ({len(partial_days)} day(s)): "
|
||||
+ ", ".join(f"{d['date']} ({d['logged_hours']}h)" for d in partial_days))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Check timesheet gaps for the current user.")
|
||||
p.add_argument("--period", required=True, help="YYYY-MM-DD:YYYY-MM-DD")
|
||||
p.add_argument("--output-dir", required=True)
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
280
plugins/timesheet/skills/check-my-timesheet/scripts/log.py
Normal file
280
plugins/timesheet/skills/check-my-timesheet/scripts/log.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Search Jira issues or log a time entry to Tempo.
|
||||
|
||||
Modes:
|
||||
--mode search --query "text"
|
||||
Search Jira for matching issues. Returns JSON list of matches.
|
||||
|
||||
--mode log --issue KEY --hours N --date YYYY-MM-DD [--description "..."]
|
||||
Post a worklog to Tempo. Requires confirmation from the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Jira helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def jira_headers(jira_email: str, jira_token: str) -> dict:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
return {"Authorization": f"Basic {creds}", "Accept": "application/json",
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=jira_headers(jira_email, jira_token))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
users = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira 401. Check JIRA_EMAIL and JIRA_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise
|
||||
match = next((u for u in users if u.get("emailAddress", "").lower() == email.lower()), None)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
def search_issues(query: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> list[dict]:
|
||||
"""Search Jira for issues matching the query. Uses POST /search/jql (v3)."""
|
||||
body = json.dumps({
|
||||
"jql": f'text ~ "{query}" ORDER BY updated DESC',
|
||||
"fields": ["key", "summary", "status", "assignee"],
|
||||
"maxResults": 5,
|
||||
}).encode()
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/search/jql"
|
||||
req = urllib.request.Request(url, data=body,
|
||||
headers=jira_headers(jira_email, jira_token),
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body_txt = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Jira search failed ({exc.code}): {body_txt[:300]}") from exc
|
||||
results = []
|
||||
for issue in data.get("issues", []):
|
||||
fields = issue.get("fields", {})
|
||||
results.append({
|
||||
"key": issue["key"],
|
||||
"summary": fields.get("summary", ""),
|
||||
"status": fields.get("status", {}).get("name", ""),
|
||||
"assignee": (fields.get("assignee") or {}).get("displayName", "unassigned"),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_issue(issue_key: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> dict:
|
||||
"""Fetch id and summary of a Jira issue. Returns {"id": int, "summary": str}."""
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/issue/{issue_key}?fields=summary,id"
|
||||
req = urllib.request.Request(url, headers=jira_headers(jira_email, jira_token))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
return {
|
||||
"id": int(data.get("id", 0)),
|
||||
"summary": data.get("fields", {}).get("summary", ""),
|
||||
}
|
||||
except Exception:
|
||||
return {"id": 0, "summary": ""}
|
||||
|
||||
|
||||
# ── Tempo log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def log_worklog(account_id: str, issue_id: int, hours: float,
|
||||
log_date: str, description: str, tempo_token: str,
|
||||
base_url: str = "https://api.tempo.io/4") -> dict:
|
||||
"""POST a new worklog to Tempo. Returns the created worklog data."""
|
||||
body = {
|
||||
"issueId": issue_id,
|
||||
"timeSpentSeconds": int(hours * 3600),
|
||||
"startDate": log_date,
|
||||
"startTime": "09:00:00",
|
||||
"authorAccountId": account_id,
|
||||
}
|
||||
if description:
|
||||
body["description"] = description
|
||||
|
||||
data = json.dumps(body).encode()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {tempo_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
req = urllib.request.Request(f"{base_url}/worklogs", data=data,
|
||||
headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body_txt = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body_txt[:300]}") from exc
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email),
|
||||
("JIRA_API_TOKEN", jira_token)] if not v]
|
||||
if args.mode == "log" and not tempo_token:
|
||||
missing.append("TEMPO_API_TOKEN")
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Search mode ───────────────────────────────────────────────────────────
|
||||
if args.mode == "search":
|
||||
if not args.query:
|
||||
print("Error: --query is required for search mode.", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Searching Jira for: {args.query!r}")
|
||||
try:
|
||||
results = search_issues(args.query, jira_base_url, jira_email, jira_token)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
if not results:
|
||||
print("No issues found.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# ── Log mode ──────────────────────────────────────────────────────────────
|
||||
if not args.issue:
|
||||
print("Error: --issue is required for log mode.", file=sys.stderr)
|
||||
return 1
|
||||
if not args.hours or args.hours <= 0:
|
||||
print("Error: --hours must be a positive number.", file=sys.stderr)
|
||||
return 1
|
||||
if not args.date:
|
||||
print("Error: --date is required for log mode (YYYY-MM-DD).", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
issue_key = args.issue.strip().upper()
|
||||
if not re.fullmatch(r"[A-Z][A-Z0-9]+-\d+", issue_key):
|
||||
print(f"Error: '{issue_key}' is not a valid Jira issue key (expected e.g. IAA-42).",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Read configured daily limit for over-hours warning
|
||||
try:
|
||||
hours_per_day = float(os.environ.get("MY_TIMESHEET_HOURS_PER_DAY", "8"))
|
||||
except ValueError:
|
||||
hours_per_day = 8.0
|
||||
|
||||
print(f"Resolving Jira account for {jira_email}...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
|
||||
# Fetch issue ID and summary (Tempo v4 requires numeric issueId, not issueKey)
|
||||
issue_info = get_issue(issue_key, jira_base_url, jira_email, jira_token)
|
||||
issue_id = issue_info["id"]
|
||||
summary = issue_info["summary"]
|
||||
if not issue_id:
|
||||
print(f"Error: could not resolve numeric ID for {issue_key}.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Guardrail: over-hours warning ─────────────────────────────────────────
|
||||
if args.hours > hours_per_day:
|
||||
print(
|
||||
f"\n⚠ WARNING: {args.hours}h exceeds the configured daily limit "
|
||||
f"of {hours_per_day}h (MY_TIMESHEET_HOURS_PER_DAY).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# ── Preview (always shown) ────────────────────────────────────────────────
|
||||
print(f"\nPending worklog:")
|
||||
print(f" Issue: {issue_key}" + (f" — {summary}" if summary else ""))
|
||||
print(f" Hours: {args.hours}h")
|
||||
print(f" Date: {args.date}")
|
||||
if args.description:
|
||||
print(f" Note: {args.description}")
|
||||
|
||||
# ── Guardrail: require explicit confirmation ───────────────────────────────
|
||||
if not args.confirmed:
|
||||
print(
|
||||
"\n⏸ Not logged. Re-run with --confirmed to post this worklog to Tempo."
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
result = log_worklog(
|
||||
account_id=account_id,
|
||||
issue_id=issue_id,
|
||||
hours=args.hours,
|
||||
log_date=args.date,
|
||||
description=args.description or "",
|
||||
tempo_token=tempo_token,
|
||||
)
|
||||
worklog_id = result.get("tempoWorklogId") or result.get("id", "?")
|
||||
print(f"\n✓ Worklog logged. Tempo ID: {worklog_id}")
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(f"\nError: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Search Jira or log time to Tempo.")
|
||||
p.add_argument("--mode", required=True, choices=["search", "log"])
|
||||
p.add_argument("--query", help="Search text (search mode)")
|
||||
p.add_argument("--issue", help="Jira issue key, e.g. IAA-42 (log mode)")
|
||||
p.add_argument("--hours", type=float, help="Hours to log, e.g. 2.5 (log mode)")
|
||||
p.add_argument("--date", help="Date YYYY-MM-DD (log mode)")
|
||||
p.add_argument("--description", default="", help="Optional worklog note (log mode)")
|
||||
p.add_argument("--confirmed", action="store_true",
|
||||
help="Must be passed to actually post the worklog. Without it the script "
|
||||
"only shows a preview.")
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
84
plugins/timesheet/skills/invoice-prep/SKILL.md
Normal file
84
plugins/timesheet/skills/invoice-prep/SKILL.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
name: invoice-prep
|
||||
description: >
|
||||
Show what to put on an invoice for a selected month by checking the user's
|
||||
own Tempo timesheets. Lists every Jira project code and the total hours logged
|
||||
against it. Warns if there are missing hours or days with no logged time.
|
||||
TRIGGER when user asks what to put on an invoice, what to invoice, invoice
|
||||
preparation, timesheet summary for invoicing, or similar phrases.
|
||||
Trigger phrases: "what should I put on an invoice", "prepare my invoice",
|
||||
"invoice for [month]", "what to invoice for", "check my TS for invoice".
|
||||
---
|
||||
|
||||
# Invoice Preparation
|
||||
|
||||
Use this skill when someone wants to know what project codes and hours to put on their invoice for a given month. It reads their own Tempo worklogs, groups hours by Jira project, and warns about any missing time.
|
||||
|
||||
**In Claude Code, you run the pipeline automatically.** The user provides a month — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Uses the same credentials as the timesheet-checker — no extra setup needed:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `TEMPO_API_TOKEN` | Fetch worklogs from Tempo |
|
||||
| `JIRA_BASE_URL` | Resolve the user's account ID |
|
||||
| `JIRA_EMAIL` | Identifies whose timesheets to read |
|
||||
| `JIRA_API_TOKEN` | Authenticate with Jira |
|
||||
|
||||
All four must be present in the `.env` file in the project root.
|
||||
|
||||
## Extracting the month
|
||||
|
||||
Convert natural language to `YYYY-MM` before running:
|
||||
|
||||
| User says | CLI value |
|
||||
|---|---|
|
||||
| "last month" | Previous calendar month |
|
||||
| "this month" | Current calendar month |
|
||||
| "June", "June 2026" | `2026-06` |
|
||||
| "May" | `2026-05` (current year) |
|
||||
|
||||
If no month is specified, ask: "Which month should I check? (e.g. last month, June 2026)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 — Run the script:**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/run.py \
|
||||
--month {YYYY-MM} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This resolves the user's Jira account, fetches their Tempo worklogs for the full calendar month, groups hours by Jira project code, checks completeness against the Polish working calendar, and writes `invoice_prep_{YYYY-MM}.json` to `./timesheet-output/`.
|
||||
|
||||
**Step 2 — Present results.** Read the output JSON and show the user:
|
||||
|
||||
1. A table of project codes and hours — formatted ready to copy onto an invoice.
|
||||
Include **every project in the `projects` array**. Do NOT label any project as "non-billable" or omit it from the table — the script already excludes ignored issues; everything remaining is billable:
|
||||
|
||||
```
|
||||
| Project | Hours |
|
||||
| IAA | 45.5 |
|
||||
| PROJ | 22.0 |
|
||||
```
|
||||
|
||||
Below the table, note ignored issues and their hours as a parenthetical: `(INTERNAL-1: 40h ignored per config.)` — do not mix them into the billable table.
|
||||
|
||||
2. The **total billable hours** from `total_billable_hours` (not `total_logged_hours`).
|
||||
|
||||
3. If `missing_hours > 0`: a clear warning showing:
|
||||
- How many hours are missing vs the expected total for the month
|
||||
- Which specific dates have no logged time (up to 10; if more, show count)
|
||||
- Reminder to complete the timesheet before issuing the invoice
|
||||
|
||||
4. If `total_logged_hours == 0`: warn that no hours were found at all for the month.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never fabricate or estimate hours. All data comes from Tempo.
|
||||
- If `JIRA_EMAIL` is not set, stop and tell the user.
|
||||
- If any script step fails, show the error and stop.
|
||||
- Always remind the user that incomplete timesheets will cause Jira compliance failures when the invoice is checked.
|
||||
353
plugins/timesheet/skills/invoice-prep/scripts/run.py
Normal file
353
plugins/timesheet/skills/invoice-prep/scripts/run.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate invoice line items from the user's own Tempo worklogs for a given month.
|
||||
|
||||
Groups hours by Jira project code and reports missing time vs the Polish
|
||||
working calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import calendar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Period helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_month(month_str: str) -> tuple[date, date]:
|
||||
"""Parse 'YYYY-MM' into (first_day, last_day) of that month."""
|
||||
s = month_str.strip()
|
||||
if not re.fullmatch(r"\d{4}-\d{2}", s):
|
||||
raise ValueError(f"Invalid month '{month_str}'. Use YYYY-MM.")
|
||||
year, month = int(s[:4]), int(s[5:7])
|
||||
first = date(year, month, 1)
|
||||
last = date(year, month, calendar.monthrange(year, month)[1])
|
||||
return first, last
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_public_holidays(country: str, year: int) -> set[date]:
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "invoice-prep/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return {date.fromisoformat(item["date"]) for item in json.loads(resp.read())}
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
|
||||
return set()
|
||||
|
||||
|
||||
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
result, cur = [], start
|
||||
while cur <= end:
|
||||
if cur.weekday() < 5 and cur not in holidays:
|
||||
result.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Jira account lookup ───────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
users = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira API returned 401. Check JIRA_EMAIL and JIRA_API_TOKEN.",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Jira API {exc.code}") from exc
|
||||
match = next(
|
||||
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
|
||||
None,
|
||||
)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
# ── Tempo worklogs ────────────────────────────────────────────────────────────
|
||||
|
||||
_PROJECT_CODE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})-\d+\b")
|
||||
|
||||
|
||||
def load_jira_ignore(path: Path) -> set[str]:
|
||||
"""Load issue keys to ignore from a markdown table in jira_ignore.md."""
|
||||
if not path.exists():
|
||||
return set()
|
||||
ignored: set[str] = set()
|
||||
in_table = False
|
||||
header_seen = False
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not (line.startswith("|") and line.endswith("|")):
|
||||
in_table = False
|
||||
header_seen = False
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
if not header_seen:
|
||||
# First row is header or separator
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
|
||||
continue # separator row
|
||||
header_seen = True
|
||||
in_table = True
|
||||
continue
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
|
||||
continue # separator row
|
||||
if in_table and cells and cells[0]:
|
||||
ignored.add(cells[0].strip().upper())
|
||||
return ignored
|
||||
|
||||
|
||||
def extract_project_code(issue_key: str) -> str | None:
|
||||
"""Extract project code from a Jira issue key, e.g. 'IAA' from 'IAA-42'."""
|
||||
m = _PROJECT_CODE_RE.match(issue_key.strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def fetch_worklogs(account_id: str, start: date, end: date,
|
||||
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
|
||||
"""Return all Tempo worklog entries for the account in [start, end]."""
|
||||
results: list[dict] = []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = (
|
||||
f"{base_url}/worklogs/user/{account_id}"
|
||||
f"?from={start}&to={end}&limit=1000"
|
||||
)
|
||||
while next_url:
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo API returned 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
|
||||
results.extend(data.get("results", []))
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
return results
|
||||
|
||||
|
||||
def resolve_issue_keys(worklogs: list[dict], jira_email: str,
|
||||
jira_token: str) -> dict[int, str]:
|
||||
"""
|
||||
Resolve Jira issue IDs → keys by calling each issue's self URL directly.
|
||||
Uses the self URL already present in each Tempo worklog entry.
|
||||
"""
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
seen: dict[int, str] = {}
|
||||
for wl in worklogs:
|
||||
issue = wl.get("issue", {})
|
||||
issue_id = issue.get("id")
|
||||
if not issue_id or int(issue_id) in seen:
|
||||
continue
|
||||
self_url = issue.get("self", "")
|
||||
if not self_url:
|
||||
continue
|
||||
# Swap api/2 → api/3 if needed; append fields filter
|
||||
url = re.sub(r"/rest/api/\d+/", "/rest/api/3/", self_url) + "?fields=key"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
seen[int(issue_id)] = data.get("key", "")
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not resolve issue {issue_id}: {exc}", file=sys.stderr)
|
||||
return seen
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [
|
||||
("TEMPO_API_TOKEN", tempo_token), ("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token),
|
||||
] if not v]
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
start, end = parse_month(args.month)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load ignore list
|
||||
ignore_path = Path.cwd() / "jira_ignore.md"
|
||||
ignored_keys = load_jira_ignore(ignore_path)
|
||||
if ignored_keys:
|
||||
print(f"Ignoring issues: {', '.join(sorted(ignored_keys))}")
|
||||
|
||||
print(f"Month: {args.month} ({start} – {end})")
|
||||
print(f"User: {jira_email}")
|
||||
|
||||
# Resolve account ID
|
||||
print("Resolving Jira account...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
print(f" {jira_email} → {account_id}")
|
||||
|
||||
# Working days (Polish calendar)
|
||||
print("Fetching public holidays...")
|
||||
holidays = get_public_holidays("PL", start.year)
|
||||
wdays = working_days(start, end, holidays)
|
||||
expected_hours = len(wdays) * 8.0
|
||||
print(f" Working days: {len(wdays)} Expected hours: {expected_hours:.0f}h")
|
||||
|
||||
# Fetch Tempo worklogs
|
||||
print("Fetching Tempo worklogs...")
|
||||
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
|
||||
print(f" Worklogs fetched: {len(worklogs)}")
|
||||
|
||||
# Resolve issue IDs → keys via each issue's self URL (Tempo v4 omits the key)
|
||||
unique_ids = {int(wl["issue"]["id"]) for wl in worklogs if wl.get("issue", {}).get("id")}
|
||||
issue_key_map: dict[int, str] = {}
|
||||
if unique_ids:
|
||||
print(f" Resolving {len(unique_ids)} unique issue key(s) from Jira...")
|
||||
issue_key_map = resolve_issue_keys(worklogs, jira_email, jira_token)
|
||||
print(f" Resolved: {len(issue_key_map)}")
|
||||
|
||||
# Group hours by project code and by date, skipping ignored issues
|
||||
project_hours: dict[str, float] = {}
|
||||
hours_by_date: dict[str, float] = {}
|
||||
unmatched_hours = 0.0
|
||||
ignored_hours = 0.0
|
||||
|
||||
for wl in worklogs:
|
||||
issue_id = int(wl.get("issue", {}).get("id", 0))
|
||||
issue_key = issue_key_map.get(issue_id, "")
|
||||
hours = wl.get("timeSpentSeconds", 0) / 3600
|
||||
wl_date = wl.get("startDate", "")
|
||||
|
||||
if issue_key.upper() in ignored_keys:
|
||||
ignored_hours += hours
|
||||
# Still count toward date totals so missing-day detection stays accurate
|
||||
if wl_date:
|
||||
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
|
||||
continue
|
||||
|
||||
code = extract_project_code(issue_key)
|
||||
if code:
|
||||
project_hours[code] = project_hours.get(code, 0.0) + hours
|
||||
else:
|
||||
unmatched_hours += hours
|
||||
if wl_date:
|
||||
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
|
||||
|
||||
# Sort projects by hours descending
|
||||
projects = sorted(
|
||||
[{"code": k, "hours": round(v, 2)} for k, v in project_hours.items()],
|
||||
key=lambda x: x["hours"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
total_logged = round(sum(project_hours.values()) + unmatched_hours + ignored_hours, 2)
|
||||
total_billable = round(sum(project_hours.values()) + unmatched_hours, 2)
|
||||
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
|
||||
|
||||
# Find working days with no logged time
|
||||
days_missing = [
|
||||
d.isoformat() for d in wdays
|
||||
if hours_by_date.get(d.isoformat(), 0.0) == 0.0
|
||||
]
|
||||
|
||||
output = {
|
||||
"month": args.month,
|
||||
"user_email": jira_email,
|
||||
"account_id": account_id,
|
||||
"working_days": len(wdays),
|
||||
"expected_hours": expected_hours,
|
||||
"total_logged_hours": total_logged,
|
||||
"total_billable_hours": total_billable,
|
||||
"ignored_hours": round(ignored_hours, 2),
|
||||
"ignored_keys": sorted(ignored_keys),
|
||||
"missing_hours": missing_hours,
|
||||
"unmatched_hours": round(unmatched_hours, 2),
|
||||
"days_with_no_hours": days_missing,
|
||||
"projects": projects,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / f"invoice_prep_{args.month}.json"
|
||||
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
# Print quick summary
|
||||
print(f"\nProjects:")
|
||||
for p in projects:
|
||||
print(f" {p['code']:12s} {p['hours']:.2f}h")
|
||||
if unmatched_hours:
|
||||
print(f" (no project) {unmatched_hours:.2f}h")
|
||||
if ignored_hours:
|
||||
print(f" (ignored) {ignored_hours:.2f}h ({', '.join(sorted(ignored_keys))})")
|
||||
print(f"\nBillable total: {total_billable:.2f}h")
|
||||
print(f"Total logged: {total_logged:.2f}h / {expected_hours:.0f}h expected")
|
||||
if missing_hours > 0:
|
||||
print(f"⚠ Missing: {missing_hours:.2f}h ({len(days_missing)} day(s) with no time logged)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate invoice line items from Tempo worklogs."
|
||||
)
|
||||
parser.add_argument("--month", required=True, help="Month to check: YYYY-MM")
|
||||
parser.add_argument("--output-dir", required=True,
|
||||
help="Directory for invoice_prep_YYYY-MM.json")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
Loading…
Add table
Reference in a new issue