fix: cleanup codebase and sync SPEC with actual permissions

Phase 1: Ruff lint fixes
- Remove unused imports across all files
- Remove unused variables (now_utc, tz, ctx)
- Fix f-string without placeholders
- Fix E402 import order with noqa comments

Phase 2: Remove confusing hard delete from storage
- Removed delete_bounty() from RoomStorage Protocol (never used by app)
- Removed delete_bounty() from JsonFileRoomStorage (was hard delete)
- Removed corresponding tests (hard delete was never used)

Phase 3: Sync SPEC.md with actual code behavior
- Updated overview: admins can add/edit/delete (not 'anyone' + 'creator')
- Updated command table: /add, /edit, /delete are admin only
- Updated error handling messages

Test results: 96 passed (2 hard delete tests removed)
This commit is contained in:
shokollm
2026-04-09 10:01:02 +00:00
parent 75122b3ee2
commit 4885be0752
15 changed files with 14 additions and 85 deletions

10
SPEC.md
View File

@@ -8,7 +8,7 @@
JIGAIDO is a Telegram bot that lets groups and individuals track bounties — tasks, obligations, and deadlines — with optional due dates and personal tracking.
- **Group mode**: Each Telegram group has its own bounty list. Anyone can add bounties. Only creator can edit/delete.
- **Group mode**: Each Telegram group has its own bounty list. Admins can add/edit/delete bounties. Anyone can track.
- **DM mode**: Personal bounty list. Anyone can manage their own bounties.
- **Tracking**: Users can track any bounty (group or personal) to their tracking list.
- **Due dates**: Free-form text (`"april 15"`, `"in 3 days"`, `"tomorrow"`) parsed at add time, stored as Unix timestamp. If unparseable, stored as `NULL`.
@@ -122,9 +122,9 @@ Data is stored at `~/.jigaido/` (home directory), NOT inside the repository.
|---|---|---|
| `/bounty` | anyone | List all bounties in this group |
| `/my` | anyone | List bounties tracked by you in this group |
| `/add <text> [link] [due date]` | anyone | Add a new bounty to the group |
| `/edit <bounty_id> [text] [link] [due_date]` | creator only | Edit an existing bounty |
| `/delete <bounty_id>` | creator only | Delete a bounty |
| `/add <text> [link] [due date]` | admin only | Add a new bounty to the group |
| `/edit <bounty_id> [text] [link] [due_date]` | admin only | Edit an existing bounty |
| `/delete <bounty_id>` | admin only | Delete a bounty |
| `/track <bounty_id>` | anyone | Track a group bounty |
| `/untrack <bounty_id>` | anyone | Stop tracking a bounty |
@@ -170,7 +170,7 @@ Stored as Unix timestamp. User-facing display can be localized/converted to any
## Error Handling
- Unknown command → help text with available commands
- `/edit`/`/delete` by non-creator → "⛔ Only the creator can edit/delete this bounty."
- `/add`/`/edit`/`/delete` by non-admin → "⛔ Only admins can add/edit/delete bounties."
- `/track` already tracked → "Already tracking" (idempotent)
- `/untrack` not tracked → "Not tracking" (idempotent)
- Bounty not found → "Bounty not found"

View File

@@ -12,7 +12,6 @@ import tempfile
from pathlib import Path
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
from core.ports import RoomStorage, TrackingStorage
class JsonFileRoomStorage:
@@ -119,15 +118,6 @@ class JsonFileRoomStorage:
self.save(room_data)
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
"""Delete a bounty from a room."""
room_data = self.load(room_id)
if room_data is None:
return
room_data.bounties = [b for b in room_data.bounties if b.id != bounty_id]
self.save(room_data)
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty from a room by ID."""
room_data = self.load(room_id)

View File

@@ -1,7 +1,6 @@
"""JIGAIDO Telegram bot entrypoint."""
import logging
import os
import sys
sys.path.insert(0, "/home/shoko/repositories/jigaido")
@@ -9,9 +8,7 @@ sys.path.insert(0, "/home/shoko/repositories/jigaido")
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
filters,
)
from commands import (
@@ -38,7 +35,7 @@ logging.basicConfig(
)
log = logging.getLogger(__name__)
from config import config
from config import config # noqa: E402
BOT_TOKEN = config.bot_token or ""

View File

@@ -1,8 +1,7 @@
"""Telegram command handlers for JIGAIDO - Thin wrappers around core services."""
import time
from datetime import datetime, timezone
from functools import wraps
from datetime import datetime
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -175,7 +174,6 @@ def format_bounty(
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
now_utc = int(time.time())
dt_now = datetime.now(tz)
dt_due = datetime.fromtimestamp(b.due_date_ts, tz=tz)
@@ -459,11 +457,6 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
timezone_str = BOUNTY_SERVICE.get_timezone(room_id)
try:
tz = ZoneInfo(timezone_str)
except (KeyError, ZoneInfoNotFoundError):
tz = ZoneInfo("UTC")
text, link, due_date_ts, clear_link, clear_date = parse_args(args[1:], timezone_str)
if (
not text
@@ -1034,7 +1027,7 @@ async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
f"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions),
"Room Admins:\n" + "\n".join(f"- {m}" for m in admin_mentions),
parse_mode=ParseMode.HTML,
reply_markup=reply_markup,
)

View File

@@ -8,7 +8,7 @@ os.chdir("/home/shoko/repositories/jigaido/apps/telegram-bot")
sys.path.insert(0, "/home/shoko/repositories/jigaido")
# Import main from the local bot module
import bot as bot_module
import bot as bot_module # noqa: E402
if __name__ == "__main__":
if not bot_module.BOT_TOKEN:

View File

@@ -1,10 +1,8 @@
"""Pytest fixtures for telegram-bot tests."""
import sys
import tempfile
from pathlib import Path
import pytest
# Add the app directory to path so imports work when running pytest
sys.path.insert(0, str(Path(__file__).parent.parent))

View File

@@ -1,11 +1,11 @@
"""Tests for commands.py — parsing, formatting, and command handlers."""
import time
from unittest.mock import MagicMock, patch, AsyncMock, sentinel
from unittest.mock import MagicMock, patch, AsyncMock
import pytest
from telegram import Update, Message, User, Chat, CallbackQuery
from telegram import Update, Message, User, Chat
from telegram.ext import ContextTypes
from commands import (
@@ -368,7 +368,6 @@ class TestCmdAdd:
@pytest.mark.asyncio
async def test_add_needs_text_or_link(self):
update = create_mock_update(message_text="/add")
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
_, link, _ = parse_args([])
if not "test" and not link:

View File

@@ -2,7 +2,6 @@
import argparse
import sys
from pathlib import Path
import dateparser

View File

@@ -32,10 +32,6 @@ class RoomStorage(Protocol):
"""Update an existing bounty in a room."""
...
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
"""Delete a bounty from a room."""
...
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
"""Get a specific bounty from a room by ID."""
...

View File

@@ -3,7 +3,6 @@
import pytest
from unittest.mock import patch, MagicMock
from io import StringIO
import sys
from core.models import Bounty
from core.ports import RoomStorage, TrackingStorage
@@ -237,7 +236,7 @@ class TestCLIValidation:
main()
mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_link") == True
assert call_kwargs.kwargs.get("clear_link") is True
def test_update_clear_due_flag(self):
"""Test update with --clear-due flag."""
@@ -255,7 +254,7 @@ class TestCLIValidation:
main()
mock_bounty_service.update_bounty.assert_called_once()
call_kwargs = mock_bounty_service.update_bounty.call_args
assert call_kwargs.kwargs.get("clear_due") == True
assert call_kwargs.kwargs.get("clear_due") is True
class TestCLIOutput:

View File

@@ -2,11 +2,9 @@
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from config import Config, DEFAULT_DATA_DIR

View File

@@ -1,11 +1,9 @@
"""Tests for adapters/storage/json_file.py — JSON file storage adapter."""
import json
import os
import tempfile
from pathlib import Path
import pytest
from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
@@ -99,15 +97,6 @@ class TestJsonFileRoomStorage:
assert self.storage.load(-1001) is None
def test_delete_bounty(self):
"""Test that delete_bounty removes a bounty."""
bounty = self._create_bounty(id=1)
self.storage.add_bounty(-1001, bounty)
self.storage.delete_bounty(-1001, 1)
loaded = self.storage.load(-1001)
assert len(loaded.bounties) == 0
def test_get_bounty_found(self):
"""Test that get_bounty returns the bounty when found."""
bounty = self._create_bounty(id=1)

View File

@@ -1,8 +1,6 @@
"""Tests for core/models.py — domain dataclasses."""
import time
import pytest
from core.models import (
Bounty,

View File

@@ -1,6 +1,5 @@
"""Tests for core/ports.py — storage interfaces."""
import pytest
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
from core.ports import RoomStorage, TrackingStorage
@@ -35,12 +34,6 @@ class SimpleRoomStorage:
self._rooms[room_id].bounties[i] = bounty
break
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
if room_id in self._rooms:
self._rooms[room_id].bounties = [
b for b in self._rooms[room_id].bounties if b.id != bounty_id
]
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
if room_id in self._rooms:
for b in self._rooms[room_id].bounties:
@@ -120,12 +113,6 @@ class MockRoomStorage:
self._rooms[room_id].bounties[i] = bounty
break
def delete_bounty(self, room_id: int, bounty_id: int) -> None:
if room_id in self._rooms:
self._rooms[room_id].bounties = [
b for b in self._rooms[room_id].bounties if b.id != bounty_id
]
def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None:
if room_id in self._rooms:
for b in self._rooms[room_id].bounties:
@@ -243,19 +230,6 @@ class TestRoomStorage:
assert result is not None
assert result.text == "Updated"
def test_delete_bounty(self):
storage = MockRoomStorage()
bounty = Bounty(
id=1,
text="Test",
link=None,
due_date_ts=None,
created_at=0,
created_by_user_id=123,
)
storage.add_bounty(-1001, bounty)
storage.delete_bounty(-1001, 1)
assert storage.get_bounty(-1001, 1) is None
class TestTrackingStorage:

View File

@@ -1,9 +1,8 @@
"""Tests for core/services.py — business logic services."""
import pytest
from unittest.mock import MagicMock
from core.models import Bounty, RoomData, TrackingData, TrackedBounty
from core.models import RoomData
from core.services import BountyService, TrackingService
from tests.test_ports import MockRoomStorage, MockTrackingStorage