Skip to content

Commit 58adc5a

Browse files
committed
Some wildly busy work to make compiling, signing, and pushing timesheets more ergonomic. Rough edges remain.
1 parent 5b2e27e commit 58adc5a

File tree

3 files changed

+365
-23
lines changed

3 files changed

+365
-23
lines changed

src/faff_cli/main.py

Lines changed: 260 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,37 +65,219 @@ def config(ctx: typer.Context):
6565
typer.echo("No changes detected.")
6666

6767
@cli.command()
68-
def compile(ctx: typer.Context, date: str = typer.Argument(None)):
68+
def pull(ctx: typer.Context, remote: str = typer.Argument(None, help="Remote to pull from (omit to pull from all)")):
6969
"""
70-
cli: faff compile
71-
Compile the timesheet for a given date, defaulting to today.
70+
Pull plans from a remote (or all remotes).
7271
"""
7372
try:
74-
ws = ctx.obj
75-
resolved_date = ws.parse_natural_date(date)
73+
ws: Workspace = ctx.obj
74+
remotes = ws.plans.remotes()
7675

77-
log = ws.logs.get_log_or_create(resolved_date)
76+
if remote:
77+
remotes = [r for r in remotes if r.id == remote]
78+
if len(remotes) == 0:
79+
typer.echo(f"Unknown remote: {remote}", err=True)
80+
raise typer.Exit(1)
81+
82+
for r in remotes:
83+
try:
84+
plan = r.pull_plan(ws.today())
85+
if plan:
86+
ws.plans.write_plan(plan)
87+
typer.echo(f"Pulled plan from {r.id}")
88+
else:
89+
typer.echo(f"No plans found for {r.id}")
90+
except Exception as e:
91+
typer.echo(f"Error pulling plan from {r.id}: {e}", err=True)
92+
except Exception as e:
93+
typer.echo(f"Error pulling plans: {e}", err=True)
94+
raise typer.Exit(1)
95+
96+
@cli.command()
97+
def compile(ctx: typer.Context,
98+
date: str = typer.Argument(None, help="Specific date to compile (omit to compile all uncompiled logs)"),
99+
audience: str = typer.Option(None, "--audience", "-a", help="Specific audience to compile for (omit for all)")):
100+
"""
101+
Compile timesheets. By default, compiles all logs that don't have timesheets yet.
102+
Specify a date to force recompile a specific date.
103+
"""
104+
try:
105+
ws = ctx.obj
78106
audiences = ws.timesheets.audiences()
79107

80-
for audience in audiences:
81-
compiled_timesheet = audience.compile_time_sheet(log)
82-
ws.timesheets.write_timesheet(compiled_timesheet)
83-
typer.echo(f"Compiled timesheet for {resolved_date} using {audience.id}.")
108+
if audience:
109+
audiences = [a for a in audiences if a.id == audience]
110+
if len(audiences) == 0:
111+
typer.echo(f"Unknown audience: {audience}", err=True)
112+
raise typer.Exit(1)
113+
114+
if date:
115+
# Specific date provided - compile that date
116+
resolved_date = ws.parse_natural_date(date)
117+
log = ws.logs.get_log_or_create(resolved_date)
118+
119+
# FIXME: This check should be in faff-core, not faff-cli
120+
# The compile_time_sheet method should refuse to compile logs with active sessions
121+
# Check for unclosed session
122+
if log.active_session():
123+
typer.echo(f"Cannot compile {resolved_date}: log has an unclosed session. Run 'faff stop' first.", err=True)
124+
raise typer.Exit(1)
125+
126+
for aud in audiences:
127+
compiled_timesheet = aud.compile_time_sheet(log)
128+
if compiled_timesheet:
129+
# Sign the timesheet if signing_ids are configured
130+
signing_ids = aud.config.get('signing_ids', [])
131+
if signing_ids:
132+
signed = False
133+
for signing_id in signing_ids:
134+
key = ws.identities.get_identity(signing_id)
135+
if key:
136+
compiled_timesheet = compiled_timesheet.sign(signing_id, bytes(key))
137+
signed = True
138+
else:
139+
typer.echo(f"Warning: No identity key found for {signing_id}", err=True)
140+
141+
if signed:
142+
ws.timesheets.write_timesheet(compiled_timesheet)
143+
typer.echo(f"Compiled and signed timesheet for {resolved_date} using {aud.id}.")
144+
else:
145+
ws.timesheets.write_timesheet(compiled_timesheet)
146+
typer.echo(f"Warning: Compiled unsigned timesheet for {resolved_date} using {aud.id} (no valid signing keys)", err=True)
147+
else:
148+
ws.timesheets.write_timesheet(compiled_timesheet)
149+
typer.echo(f"Warning: Compiled unsigned timesheet for {resolved_date} using {aud.id} (no signing_ids configured)", err=True)
150+
else:
151+
typer.echo(f"No timesheet for {resolved_date} from {aud.id} (no relevant sessions).")
152+
else:
153+
# No date provided - find all logs that need compiling
154+
log_dates = ws.logs.list_log_dates()
155+
existing_timesheets = ws.timesheets.list()
156+
157+
# Build a set of (audience_id, date) tuples for existing timesheets
158+
existing = {(ts.meta.audience_id, ts.date) for ts in existing_timesheets}
159+
160+
compiled_count = 0
161+
skipped_unclosed = []
162+
for log_date in log_dates:
163+
log = ws.logs.get_log(log_date)
164+
if not log:
165+
continue
166+
167+
# FIXME: This check should be in faff-core, not faff-cli
168+
# The compile_time_sheet method should refuse to compile logs with active sessions
169+
# Check for unclosed session
170+
if log.active_session():
171+
skipped_unclosed.append(log_date)
172+
continue
173+
174+
for aud in audiences:
175+
if (aud.id, log_date) not in existing:
176+
compiled_timesheet = aud.compile_time_sheet(log)
177+
# Sign the timesheet if signing_ids are configured (even if empty)
178+
is_empty = len(compiled_timesheet.timeline) == 0
179+
signing_ids = aud.config.get('signing_ids', [])
180+
181+
if signing_ids:
182+
signed = False
183+
for signing_id in signing_ids:
184+
key = ws.identities.get_identity(signing_id)
185+
if key:
186+
compiled_timesheet = compiled_timesheet.sign(signing_id, bytes(key))
187+
signed = True
188+
else:
189+
typer.echo(f"Warning: No identity key found for {signing_id}", err=True)
190+
191+
if signed:
192+
ws.timesheets.write_timesheet(compiled_timesheet)
193+
if is_empty:
194+
typer.echo(f"Compiled and signed empty timesheet for {log_date} using {aud.id} (no relevant sessions).")
195+
else:
196+
typer.echo(f"Compiled and signed timesheet for {log_date} using {aud.id}.")
197+
else:
198+
ws.timesheets.write_timesheet(compiled_timesheet)
199+
if is_empty:
200+
typer.echo(f"Warning: Compiled unsigned empty timesheet for {log_date} using {aud.id} (no valid signing keys)", err=True)
201+
else:
202+
typer.echo(f"Warning: Compiled unsigned timesheet for {log_date} using {aud.id} (no valid signing keys)", err=True)
203+
else:
204+
ws.timesheets.write_timesheet(compiled_timesheet)
205+
if is_empty:
206+
typer.echo(f"Warning: Compiled unsigned empty timesheet for {log_date} using {aud.id} (no signing_ids configured)", err=True)
207+
else:
208+
typer.echo(f"Warning: Compiled unsigned timesheet for {log_date} using {aud.id} (no signing_ids configured)", err=True)
209+
210+
compiled_count += 1
211+
212+
if skipped_unclosed:
213+
typer.echo(f"\nSkipped {len(skipped_unclosed)} log(s) with unclosed sessions:", err=True)
214+
for log_date in skipped_unclosed:
215+
typer.echo(f" - {log_date} (run 'faff stop' to close the active session)", err=True)
216+
217+
if compiled_count == 0 and not skipped_unclosed:
218+
typer.echo("All logs already have compiled timesheets.")
84219
except Exception as e:
85220
typer.echo(f"Error compiling timesheet: {e}", err=True)
86221
raise typer.Exit(1)
87222

223+
@cli.command()
224+
def push(ctx: typer.Context,
225+
date: str = typer.Argument(None, help="Specific date to push (omit to push all unsubmitted timesheets)"),
226+
audience: str = typer.Option(None, "--audience", "-a", help="Specific audience to push to (omit for all)")):
227+
"""
228+
Push timesheets. By default, pushes all compiled timesheets that haven't been submitted yet.
229+
Specify a date to force push a specific date.
230+
"""
231+
try:
232+
ws: Workspace = ctx.obj
233+
234+
if date:
235+
# Specific date provided - push that date
236+
resolved_date = ws.parse_natural_date(date)
237+
audiences = ws.timesheets.audiences()
238+
239+
if audience:
240+
audiences = [a for a in audiences if a.id == audience]
241+
if len(audiences) == 0:
242+
typer.echo(f"Unknown audience: {audience}", err=True)
243+
raise typer.Exit(1)
244+
245+
for aud in audiences:
246+
timesheet = ws.timesheets.get_timesheet(aud.id, resolved_date)
247+
if timesheet:
248+
ws.timesheets.submit(timesheet)
249+
typer.echo(f"Pushed timesheet for {resolved_date} to {aud.id}.")
250+
else:
251+
typer.echo(f"No timesheet found for {aud.id} on {resolved_date}. Did you run 'faff compile' first?", err=True)
252+
else:
253+
# No date provided - push all unsubmitted timesheets
254+
all_timesheets = ws.timesheets.list()
255+
unsubmitted = [ts for ts in all_timesheets if ts.meta.submitted_at is None]
256+
257+
if audience:
258+
unsubmitted = [ts for ts in unsubmitted if ts.meta.audience_id == audience]
259+
260+
if len(unsubmitted) == 0:
261+
typer.echo("All timesheets have been submitted.")
262+
else:
263+
for timesheet in unsubmitted:
264+
ws.timesheets.submit(timesheet)
265+
typer.echo(f"Pushed timesheet for {timesheet.date} to {timesheet.meta.audience_id}.")
266+
except Exception as e:
267+
typer.echo(f"Error pushing timesheet: {e}", err=True)
268+
raise typer.Exit(1)
269+
88270
@cli.command()
89271
def status(ctx: typer.Context):
90272
"""
91-
cli: faff status
92-
Show the status of the faff repository.
273+
Show the status of the faff repository, including what needs compiling/pushing.
93274
"""
94275
try:
95276
ws: Workspace = ctx.obj
96277
typer.echo(f"Status for faff repo root at: {ws.storage().root_dir()}")
97-
typer.echo(f"faff-core library version: {faff_core.version()}")
278+
typer.echo(f"faff-core library version: {faff_core.version()}\n")
98279

280+
# Today's status
99281
log = ws.logs.get_log_or_create(ws.today())
100282
typer.echo(f"Total recorded time for today: {humanize.precisedelta(log.total_recorded_time(),minimum_unit='minutes')}")
101283

@@ -108,6 +290,71 @@ def status(ctx: typer.Context):
108290
typer.echo(f"Working on {active_session.intent.alias} for {humanize.precisedelta(duration)}")
109291
else:
110292
typer.echo("Not currently working on anything.")
293+
294+
# Check what needs compiling
295+
typer.echo("\n--- Logs needing compilation ---")
296+
log_dates = ws.logs.list_log_dates()
297+
existing_timesheets = ws.timesheets.list()
298+
audiences = ws.timesheets.audiences()
299+
300+
# Build a set of (audience_id, date) tuples for existing timesheets
301+
existing = {(ts.meta.audience_id, ts.date) for ts in existing_timesheets}
302+
303+
needs_compiling = []
304+
has_unclosed = []
305+
for log_date in log_dates:
306+
log = ws.logs.get_log(log_date)
307+
if not log:
308+
continue
309+
310+
# Check if this log needs compiling for any audience
311+
needs_compile_for_audiences = [aud.id for aud in audiences if (aud.id, log_date) not in existing]
312+
313+
if needs_compile_for_audiences:
314+
total_hours = log.total_recorded_time().total_seconds() / 3600
315+
if log.active_session():
316+
has_unclosed.append((log_date, total_hours, needs_compile_for_audiences))
317+
else:
318+
needs_compiling.append((log_date, total_hours, needs_compile_for_audiences))
319+
320+
if has_unclosed:
321+
typer.echo("⚠️ Logs with unclosed sessions (cannot compile):")
322+
for log_date, hours, audience_ids in has_unclosed:
323+
typer.echo(f" {log_date}: {hours:.2f}h (for {', '.join(audience_ids)})")
324+
typer.echo(" Run 'faff stop' to close the active session\n")
325+
326+
if needs_compiling:
327+
typer.echo("Ready to compile:")
328+
for log_date, hours, audience_ids in needs_compiling:
329+
typer.echo(f" {log_date}: {hours:.2f}h (for {', '.join(audience_ids)})")
330+
total_to_compile = sum(hours for _, hours, _ in needs_compiling)
331+
typer.echo(f" Total: {len(needs_compiling)} log(s), {total_to_compile:.2f}h")
332+
elif not has_unclosed:
333+
typer.echo("All logs have compiled timesheets ✓")
334+
335+
# Check what needs pushing
336+
typer.echo("\n--- Timesheets needing submission ---")
337+
unsubmitted = [ts for ts in existing_timesheets if ts.meta.submitted_at is None]
338+
339+
if unsubmitted:
340+
# Group by audience
341+
by_audience = {}
342+
for ts in unsubmitted:
343+
if ts.meta.audience_id not in by_audience:
344+
by_audience[ts.meta.audience_id] = []
345+
by_audience[ts.meta.audience_id].append(ts)
346+
347+
for audience_id, timesheets in by_audience.items():
348+
typer.echo(f"For {audience_id}:")
349+
total_hours = 0
350+
for ts in sorted(timesheets, key=lambda t: t.date):
351+
hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600
352+
total_hours += hours
353+
typer.echo(f" {ts.date}: {hours:.2f}h")
354+
typer.echo(f" Total: {len(timesheets)} timesheet(s), {total_hours:.2f}h")
355+
else:
356+
typer.echo("All timesheets have been submitted ✓")
357+
111358
except Exception as e:
112359
typer.echo(f"Error getting status: {e}", err=True)
113360
raise typer.Exit(1)

src/faff_cli/remote.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,78 @@ def list_remotes(ctx: typer.Context):
5353
raise typer.Exit(1)
5454

5555

56+
@app.command()
57+
def add(
58+
ctx: typer.Context,
59+
remote_id: str = typer.Argument(..., help="ID for the remote"),
60+
plugin: str = typer.Argument(..., help="Plugin name (e.g., 'my-hours', 'jira')"),
61+
):
62+
"""
63+
Create a new remote configuration.
64+
"""
65+
try:
66+
ws: Workspace = ctx.obj
67+
console = Console()
68+
69+
remotes_dir = Path(ws.storage().remotes_dir())
70+
remote_file = remotes_dir / f"{remote_id}.toml"
71+
72+
if remote_file.exists():
73+
console.print(f"[red]Remote '{remote_id}' already exists[/red]")
74+
console.print(f"File: {remote_file}")
75+
console.print("\nUse 'faff remote edit' to modify it.")
76+
raise typer.Exit(1)
77+
78+
# Create minimal remote config
79+
config = f"""id = "{remote_id}"
80+
plugin = "{plugin}"
81+
82+
[connection]
83+
# Add your connection details here
84+
85+
[vocabulary]
86+
# Add static ROAST vocabulary items here (optional)
87+
"""
88+
89+
remote_file.write_text(config)
90+
console.print(f"[green]Created remote '{remote_id}'[/green]")
91+
console.print(f"File: {remote_file}")
92+
console.print(f"\nRun: [cyan]faff remote edit {remote_id}[/cyan] to configure")
93+
94+
except Exception as e:
95+
typer.echo(f"Error adding remote: {e}", err=True)
96+
raise typer.Exit(1)
97+
98+
99+
@app.command()
100+
def edit(ctx: typer.Context, remote_id: str = typer.Argument(..., help="Remote ID to edit")):
101+
"""
102+
Edit a remote configuration in your preferred editor.
103+
"""
104+
try:
105+
ws: Workspace = ctx.obj
106+
console = Console()
107+
108+
remotes_dir = Path(ws.storage().remotes_dir())
109+
remote_file = remotes_dir / f"{remote_id}.toml"
110+
111+
if not remote_file.exists():
112+
console.print(f"[red]Remote '{remote_id}' not found[/red]")
113+
console.print(f"\nRun: [cyan]faff remote add {remote_id} <plugin>[/cyan]")
114+
raise typer.Exit(1)
115+
116+
from faff_cli.utils import edit_file
117+
118+
if edit_file(remote_file):
119+
console.print(f"[green]Remote '{remote_id}' updated[/green]")
120+
else:
121+
console.print("No changes detected.")
122+
123+
except Exception as e:
124+
typer.echo(f"Error editing remote: {e}", err=True)
125+
raise typer.Exit(1)
126+
127+
56128
@app.command()
57129
def show(ctx: typer.Context, remote_id: str = typer.Argument(..., help="Remote ID to show")):
58130
"""

0 commit comments

Comments
 (0)