jobhound 0.2.0__tar.gz
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.
- jobhound-0.2.0/PKG-INFO +10 -0
- jobhound-0.2.0/pyproject.toml +44 -0
- jobhound-0.2.0/src/jobhound/__init__.py +3 -0
- jobhound-0.2.0/src/jobhound/cli.py +48 -0
- jobhound-0.2.0/src/jobhound/commands/__init__.py +0 -0
- jobhound-0.2.0/src/jobhound/commands/_terminal.py +43 -0
- jobhound-0.2.0/src/jobhound/commands/accept.py +25 -0
- jobhound-0.2.0/src/jobhound/commands/apply.py +48 -0
- jobhound-0.2.0/src/jobhound/commands/archive.py +32 -0
- jobhound-0.2.0/src/jobhound/commands/contact.py +39 -0
- jobhound-0.2.0/src/jobhound/commands/decline.py +25 -0
- jobhound-0.2.0/src/jobhound/commands/delete.py +36 -0
- jobhound-0.2.0/src/jobhound/commands/edit.py +81 -0
- jobhound-0.2.0/src/jobhound/commands/ghost.py +25 -0
- jobhound-0.2.0/src/jobhound/commands/link.py +28 -0
- jobhound-0.2.0/src/jobhound/commands/list_.py +15 -0
- jobhound-0.2.0/src/jobhound/commands/log.py +79 -0
- jobhound-0.2.0/src/jobhound/commands/new.py +60 -0
- jobhound-0.2.0/src/jobhound/commands/note.py +39 -0
- jobhound-0.2.0/src/jobhound/commands/priority.py +36 -0
- jobhound-0.2.0/src/jobhound/commands/sync.py +36 -0
- jobhound-0.2.0/src/jobhound/commands/tag.py +39 -0
- jobhound-0.2.0/src/jobhound/commands/withdraw.py +25 -0
- jobhound-0.2.0/src/jobhound/config.py +48 -0
- jobhound-0.2.0/src/jobhound/contact.py +44 -0
- jobhound-0.2.0/src/jobhound/git.py +48 -0
- jobhound-0.2.0/src/jobhound/meta_io.py +98 -0
- jobhound-0.2.0/src/jobhound/opportunities.py +171 -0
- jobhound-0.2.0/src/jobhound/paths.py +40 -0
- jobhound-0.2.0/src/jobhound/priority.py +11 -0
- jobhound-0.2.0/src/jobhound/prompts.py +70 -0
- jobhound-0.2.0/src/jobhound/repository.py +100 -0
- jobhound-0.2.0/src/jobhound/slug.py +34 -0
- jobhound-0.2.0/src/jobhound/slug_value.py +44 -0
- jobhound-0.2.0/src/jobhound/status.py +89 -0
- jobhound-0.2.0/src/jobhound/transitions.py +67 -0
jobhound-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jobhound
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Action-based CLI for tracking a job hunt
|
|
5
|
+
Requires-Dist: cyclopts==4.11.2
|
|
6
|
+
Requires-Dist: questionary>=2.0
|
|
7
|
+
Requires-Dist: tomli-w>=1.0
|
|
8
|
+
Requires-Dist: rich>=13
|
|
9
|
+
Requires-Dist: xdg-base-dirs>=6
|
|
10
|
+
Requires-Python: >=3.13
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jobhound"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Action-based CLI for tracking a job hunt"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"cyclopts==4.11.2",
|
|
8
|
+
"questionary>=2.0",
|
|
9
|
+
"tomli-w>=1.0",
|
|
10
|
+
"rich>=13",
|
|
11
|
+
"xdg-base-dirs>=6",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
jh = "jobhound.cli:app"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8",
|
|
20
|
+
"ruff>=0.6",
|
|
21
|
+
"ty>=0.0.1a8",
|
|
22
|
+
"pytest-cov>=5",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.4,<0.12"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 100
|
|
31
|
+
target-version = "py313"
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff.format]
|
|
37
|
+
quote-style = "double"
|
|
38
|
+
|
|
39
|
+
[tool.ty.rules]
|
|
40
|
+
# strict-by-default; tighten as needed
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
addopts = "-ra --strict-markers"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The `jh` CLI entry point. Commands are registered explicitly below."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cyclopts import App
|
|
6
|
+
|
|
7
|
+
from jobhound import __version__
|
|
8
|
+
from jobhound.commands import accept as cmd_accept
|
|
9
|
+
from jobhound.commands import apply as cmd_apply
|
|
10
|
+
from jobhound.commands import archive as cmd_archive
|
|
11
|
+
from jobhound.commands import contact as cmd_contact
|
|
12
|
+
from jobhound.commands import decline as cmd_decline
|
|
13
|
+
from jobhound.commands import delete as cmd_delete
|
|
14
|
+
from jobhound.commands import edit as cmd_edit
|
|
15
|
+
from jobhound.commands import ghost as cmd_ghost
|
|
16
|
+
from jobhound.commands import link as cmd_link
|
|
17
|
+
from jobhound.commands import list_ as cmd_list
|
|
18
|
+
from jobhound.commands import log as cmd_log
|
|
19
|
+
from jobhound.commands import new as cmd_new
|
|
20
|
+
from jobhound.commands import note as cmd_note
|
|
21
|
+
from jobhound.commands import priority as cmd_priority
|
|
22
|
+
from jobhound.commands import sync as cmd_sync
|
|
23
|
+
from jobhound.commands import tag as cmd_tag
|
|
24
|
+
from jobhound.commands import withdraw as cmd_withdraw
|
|
25
|
+
|
|
26
|
+
app = App(
|
|
27
|
+
name="jh",
|
|
28
|
+
help="Action-based CLI for tracking a job hunt.",
|
|
29
|
+
version=__version__,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.command(cmd_new.run, name="new")
|
|
33
|
+
app.command(cmd_apply.run, name="apply")
|
|
34
|
+
app.command(cmd_log.run, name="log")
|
|
35
|
+
app.command(cmd_withdraw.run, name="withdraw")
|
|
36
|
+
app.command(cmd_ghost.run, name="ghost")
|
|
37
|
+
app.command(cmd_accept.run, name="accept")
|
|
38
|
+
app.command(cmd_decline.run, name="decline")
|
|
39
|
+
app.command(cmd_note.run, name="note")
|
|
40
|
+
app.command(cmd_priority.run, name="priority")
|
|
41
|
+
app.command(cmd_tag.run, name="tag")
|
|
42
|
+
app.command(cmd_link.run, name="link")
|
|
43
|
+
app.command(cmd_contact.run, name="contact")
|
|
44
|
+
app.command(cmd_list.run, name="list")
|
|
45
|
+
app.command(cmd_edit.run, name="edit")
|
|
46
|
+
app.command(cmd_archive.run, name="archive")
|
|
47
|
+
app.command(cmd_delete.run, name="delete")
|
|
48
|
+
app.command(cmd_sync.run, name="sync")
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared logic for terminal-status verbs (withdraw, ghost, accept, decline)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
from jobhound.config import load_config
|
|
9
|
+
from jobhound.opportunities import Opportunity
|
|
10
|
+
from jobhound.paths import paths_from_config
|
|
11
|
+
from jobhound.repository import OpportunityRepository
|
|
12
|
+
from jobhound.transitions import InvalidTransitionError
|
|
13
|
+
|
|
14
|
+
_METHODS = {
|
|
15
|
+
"withdraw": Opportunity.withdraw,
|
|
16
|
+
"ghost": Opportunity.ghost,
|
|
17
|
+
"accept": Opportunity.accept,
|
|
18
|
+
"decline": Opportunity.decline,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_transition(
|
|
23
|
+
*,
|
|
24
|
+
slug_query: str,
|
|
25
|
+
verb: str,
|
|
26
|
+
today: str | None,
|
|
27
|
+
no_commit: bool,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Move an opportunity to its terminal status via the entity method."""
|
|
30
|
+
cfg = load_config()
|
|
31
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
32
|
+
today_date = date.fromisoformat(today) if today else date.today()
|
|
33
|
+
opp, opp_dir = repo.find(slug_query)
|
|
34
|
+
|
|
35
|
+
method = _METHODS[verb]
|
|
36
|
+
try:
|
|
37
|
+
updated = method(opp, today=today_date)
|
|
38
|
+
except InvalidTransitionError as exc:
|
|
39
|
+
print(str(exc), file=sys.stderr)
|
|
40
|
+
raise SystemExit(1) from exc
|
|
41
|
+
|
|
42
|
+
repo.save(updated, opp_dir, message=f"{verb}: {opp.slug}", no_commit=no_commit)
|
|
43
|
+
print(f"{verb}: {opp.slug}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""`jh accept` — accepted offer, status → accepted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from jobhound.commands._terminal import run_transition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
slug_query: str,
|
|
14
|
+
/,
|
|
15
|
+
*,
|
|
16
|
+
today: Annotated[str | None, Parameter(show=False)] = None,
|
|
17
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Accept the offer."""
|
|
20
|
+
run_transition(
|
|
21
|
+
slug_query=slug_query,
|
|
22
|
+
verb="accept",
|
|
23
|
+
today=today,
|
|
24
|
+
no_commit=no_commit,
|
|
25
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""`jh apply` — submitted application, status → applied."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from cyclopts import Parameter
|
|
10
|
+
|
|
11
|
+
from jobhound.config import load_config
|
|
12
|
+
from jobhound.paths import paths_from_config
|
|
13
|
+
from jobhound.repository import OpportunityRepository
|
|
14
|
+
from jobhound.transitions import InvalidTransitionError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(
|
|
18
|
+
slug_query: str,
|
|
19
|
+
/,
|
|
20
|
+
*,
|
|
21
|
+
on: str | None = None,
|
|
22
|
+
next_action: str,
|
|
23
|
+
next_action_due: str,
|
|
24
|
+
today: Annotated[str | None, Parameter(show=False)] = None,
|
|
25
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Mark the application as submitted."""
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
30
|
+
|
|
31
|
+
today_date = date.fromisoformat(today) if today else date.today()
|
|
32
|
+
applied_on = date.fromisoformat(on) if on else today_date
|
|
33
|
+
due = date.fromisoformat(next_action_due)
|
|
34
|
+
|
|
35
|
+
opp, opp_dir = repo.find(slug_query)
|
|
36
|
+
try:
|
|
37
|
+
updated = opp.apply(
|
|
38
|
+
applied_on=applied_on,
|
|
39
|
+
today=today_date,
|
|
40
|
+
next_action=next_action,
|
|
41
|
+
next_action_due=due,
|
|
42
|
+
)
|
|
43
|
+
except InvalidTransitionError as exc:
|
|
44
|
+
print(str(exc), file=sys.stderr)
|
|
45
|
+
raise SystemExit(1) from exc
|
|
46
|
+
|
|
47
|
+
repo.save(updated, opp_dir, message=f"apply: {opp.slug}", no_commit=no_commit)
|
|
48
|
+
print(f"applied: {opp.slug}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""`jh archive` — move <slug> from opportunities/ to archive/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from cyclopts import Parameter
|
|
9
|
+
|
|
10
|
+
from jobhound.config import load_config
|
|
11
|
+
from jobhound.paths import Paths, paths_from_config
|
|
12
|
+
from jobhound.repository import OpportunityRepository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(
|
|
16
|
+
slug_query: str,
|
|
17
|
+
/,
|
|
18
|
+
*,
|
|
19
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Move an opportunity to the archive directory."""
|
|
22
|
+
cfg = load_config()
|
|
23
|
+
paths = paths_from_config(cfg)
|
|
24
|
+
Paths.ensure(paths)
|
|
25
|
+
repo = OpportunityRepository(paths, cfg)
|
|
26
|
+
_, opp_dir = repo.find(slug_query)
|
|
27
|
+
try:
|
|
28
|
+
repo.archive(opp_dir, no_commit=no_commit)
|
|
29
|
+
except FileExistsError as exc:
|
|
30
|
+
print(str(exc), file=sys.stderr)
|
|
31
|
+
raise SystemExit(1) from exc
|
|
32
|
+
print(f"archived: {opp_dir.name}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""`jh contact` — append a contact entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from jobhound.config import load_config
|
|
10
|
+
from jobhound.contact import Contact
|
|
11
|
+
from jobhound.paths import paths_from_config
|
|
12
|
+
from jobhound.repository import OpportunityRepository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(
|
|
16
|
+
slug_query: str,
|
|
17
|
+
/,
|
|
18
|
+
*,
|
|
19
|
+
name: str,
|
|
20
|
+
role_title: str | None = None,
|
|
21
|
+
channel: str | None = None,
|
|
22
|
+
company: str | None = None,
|
|
23
|
+
note: str | None = None,
|
|
24
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Add a contact to the contacts list."""
|
|
27
|
+
contact = Contact(
|
|
28
|
+
name=name,
|
|
29
|
+
role=role_title,
|
|
30
|
+
channel=channel,
|
|
31
|
+
company=company,
|
|
32
|
+
note=note,
|
|
33
|
+
)
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
36
|
+
opp, opp_dir = repo.find(slug_query)
|
|
37
|
+
updated = opp.with_contact(contact)
|
|
38
|
+
repo.save(updated, opp_dir, message=f"contact: {opp.slug} {name}", no_commit=no_commit)
|
|
39
|
+
print(f"contact added: {opp.slug} {name}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""`jh decline` — declined offer, status → declined."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from jobhound.commands._terminal import run_transition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
slug_query: str,
|
|
14
|
+
/,
|
|
15
|
+
*,
|
|
16
|
+
today: Annotated[str | None, Parameter(show=False)] = None,
|
|
17
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Decline the offer."""
|
|
20
|
+
run_transition(
|
|
21
|
+
slug_query=slug_query,
|
|
22
|
+
verb="decline",
|
|
23
|
+
today=today,
|
|
24
|
+
no_commit=no_commit,
|
|
25
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""`jh delete` — remove an opportunity directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from cyclopts import Parameter
|
|
9
|
+
|
|
10
|
+
from jobhound.config import load_config
|
|
11
|
+
from jobhound.paths import paths_from_config
|
|
12
|
+
from jobhound.repository import OpportunityRepository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(
|
|
16
|
+
slug_query: str,
|
|
17
|
+
/,
|
|
18
|
+
*,
|
|
19
|
+
yes: bool = False,
|
|
20
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Delete an opportunity directory (e.g. a duplicate scaffold).
|
|
23
|
+
|
|
24
|
+
--yes: skip the confirmation prompt.
|
|
25
|
+
"""
|
|
26
|
+
cfg = load_config()
|
|
27
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
28
|
+
_, opp_dir = repo.find(slug_query)
|
|
29
|
+
if not yes:
|
|
30
|
+
confirm = questionary.confirm(f"Delete {opp_dir.name}?", default=False).ask()
|
|
31
|
+
if not confirm:
|
|
32
|
+
print("aborted")
|
|
33
|
+
raise SystemExit(1)
|
|
34
|
+
name = opp_dir.name
|
|
35
|
+
repo.delete(opp_dir, no_commit=no_commit)
|
|
36
|
+
print(f"deleted: {name}")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""`jh edit` — open meta.toml in $EDITOR with validation loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
from cyclopts import Parameter
|
|
12
|
+
|
|
13
|
+
from jobhound.config import load_config
|
|
14
|
+
from jobhound.meta_io import ValidationError, read_meta
|
|
15
|
+
from jobhound.paths import paths_from_config
|
|
16
|
+
from jobhound.repository import OpportunityRepository
|
|
17
|
+
|
|
18
|
+
_ERROR_PREFIX = "# ERROR:"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _editor_argv(cfg_editor: str) -> list[str]:
|
|
22
|
+
raw = cfg_editor or os.environ.get("EDITOR") or "vi"
|
|
23
|
+
return shlex.split(raw)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strip_error_block(text: str) -> str:
|
|
27
|
+
lines = text.splitlines(keepends=True)
|
|
28
|
+
idx = 0
|
|
29
|
+
while idx < len(lines) and lines[idx].startswith(_ERROR_PREFIX):
|
|
30
|
+
idx += 1
|
|
31
|
+
if idx == 0:
|
|
32
|
+
return text
|
|
33
|
+
if idx < len(lines) and lines[idx].rstrip() == "#":
|
|
34
|
+
idx += 1
|
|
35
|
+
if idx < len(lines) and lines[idx].strip() == "":
|
|
36
|
+
idx += 1
|
|
37
|
+
return "".join(lines[idx:])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepend_error(path: Path, message: str) -> None:
|
|
41
|
+
body = _strip_error_block(path.read_text())
|
|
42
|
+
block = "".join(f"{_ERROR_PREFIX} {line}\n" for line in message.splitlines())
|
|
43
|
+
path.write_text(f"{block}#\n{body}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _open_editor(argv: list[str], path: Path) -> None:
|
|
47
|
+
subprocess.run([*argv, str(path)], check=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run(
|
|
51
|
+
slug_query: str,
|
|
52
|
+
/,
|
|
53
|
+
*,
|
|
54
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Open meta.toml in $EDITOR; validate on save; rename on slug change."""
|
|
57
|
+
cfg = load_config()
|
|
58
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
59
|
+
_, opp_dir = repo.find(slug_query)
|
|
60
|
+
meta = opp_dir / "meta.toml"
|
|
61
|
+
editor_argv = _editor_argv(cfg.editor)
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
before = meta.read_text()
|
|
65
|
+
_open_editor(editor_argv, meta)
|
|
66
|
+
after = meta.read_text()
|
|
67
|
+
if after == before:
|
|
68
|
+
print("No changes.")
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
opp = read_meta(meta)
|
|
72
|
+
except ValidationError as exc:
|
|
73
|
+
_prepend_error(meta, str(exc))
|
|
74
|
+
continue
|
|
75
|
+
cleaned = _strip_error_block(meta.read_text())
|
|
76
|
+
if cleaned != after:
|
|
77
|
+
meta.write_text(cleaned)
|
|
78
|
+
opp = read_meta(meta)
|
|
79
|
+
final_dir = repo.save(opp, opp_dir, message=f"edit: {opp.slug}", no_commit=no_commit)
|
|
80
|
+
print(f"Updated {final_dir.relative_to(repo.paths.db_root)}")
|
|
81
|
+
return
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""`jh ghost` — no response, status → ghosted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from jobhound.commands._terminal import run_transition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
slug_query: str,
|
|
14
|
+
/,
|
|
15
|
+
*,
|
|
16
|
+
today: Annotated[str | None, Parameter(show=False)] = None,
|
|
17
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Mark this opportunity as ghosted (no response, giving up)."""
|
|
20
|
+
run_transition(
|
|
21
|
+
slug_query=slug_query,
|
|
22
|
+
verb="ghost",
|
|
23
|
+
today=today,
|
|
24
|
+
no_commit=no_commit,
|
|
25
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""`jh link` — add or update an entry in the links table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from jobhound.config import load_config
|
|
10
|
+
from jobhound.paths import paths_from_config
|
|
11
|
+
from jobhound.repository import OpportunityRepository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(
|
|
15
|
+
slug_query: str,
|
|
16
|
+
/,
|
|
17
|
+
*,
|
|
18
|
+
name: str,
|
|
19
|
+
url: str,
|
|
20
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Add or update a link."""
|
|
23
|
+
cfg = load_config()
|
|
24
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
25
|
+
opp, opp_dir = repo.find(slug_query)
|
|
26
|
+
updated = opp.with_link(name=name, url=url)
|
|
27
|
+
repo.save(updated, opp_dir, message=f"link: {opp.slug} {name}", no_commit=no_commit)
|
|
28
|
+
print(f"link {opp.slug}: {name} = {url}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""`jh list` — one-line summary of every opportunity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from jobhound.config import load_config
|
|
6
|
+
from jobhound.paths import paths_from_config
|
|
7
|
+
from jobhound.repository import OpportunityRepository
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run() -> None:
|
|
11
|
+
"""List every opportunity as `<slug> <status> <priority>`, sorted by slug."""
|
|
12
|
+
cfg = load_config()
|
|
13
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
14
|
+
for opp in repo.all():
|
|
15
|
+
print(f"{opp.slug:<55} {opp.status:<12} {opp.priority}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`jh log` — record an interaction; default next status advances one stage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
from cyclopts import Parameter
|
|
12
|
+
|
|
13
|
+
from jobhound.config import load_config
|
|
14
|
+
from jobhound.paths import paths_from_config
|
|
15
|
+
from jobhound.repository import OpportunityRepository
|
|
16
|
+
from jobhound.transitions import InvalidTransitionError
|
|
17
|
+
|
|
18
|
+
_NAME_SLUG = re.compile(r"[^a-z0-9]+")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _name_slug(who: str) -> str:
|
|
22
|
+
return _NAME_SLUG.sub("-", who.lower()).strip("-") or "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _correspondence_filename(when: date, channel: str, direction: str, who: str) -> str:
|
|
26
|
+
return f"{when.isoformat()}-{channel}-{direction}-{_name_slug(who)}.md"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
slug_query: str,
|
|
31
|
+
/,
|
|
32
|
+
*,
|
|
33
|
+
channel: str,
|
|
34
|
+
direction: str,
|
|
35
|
+
who: str,
|
|
36
|
+
body: Path,
|
|
37
|
+
next_status: str = "stay",
|
|
38
|
+
next_action: str | None = None,
|
|
39
|
+
next_action_due: str | None = None,
|
|
40
|
+
force: bool = False,
|
|
41
|
+
today: Annotated[str | None, Parameter(show=False)] = None,
|
|
42
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Record an interaction (correspondence) and update status + next action."""
|
|
45
|
+
cfg = load_config()
|
|
46
|
+
repo = OpportunityRepository(paths_from_config(cfg), cfg)
|
|
47
|
+
today_date = date.fromisoformat(today) if today else date.today()
|
|
48
|
+
|
|
49
|
+
if direction not in {"from", "to"}:
|
|
50
|
+
print(f"--direction must be 'from' or 'to', got {direction!r}", file=sys.stderr)
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
if not body.is_file():
|
|
53
|
+
print(f"--body file not found: {body}", file=sys.stderr)
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
|
|
56
|
+
opp, opp_dir = repo.find(slug_query)
|
|
57
|
+
due = date.fromisoformat(next_action_due) if next_action_due else None
|
|
58
|
+
try:
|
|
59
|
+
updated = opp.log_interaction(
|
|
60
|
+
today=today_date,
|
|
61
|
+
next_status=next_status,
|
|
62
|
+
next_action=next_action,
|
|
63
|
+
next_action_due=due,
|
|
64
|
+
force=force,
|
|
65
|
+
)
|
|
66
|
+
except InvalidTransitionError as exc:
|
|
67
|
+
print(str(exc), file=sys.stderr)
|
|
68
|
+
raise SystemExit(1) from exc
|
|
69
|
+
|
|
70
|
+
corr_dir = opp_dir / "correspondence"
|
|
71
|
+
corr_dir.mkdir(exist_ok=True)
|
|
72
|
+
corr_path = corr_dir / _correspondence_filename(today_date, channel, direction, who)
|
|
73
|
+
corr_path.write_text(body.read_text())
|
|
74
|
+
|
|
75
|
+
arrow = (
|
|
76
|
+
f"{opp.status} → {updated.status}" if updated.status != opp.status else "(no status change)"
|
|
77
|
+
)
|
|
78
|
+
repo.save(updated, opp_dir, message=f"log: {opp.slug} {arrow}", no_commit=no_commit)
|
|
79
|
+
print(f"logged: {opp.slug} {arrow}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`jh new` — scaffold a new opportunity at status `prospect`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from cyclopts import Parameter
|
|
10
|
+
|
|
11
|
+
from jobhound.config import load_config
|
|
12
|
+
from jobhound.opportunities import Opportunity
|
|
13
|
+
from jobhound.paths import Paths, paths_from_config
|
|
14
|
+
from jobhound.priority import Priority
|
|
15
|
+
from jobhound.repository import OpportunityRepository
|
|
16
|
+
from jobhound.slug_value import Slug
|
|
17
|
+
from jobhound.status import Status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(
|
|
21
|
+
*,
|
|
22
|
+
company: str,
|
|
23
|
+
role: str,
|
|
24
|
+
source: str = "(unspecified)",
|
|
25
|
+
next_action: str = "Initial review of role and company",
|
|
26
|
+
next_action_due: str | None = None,
|
|
27
|
+
today: Annotated[str | None, Parameter(show=False)] = None,
|
|
28
|
+
no_commit: Annotated[bool, Parameter(negative=())] = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Create a new opportunity at status `prospect`."""
|
|
31
|
+
cfg = load_config()
|
|
32
|
+
paths = paths_from_config(cfg)
|
|
33
|
+
Paths.ensure(paths)
|
|
34
|
+
repo = OpportunityRepository(paths, cfg)
|
|
35
|
+
|
|
36
|
+
today_date = date.fromisoformat(today) if today else date.today()
|
|
37
|
+
due = date.fromisoformat(next_action_due) if next_action_due else today_date + timedelta(days=7)
|
|
38
|
+
slug = Slug.build(today_date, company, role)
|
|
39
|
+
|
|
40
|
+
opp = Opportunity(
|
|
41
|
+
slug=slug.value,
|
|
42
|
+
company=company,
|
|
43
|
+
role=role,
|
|
44
|
+
status=Status.PROSPECT,
|
|
45
|
+
priority=Priority.MEDIUM,
|
|
46
|
+
source=source,
|
|
47
|
+
location=None,
|
|
48
|
+
comp_range=None,
|
|
49
|
+
first_contact=today_date,
|
|
50
|
+
applied_on=None,
|
|
51
|
+
last_activity=today_date,
|
|
52
|
+
next_action=next_action,
|
|
53
|
+
next_action_due=due,
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
opp_dir = repo.create(opp, message=f"new: {slug.value}", no_commit=no_commit)
|
|
57
|
+
except FileExistsError as exc:
|
|
58
|
+
print(str(exc), file=sys.stderr)
|
|
59
|
+
raise SystemExit(1) from exc
|
|
60
|
+
print(f"Created {opp_dir.relative_to(paths.db_root)}")
|