redmine-worklog 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redmine_worklog/__init__.py +3 -0
- redmine_worklog/cli.py +405 -0
- redmine_worklog/csv_backfill.py +173 -0
- redmine_worklog/engine.py +229 -0
- redmine_worklog/holidays.txt +6 -0
- redmine_worklog/retro.py +116 -0
- redmine_worklog/userconfig.py +144 -0
- redmine_worklog/worklog_parser.py +93 -0
- redmine_worklog-0.1.0.dist-info/METADATA +152 -0
- redmine_worklog-0.1.0.dist-info/RECORD +14 -0
- redmine_worklog-0.1.0.dist-info/WHEEL +5 -0
- redmine_worklog-0.1.0.dist-info/entry_points.txt +3 -0
- redmine_worklog-0.1.0.dist-info/licenses/LICENSE +21 -0
- redmine_worklog-0.1.0.dist-info/top_level.txt +1 -0
redmine_worklog/cli.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — the `rmlog` command. Retro CLI to log tasks to Redmine.
|
|
3
|
+
|
|
4
|
+
First run -> setup wizard (name, Redmine URL, API token, project).
|
|
5
|
+
After that -> greets by first name and logs tasks in the strict format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from . import __version__, csv_backfill, engine, retro, userconfig
|
|
13
|
+
from . import worklog_parser as wl
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _verify_token(url, api_key):
|
|
17
|
+
"""Return (user_dict, error). Confirms the token works."""
|
|
18
|
+
try:
|
|
19
|
+
client = engine.RedmineClient(url, api_key)
|
|
20
|
+
return client.current_user(), None
|
|
21
|
+
except requests.HTTPError as e:
|
|
22
|
+
code = e.response.status_code if e.response is not None else "?"
|
|
23
|
+
if str(code) == "401":
|
|
24
|
+
return None, "Invalid API token (401 Unauthorized)."
|
|
25
|
+
return None, f"Redmine returned HTTP {code}."
|
|
26
|
+
except requests.RequestException as e:
|
|
27
|
+
return None, f"Could not reach Redmine: {e}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_setup():
|
|
31
|
+
print(retro.banner())
|
|
32
|
+
print(retro.box([
|
|
33
|
+
retro.c("Welcome, player one!", retro.BOLD + retro.WHITE),
|
|
34
|
+
"",
|
|
35
|
+
"Let's set up your worklog cartridge.",
|
|
36
|
+
"You'll need your Redmine URL and API access key",
|
|
37
|
+
"(Redmine -> My account -> API access key).",
|
|
38
|
+
], color=retro.MAGENTA, title="NEW GAME"))
|
|
39
|
+
print()
|
|
40
|
+
|
|
41
|
+
cfg = userconfig.load()
|
|
42
|
+
|
|
43
|
+
name = retro.prompt("Your name >").strip()
|
|
44
|
+
while not name:
|
|
45
|
+
name = retro.prompt("Please enter your name >").strip()
|
|
46
|
+
|
|
47
|
+
url = retro.prompt("Redmine URL >").strip().rstrip("/")
|
|
48
|
+
while not url:
|
|
49
|
+
url = retro.prompt("Redmine URL (e.g. http://redmine.local) >").strip().rstrip("/")
|
|
50
|
+
|
|
51
|
+
# Loop until the token verifies.
|
|
52
|
+
while True:
|
|
53
|
+
token = retro.prompt("API access key >").strip()
|
|
54
|
+
if not token:
|
|
55
|
+
continue
|
|
56
|
+
retro.typewriter(" ...connecting to Redmine...", retro.DIM + retro.CYAN)
|
|
57
|
+
user, err = _verify_token(url, token)
|
|
58
|
+
if err:
|
|
59
|
+
retro.typewriter(" X " + err, retro.RED)
|
|
60
|
+
retry = retro.prompt("Try again? (y/n) >").strip().lower()
|
|
61
|
+
if retry not in ("y", "yes", ""):
|
|
62
|
+
retro.typewriter("Setup cancelled.", retro.YELLOW)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
# allow re-entering URL too
|
|
65
|
+
new_url = retro.prompt(f"Redmine URL [{url}] >").strip().rstrip("/")
|
|
66
|
+
if new_url:
|
|
67
|
+
url = new_url
|
|
68
|
+
continue
|
|
69
|
+
redmine_name = f"{user.get('firstname','')} {user.get('lastname','')}".strip()
|
|
70
|
+
retro.typewriter(f" OK Connected as {redmine_name} "
|
|
71
|
+
f"(id {user.get('id')}).", retro.GREEN)
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
project = retro.prompt("Default project name/identifier >").strip()
|
|
75
|
+
while not project:
|
|
76
|
+
project = retro.prompt("Default project (required) >").strip()
|
|
77
|
+
|
|
78
|
+
tracker = retro.prompt("Tracker [Tasks] >").strip() or "Tasks"
|
|
79
|
+
|
|
80
|
+
cfg.update({
|
|
81
|
+
"name": name,
|
|
82
|
+
"url": url,
|
|
83
|
+
"api_key": token,
|
|
84
|
+
"project": project,
|
|
85
|
+
"tracker": tracker,
|
|
86
|
+
})
|
|
87
|
+
userconfig.save(cfg)
|
|
88
|
+
|
|
89
|
+
print()
|
|
90
|
+
print(retro.box([
|
|
91
|
+
retro.c("Saved! Your progress is stored at:", retro.GREEN),
|
|
92
|
+
retro.c(userconfig.CONFIG_PATH, retro.DIM + retro.WHITE),
|
|
93
|
+
"",
|
|
94
|
+
"Run rmlog again any time to log tasks.",
|
|
95
|
+
], color=retro.GREEN, title="SAVE COMPLETE"))
|
|
96
|
+
print()
|
|
97
|
+
return cfg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _print_help(first):
|
|
101
|
+
print(retro.box([
|
|
102
|
+
retro.c(f"How to play, {first}:", retro.BOLD + retro.WHITE),
|
|
103
|
+
"",
|
|
104
|
+
"Type ONE task per line, like:",
|
|
105
|
+
retro.c(" upload module discovery 3 hr today", retro.YELLOW),
|
|
106
|
+
retro.c(" fix login bug 2h yesterday", retro.YELLOW),
|
|
107
|
+
retro.c(" sprint planning 1.5 hr 15-09-2026 16-09-2026", retro.YELLOW),
|
|
108
|
+
"",
|
|
109
|
+
"dates: today | yesterday | dd-mm-yyyy",
|
|
110
|
+
"end date is optional (defaults to start date).",
|
|
111
|
+
"",
|
|
112
|
+
"Commands:",
|
|
113
|
+
retro.c(" csv", retro.YELLOW) + " bulk upload from a CSV",
|
|
114
|
+
retro.c(" change project", retro.YELLOW) + " switch default project",
|
|
115
|
+
retro.c(" change user", retro.YELLOW) + " switch saved account",
|
|
116
|
+
retro.c(" help", retro.YELLOW) + " show this screen",
|
|
117
|
+
retro.c(" quit", retro.YELLOW) + " exit",
|
|
118
|
+
"",
|
|
119
|
+
retro.c("Tip: run rmlog --plain on old terminals.",
|
|
120
|
+
retro.DIM + retro.WHITE),
|
|
121
|
+
], color=retro.CYAN, title="INSTRUCTIONS"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def switch_user():
|
|
125
|
+
"""
|
|
126
|
+
Let the user pick a saved profile (no token re-entry) or add a new one.
|
|
127
|
+
Returns the newly-active config dict, or None if unchanged/cancelled.
|
|
128
|
+
"""
|
|
129
|
+
profiles = userconfig.list_profiles()
|
|
130
|
+
active = userconfig.active_key()
|
|
131
|
+
|
|
132
|
+
lines = [retro.c("Saved accounts:", retro.BOLD + retro.WHITE)]
|
|
133
|
+
for i, (key, display) in enumerate(profiles, 1):
|
|
134
|
+
mark = retro.c(" (active)", retro.GREEN) if key == active else ""
|
|
135
|
+
lines.append(f" {i}. {display}{mark}")
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append(retro.c(" N. add a new account", retro.YELLOW))
|
|
138
|
+
print(retro.box(lines, color=retro.MAGENTA, title="CHANGE USER"))
|
|
139
|
+
|
|
140
|
+
choice = retro.prompt("Pick a number, 'N' for new, or 'cancel' >").strip().lower()
|
|
141
|
+
if not choice or choice == "cancel":
|
|
142
|
+
retro.typewriter(" User unchanged.", retro.YELLOW)
|
|
143
|
+
return None
|
|
144
|
+
if choice in ("n", "new"):
|
|
145
|
+
return run_setup() # adds/updates a profile and makes it active
|
|
146
|
+
if choice.isdigit():
|
|
147
|
+
idx = int(choice)
|
|
148
|
+
if 1 <= idx <= len(profiles):
|
|
149
|
+
key = profiles[idx - 1][0]
|
|
150
|
+
cfg = userconfig.switch_to(key)
|
|
151
|
+
if cfg:
|
|
152
|
+
return cfg
|
|
153
|
+
retro.typewriter(" Not a valid choice. User unchanged.", retro.YELLOW)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def change_project(cfg, client):
|
|
158
|
+
"""Prompt for a new default project, verify it exists, save. Returns bool."""
|
|
159
|
+
print(retro.box([
|
|
160
|
+
retro.c(f"Current project: {cfg.get('project','(none)')}", retro.WHITE),
|
|
161
|
+
"",
|
|
162
|
+
"Enter a new project name or identifier.",
|
|
163
|
+
], color=retro.MAGENTA, title="CHANGE PROJECT"))
|
|
164
|
+
new_project = retro.prompt("New project (or 'cancel') >").strip()
|
|
165
|
+
if not new_project or new_project.lower() == "cancel":
|
|
166
|
+
retro.typewriter(" Project unchanged.", retro.YELLOW)
|
|
167
|
+
return False
|
|
168
|
+
retro.typewriter(" ...checking project in Redmine...", retro.DIM + retro.CYAN)
|
|
169
|
+
try:
|
|
170
|
+
client.project_id(new_project) # raises if not found
|
|
171
|
+
except Exception as e: # noqa: BLE001
|
|
172
|
+
retro.typewriter(" X " + str(e), retro.RED)
|
|
173
|
+
return False
|
|
174
|
+
cfg["project"] = new_project
|
|
175
|
+
userconfig.save(cfg)
|
|
176
|
+
retro.typewriter(f" OK Default project is now '{new_project}'.", retro.GREEN)
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_csv_backfill(cfg, client, ids, holidays, first):
|
|
181
|
+
"""Interactive CSV backfill. Returns nothing; posts on confirm."""
|
|
182
|
+
print(retro.box(
|
|
183
|
+
[retro.c(ln, retro.WHITE) for ln in csv_backfill.INSTRUCTIONS],
|
|
184
|
+
color=retro.CYAN, title="CSV BACKFILL"))
|
|
185
|
+
|
|
186
|
+
# Sample shown as a column | value table (generic, easy to read).
|
|
187
|
+
req = {"subject", "hours", "date"}
|
|
188
|
+
col_w = max(len(c) for c, _ in csv_backfill.SAMPLE_ROW)
|
|
189
|
+
sample_lines = [
|
|
190
|
+
retro.c(f"{'COLUMN':<{col_w}}", retro.BOLD + retro.CYAN)
|
|
191
|
+
+ " | " + retro.c("EXAMPLE VALUE", retro.BOLD + retro.CYAN),
|
|
192
|
+
retro.c("-" * col_w + "--+--" + "-" * 28, retro.DIM + retro.WHITE),
|
|
193
|
+
]
|
|
194
|
+
for col, val in csv_backfill.SAMPLE_ROW:
|
|
195
|
+
tag = retro.c(" *", retro.YELLOW) if col in req else " "
|
|
196
|
+
sample_lines.append(
|
|
197
|
+
retro.c(f"{col:<{col_w}}", retro.WHITE) + " | "
|
|
198
|
+
+ retro.c(val, retro.GREEN) + tag)
|
|
199
|
+
sample_lines.append("")
|
|
200
|
+
sample_lines.append(retro.c(" * = required in every row", retro.YELLOW))
|
|
201
|
+
print(retro.box(sample_lines, color=retro.GREEN, title="SAMPLE ROW"))
|
|
202
|
+
|
|
203
|
+
path = retro.prompt("\nPath to your CSV (or 'cancel') >").strip().strip('"')
|
|
204
|
+
if not path or path.lower() == "cancel":
|
|
205
|
+
retro.typewriter(" CSV upload cancelled.", retro.YELLOW)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
entries, errors = csv_backfill.read_and_validate(path, cfg["defaults"])
|
|
209
|
+
|
|
210
|
+
# File/format-level problems (no entries at all)
|
|
211
|
+
if not entries and errors:
|
|
212
|
+
retro.typewriter(" X Could not use this CSV:", retro.RED)
|
|
213
|
+
for e in errors:
|
|
214
|
+
retro.typewriter(" " + e, retro.YELLOW)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Apply org rules across all rows. Hard errors block; warnings need override.
|
|
218
|
+
rule_errors, warnings = engine.validate_entries(
|
|
219
|
+
entries, cfg["rules"], holidays, client, ids["user_id"])
|
|
220
|
+
all_errors = errors + rule_errors
|
|
221
|
+
if all_errors:
|
|
222
|
+
retro.typewriter(f" Found {len(all_errors)} issue(s). "
|
|
223
|
+
f"Nothing uploaded. Fix and try again:", retro.RED)
|
|
224
|
+
for e in all_errors:
|
|
225
|
+
retro.typewriter(" - " + e, retro.YELLOW)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# Summary + confirm
|
|
229
|
+
total_hours = sum(float(e["hours"]) for e in entries)
|
|
230
|
+
days = sorted({str(e["date"]) for e in entries})
|
|
231
|
+
print(retro.box([
|
|
232
|
+
retro.c(f"Rows OK : {len(entries)} task(s)", retro.WHITE),
|
|
233
|
+
retro.c(f"Hours : {total_hours}h total", retro.WHITE),
|
|
234
|
+
retro.c(f"Dates : {days[0]} -> {days[-1]}", retro.WHITE),
|
|
235
|
+
], color=retro.YELLOW, title="CSV VALIDATED"))
|
|
236
|
+
|
|
237
|
+
if warnings:
|
|
238
|
+
warn_lines = [retro.c("Over the daily hours limit:", retro.BOLD + retro.YELLOW)]
|
|
239
|
+
warn_lines += [retro.c(" " + w, retro.WHITE) for w in warnings]
|
|
240
|
+
print(retro.box(warn_lines, color=retro.RED, title="! WARNING"))
|
|
241
|
+
ow = retro.prompt("Log more than the limit anyway? (yes/no) >").strip().lower()
|
|
242
|
+
if ow not in ("yes", "y"):
|
|
243
|
+
retro.typewriter(" Cancelled. Nothing was uploaded.", retro.YELLOW)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
ans = retro.prompt(f"Upload all {len(entries)} tasks to Redmine? "
|
|
247
|
+
f"(yes/no) >").strip().lower()
|
|
248
|
+
if ans not in ("yes", "y"):
|
|
249
|
+
retro.typewriter(" Cancelled. Nothing was uploaded.", retro.YELLOW)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
ok_count, fail_count = 0, 0
|
|
253
|
+
for e in entries:
|
|
254
|
+
ok, res = engine.post_entry(client, ids, e, cfg["defaults"])
|
|
255
|
+
if ok:
|
|
256
|
+
ok_count += 1
|
|
257
|
+
retro.typewriter(f" {retro.c('★', retro.GREEN)} {e['subject']}: {res}",
|
|
258
|
+
retro.WHITE)
|
|
259
|
+
else:
|
|
260
|
+
fail_count += 1
|
|
261
|
+
retro.typewriter(f" {retro.c('✖', retro.RED)} {e['subject']}: {res}",
|
|
262
|
+
retro.WHITE)
|
|
263
|
+
print(retro.box([
|
|
264
|
+
retro.c(f"Uploaded: {ok_count}", retro.GREEN),
|
|
265
|
+
retro.c(f"Failed : {fail_count}",
|
|
266
|
+
retro.RED if fail_count else retro.DIM + retro.WHITE),
|
|
267
|
+
], color=retro.GREEN, title="BACKFILL COMPLETE"))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _connect(cfg):
|
|
271
|
+
"""Create client + resolve ids. Exits with a friendly message on failure."""
|
|
272
|
+
client = engine.RedmineClient(cfg["url"], cfg["api_key"])
|
|
273
|
+
try:
|
|
274
|
+
ids = engine.resolve_ids(client, cfg)
|
|
275
|
+
except Exception as e: # noqa: BLE001
|
|
276
|
+
retro.typewriter(f"Could not connect: {e}", retro.RED)
|
|
277
|
+
retro.typewriter("Run rmlog --setup to fix your settings.", retro.YELLOW)
|
|
278
|
+
sys.exit(1)
|
|
279
|
+
return client, ids
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def run_chat(cfg):
|
|
283
|
+
client, ids = _connect(cfg)
|
|
284
|
+
holidays = engine.parse_holidays(cfg.get("holidays", []))
|
|
285
|
+
first = userconfig.first_name(cfg["name"]) or "player"
|
|
286
|
+
|
|
287
|
+
print(retro.banner())
|
|
288
|
+
print(retro.box([
|
|
289
|
+
retro.c(f"Player 1: {first}", retro.BOLD + retro.WHITE),
|
|
290
|
+
retro.c(f"Redmine : {cfg['url']}", retro.DIM + retro.WHITE),
|
|
291
|
+
retro.c(f"Project : {cfg['project']}", retro.DIM + retro.WHITE),
|
|
292
|
+
"",
|
|
293
|
+
f"Ready when you are, {first}.",
|
|
294
|
+
"Type a task, 'csv' to bulk upload, or 'help'.",
|
|
295
|
+
], color=retro.GREEN, title=f"PRESS START - v{__version__}"))
|
|
296
|
+
|
|
297
|
+
pending = None
|
|
298
|
+
while True:
|
|
299
|
+
try:
|
|
300
|
+
msg = retro.prompt(f"\n{retro.c('◄' + first + '►', retro.MAGENTA)} >").strip()
|
|
301
|
+
except (EOFError, KeyboardInterrupt):
|
|
302
|
+
print()
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
if not msg:
|
|
306
|
+
continue
|
|
307
|
+
low = msg.lower()
|
|
308
|
+
if low in ("quit", "exit", "q"):
|
|
309
|
+
retro.typewriter(f"GAME OVER. See you, {first}!", retro.YELLOW)
|
|
310
|
+
break
|
|
311
|
+
if low in ("help", "?"):
|
|
312
|
+
_print_help(first)
|
|
313
|
+
continue
|
|
314
|
+
if low in ("csv", "upload", "backfill"):
|
|
315
|
+
run_csv_backfill(cfg, client, ids, holidays, first)
|
|
316
|
+
pending = None
|
|
317
|
+
continue
|
|
318
|
+
if low in ("change user", "switch user", "logout"):
|
|
319
|
+
new_cfg = switch_user()
|
|
320
|
+
if new_cfg:
|
|
321
|
+
cfg = new_cfg
|
|
322
|
+
client, ids = _connect(cfg)
|
|
323
|
+
first = userconfig.first_name(cfg["name"]) or "player"
|
|
324
|
+
holidays = engine.parse_holidays(cfg.get("holidays", []))
|
|
325
|
+
retro.typewriter(f" Switched. Welcome, {first}!", retro.GREEN)
|
|
326
|
+
pending = None
|
|
327
|
+
continue
|
|
328
|
+
if low in ("change project", "switch project"):
|
|
329
|
+
if change_project(cfg, client):
|
|
330
|
+
ids = engine.resolve_ids(client, cfg) # re-resolve for new project
|
|
331
|
+
pending = None
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
# confirmation of a pending task
|
|
335
|
+
if pending is not None:
|
|
336
|
+
if low in ("yes", "y", "ok", "confirm"):
|
|
337
|
+
ok, res = engine.post_entry(client, ids, pending, cfg["defaults"])
|
|
338
|
+
mark = retro.c("★ WIN", retro.GREEN) if ok else retro.c("✖ MISS", retro.RED)
|
|
339
|
+
print(f" {mark} {res}")
|
|
340
|
+
else:
|
|
341
|
+
retro.typewriter(" Cancelled. Nothing was logged.", retro.YELLOW)
|
|
342
|
+
pending = None
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
entries, err = wl.parse(msg)
|
|
346
|
+
if err:
|
|
347
|
+
retro.typewriter(" ! " + err.splitlines()[0], retro.RED)
|
|
348
|
+
for extra in err.splitlines()[1:]:
|
|
349
|
+
retro.typewriter(" " + extra, retro.DIM + retro.WHITE)
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
verrs, warnings = engine.validate_entries(
|
|
353
|
+
entries, cfg["rules"], holidays, client, ids["user_id"])
|
|
354
|
+
if verrs:
|
|
355
|
+
retro.typewriter(" BLOCKED by the rules:", retro.RED)
|
|
356
|
+
for v in verrs:
|
|
357
|
+
retro.typewriter(" - " + v, retro.YELLOW)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
# show preview
|
|
361
|
+
d = engine.describe_entry(entries[0], cfg["defaults"])
|
|
362
|
+
lines = [retro.c(f"{k:<9}", retro.CYAN) + ": " + str(v)
|
|
363
|
+
for k, v in d.items()]
|
|
364
|
+
print(retro.box(lines, color=retro.YELLOW, title="CONFIRM TASK"))
|
|
365
|
+
|
|
366
|
+
# Over the daily limit -> warn and require an explicit override first.
|
|
367
|
+
if warnings:
|
|
368
|
+
warn_lines = [retro.c("Over the daily hours limit:",
|
|
369
|
+
retro.BOLD + retro.YELLOW)]
|
|
370
|
+
warn_lines += [retro.c(" " + w, retro.WHITE) for w in warnings]
|
|
371
|
+
print(retro.box(warn_lines, color=retro.RED, title="! WARNING"))
|
|
372
|
+
ow = retro.prompt("Log more than the limit anyway? (yes/no) >").strip().lower()
|
|
373
|
+
if ow not in ("yes", "y"):
|
|
374
|
+
retro.typewriter(" Cancelled. Nothing was logged.", retro.YELLOW)
|
|
375
|
+
pending = None
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
retro.typewriter(" Push to Redmine? type 'yes' to confirm.",
|
|
379
|
+
retro.BOLD + retro.WHITE)
|
|
380
|
+
pending = entries[0]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main():
|
|
384
|
+
args = sys.argv[1:]
|
|
385
|
+
if "--version" in args:
|
|
386
|
+
print(f"redmine-worklog {__version__}")
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# Plain mode for old terminals / piping: --plain flag or NO_COLOR env var.
|
|
390
|
+
import os as _os
|
|
391
|
+
if "--plain" in args or _os.environ.get("NO_COLOR"):
|
|
392
|
+
retro.set_plain(True)
|
|
393
|
+
|
|
394
|
+
if "--setup" in args or not userconfig.exists():
|
|
395
|
+
cfg = run_setup()
|
|
396
|
+
else:
|
|
397
|
+
cfg = userconfig.load()
|
|
398
|
+
# guard against half-set config
|
|
399
|
+
if not (cfg.get("url") and cfg.get("api_key") and cfg.get("project")):
|
|
400
|
+
cfg = run_setup()
|
|
401
|
+
run_chat(cfg)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
if __name__ == "__main__":
|
|
405
|
+
main()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
csv_backfill.py — read and validate a CSV of tasks for bulk backfill.
|
|
3
|
+
|
|
4
|
+
Expected header (order doesn't matter; case-insensitive):
|
|
5
|
+
subject,description,priority,status,start_date,due_date,
|
|
6
|
+
estimated_hours,date,hours,activity,comment
|
|
7
|
+
|
|
8
|
+
Required per row: subject, hours, date.
|
|
9
|
+
Everything else is optional and falls back to the user's defaults.
|
|
10
|
+
|
|
11
|
+
Dates accepted: YYYY-MM-DD or dd-mm-yyyy or dd/mm/yyyy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
REQUIRED = ["subject", "hours", "date"]
|
|
19
|
+
KNOWN_COLUMNS = [
|
|
20
|
+
"subject", "description", "priority", "status", "start_date",
|
|
21
|
+
"due_date", "estimated_hours", "date", "hours", "activity", "comment",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Raw CSV template (written to file when the user asks for a sample).
|
|
25
|
+
TEMPLATE = (
|
|
26
|
+
"subject,description,priority,status,start_date,due_date,"
|
|
27
|
+
"estimated_hours,date,hours,activity,comment\n"
|
|
28
|
+
"Task title,short description of the work,Normal,"
|
|
29
|
+
"Closed,2026-09-17,2026-09-18,8,2026-09-18,4,Development,"
|
|
30
|
+
"worked on the task\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# A generic sample shown as a vertical table (column -> value), which reads
|
|
34
|
+
# far better in a terminal than a single very wide CSV line.
|
|
35
|
+
SAMPLE_ROW = [
|
|
36
|
+
("subject", "Task title"),
|
|
37
|
+
("description", "short description of the work"),
|
|
38
|
+
("priority", "Normal"),
|
|
39
|
+
("status", "Closed"),
|
|
40
|
+
("start_date", "2026-09-17"),
|
|
41
|
+
("due_date", "2026-09-18"),
|
|
42
|
+
("estimated_hours", "8"),
|
|
43
|
+
("date", "2026-09-18"),
|
|
44
|
+
("hours", "4"),
|
|
45
|
+
("activity", "Development"),
|
|
46
|
+
("comment", "worked on the task"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
INSTRUCTIONS = [
|
|
50
|
+
"CSV backfill lets you upload many tasks at once (e.g. last 3 months).",
|
|
51
|
+
"",
|
|
52
|
+
"Header row (exactly these column names):",
|
|
53
|
+
" subject,description,priority,status,start_date,due_date,",
|
|
54
|
+
" estimated_hours,date,hours,activity,comment",
|
|
55
|
+
"",
|
|
56
|
+
"Required in every row : subject, hours, date",
|
|
57
|
+
"Optional (use default): description, priority, status, start_date,",
|
|
58
|
+
" due_date, estimated_hours, activity, comment",
|
|
59
|
+
"",
|
|
60
|
+
"Dates: YYYY-MM-DD or dd-mm-yyyy",
|
|
61
|
+
"One row = one task. Rules (9h/day, weekends, holidays) still apply.",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_date(value):
|
|
66
|
+
v = str(value).strip()
|
|
67
|
+
# try ISO first
|
|
68
|
+
try:
|
|
69
|
+
return dt.date.fromisoformat(v)
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
# dd-mm-yyyy or dd/mm/yyyy
|
|
73
|
+
for sep in ("-", "/"):
|
|
74
|
+
parts = v.split(sep)
|
|
75
|
+
if len(parts) == 3 and len(parts[0]) <= 2:
|
|
76
|
+
d, m, y = parts
|
|
77
|
+
return dt.date(int(y), int(m), int(d))
|
|
78
|
+
raise ValueError(v)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_and_validate(path, defaults):
|
|
82
|
+
"""
|
|
83
|
+
Returns (entries, errors).
|
|
84
|
+
- entries: list of entry dicts ready for engine.post_entry
|
|
85
|
+
- errors : list of human-readable problems (empty => file is valid)
|
|
86
|
+
If the file/headers are unusable, entries is [] and errors explains why.
|
|
87
|
+
"""
|
|
88
|
+
if not os.path.exists(path):
|
|
89
|
+
return [], [f"File not found: {path}"]
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with open(path, newline="", encoding="utf-8-sig") as f:
|
|
93
|
+
reader = csv.DictReader(f)
|
|
94
|
+
if reader.fieldnames is None:
|
|
95
|
+
return [], ["The CSV appears to be empty."]
|
|
96
|
+
headers = [(h or "").strip().lower() for h in reader.fieldnames]
|
|
97
|
+
rows = list(reader)
|
|
98
|
+
except (OSError, csv.Error) as e:
|
|
99
|
+
return [], [f"Could not read the CSV: {e}"]
|
|
100
|
+
|
|
101
|
+
# header check
|
|
102
|
+
missing_required_cols = [c for c in REQUIRED if c not in headers]
|
|
103
|
+
if missing_required_cols:
|
|
104
|
+
return [], [f"Missing required column(s): {', '.join(missing_required_cols)}.",
|
|
105
|
+
"Expected header: " + ",".join(KNOWN_COLUMNS)]
|
|
106
|
+
|
|
107
|
+
entries, errors = [], []
|
|
108
|
+
for i, raw in enumerate(rows, start=2): # line 1 is the header
|
|
109
|
+
row = {(k or "").strip().lower(): (v.strip() if isinstance(v, str) else v)
|
|
110
|
+
for k, v in raw.items()}
|
|
111
|
+
|
|
112
|
+
# skip fully blank lines
|
|
113
|
+
if not any((row.get(c) or "").strip() for c in KNOWN_COLUMNS):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
subject = row.get("subject") or ""
|
|
117
|
+
if not subject:
|
|
118
|
+
errors.append(f"Line {i}: 'subject' is required.")
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# hours
|
|
122
|
+
hraw = row.get("hours") or ""
|
|
123
|
+
try:
|
|
124
|
+
hours = float(hraw)
|
|
125
|
+
if hours <= 0:
|
|
126
|
+
errors.append(f"Line {i}: 'hours' must be > 0.")
|
|
127
|
+
continue
|
|
128
|
+
except ValueError:
|
|
129
|
+
errors.append(f"Line {i}: 'hours' ('{hraw}') is not a number.")
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
# date (logging date, required)
|
|
133
|
+
draw = row.get("date") or ""
|
|
134
|
+
try:
|
|
135
|
+
day = _parse_date(draw)
|
|
136
|
+
except ValueError:
|
|
137
|
+
errors.append(f"Line {i}: 'date' ('{draw}') is invalid "
|
|
138
|
+
f"(use YYYY-MM-DD or dd-mm-yyyy).")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
entry = {"subject": subject, "hours": hours, "date": day}
|
|
142
|
+
|
|
143
|
+
# optional dates
|
|
144
|
+
for key in ("start_date", "due_date"):
|
|
145
|
+
val = row.get(key)
|
|
146
|
+
if val:
|
|
147
|
+
try:
|
|
148
|
+
entry[key] = _parse_date(val)
|
|
149
|
+
except ValueError:
|
|
150
|
+
errors.append(f"Line {i}: '{key}' ('{val}') is invalid.")
|
|
151
|
+
entry.setdefault("start_date", day)
|
|
152
|
+
entry.setdefault("due_date", entry.get("start_date", day))
|
|
153
|
+
|
|
154
|
+
# optional estimated_hours
|
|
155
|
+
est = row.get("estimated_hours")
|
|
156
|
+
if est:
|
|
157
|
+
try:
|
|
158
|
+
entry["estimated_hours"] = float(est)
|
|
159
|
+
except ValueError:
|
|
160
|
+
errors.append(f"Line {i}: 'estimated_hours' ('{est}') "
|
|
161
|
+
f"is not a number.")
|
|
162
|
+
|
|
163
|
+
# optional free-text / lookup fields
|
|
164
|
+
for key in ("description", "priority", "status", "activity", "comment"):
|
|
165
|
+
if row.get(key):
|
|
166
|
+
entry[key] = row[key]
|
|
167
|
+
|
|
168
|
+
entries.append(entry)
|
|
169
|
+
|
|
170
|
+
if not entries and not errors:
|
|
171
|
+
errors.append("No task rows found in the CSV.")
|
|
172
|
+
|
|
173
|
+
return entries, errors
|