Drop invoice-prep-test, standardize on .env for credentials
userConfig never prompted the user at install time in testing, so all credential-needing skills stay on .env.example + .env. Adds the missing .env.example/.gitignore to check-my-timesheet and invoice-prep to match invoice-checker and timesheet-checker.
This commit is contained in:
parent
576a14c82f
commit
da4f334a0c
10 changed files with 44 additions and 515 deletions
|
|
@ -11,12 +11,6 @@
|
|||
"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",
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -10,7 +10,6 @@ writing, and invoice/timesheet checks. Each skill is packaged as its own install
|
|||
| `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. |
|
||||
|
|
@ -34,25 +33,13 @@ bare `owner/repo` shorthand won't resolve — use the full clone URL instead:
|
|||
/plugin marketplace add https://git.wierzbowa.cloud/Cloud_Drift/skills-marketplace.git
|
||||
```
|
||||
|
||||
## Credential setup — under test
|
||||
## Credential setup
|
||||
|
||||
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.
|
||||
Plugins that call an external API (Tempo, Jira, KSeF) read credentials from a `.env`
|
||||
file the user creates from each skill's `.env.example`. We tested Claude Code's plugin
|
||||
`userConfig` mechanism (prompting for a credential at install time) as an alternative —
|
||||
in practice it did not prompt the user at all, so we're sticking with `.env` files
|
||||
across all plugins.
|
||||
|
||||
## Licensing
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# 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,94 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
#!/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()))
|
||||
21
plugins/timesheet/skills/check-my-timesheet/.env.example
Normal file
21
plugins/timesheet/skills/check-my-timesheet/.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Check My Timesheet — credentials template
|
||||
# Copy this file to .env (in the project root) and fill in the 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=
|
||||
|
||||
# Country code for the public holiday calendar (optional, defaults to PL)
|
||||
MY_TIMESHEET_COUNTRY=PL
|
||||
|
||||
# Expected working hours per day (optional, defaults to 8)
|
||||
MY_TIMESHEET_HOURS_PER_DAY=8
|
||||
16
plugins/timesheet/skills/invoice-prep/.env.example
Normal file
16
plugins/timesheet/skills/invoice-prep/.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Invoice Prep — credentials template
|
||||
# Copy this file to .env (in the project root) and fill in the values.
|
||||
# .env is gitignored and never committed.
|
||||
# Same credentials as check-my-timesheet — one .env can be shared by both.
|
||||
|
||||
# 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/timesheet/skills/invoice-prep/.gitignore
vendored
Normal file
1
plugins/timesheet/skills/invoice-prep/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
Loading…
Add table
Reference in a new issue