git-ew 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.
- git_ew/__init__.py +166 -0
- git_ew/__main__.py +32 -0
- git_ew/_internal/__init__.py +17 -0
- git_ew/_internal/app.py +418 -0
- git_ew/_internal/cli.py +246 -0
- git_ew/_internal/config.py +148 -0
- git_ew/_internal/database.py +297 -0
- git_ew/_internal/debug.py +130 -0
- git_ew/_internal/email_fetcher.py +301 -0
- git_ew/_internal/email_parser.py +274 -0
- git_ew/_internal/email_sender.py +220 -0
- git_ew/_internal/mailing_lists/__init__.py +19 -0
- git_ew/_internal/mailing_lists/zsh_workers/__init__.py +17 -0
- git_ew/_internal/mailing_lists/zsh_workers/ingest.py +427 -0
- git_ew/_internal/mailing_lists/zsh_workers/sync_archives.py +300 -0
- git_ew/_internal/models.py +149 -0
- git_ew/_internal/secrets.py +59 -0
- git_ew/_internal/sync.py +156 -0
- git_ew/_internal/thread_utils.py +185 -0
- git_ew/py.typed +0 -0
- git_ew/static/css/style.css +608 -0
- git_ew/static/js/main.js +47 -0
- git_ew/templates/base.html +38 -0
- git_ew/templates/index.html +44 -0
- git_ew/templates/thread.html +153 -0
- git_ew-0.1.0.dist-info/METADATA +291 -0
- git_ew-0.1.0.dist-info/RECORD +30 -0
- git_ew-0.1.0.dist-info/WHEEL +4 -0
- git_ew-0.1.0.dist-info/entry_points.txt +5 -0
- git_ew-0.1.0.dist-info/licenses/LICENSE +15 -0
git_ew/_internal/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# SPDX-License-Identifier: ISC
|
|
2
|
+
#
|
|
3
|
+
# ISC License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Timothée Mazzucotelli and contributors
|
|
6
|
+
#
|
|
7
|
+
# Permission to use, copy, modify, and/or distribute this software for any
|
|
8
|
+
# purpose with or without fee is hereby granted, provided that the above
|
|
9
|
+
# copyright notice and this permission notice appear in all copies.
|
|
10
|
+
#
|
|
11
|
+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
12
|
+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
13
|
+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
14
|
+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
15
|
+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
16
|
+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
17
|
+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
18
|
+
|
|
19
|
+
# Why does this file exist, and why not put this in `__main__`?
|
|
20
|
+
#
|
|
21
|
+
# You might be tempted to import things from `__main__` later,
|
|
22
|
+
# but that will cause problems: the code will get executed twice:
|
|
23
|
+
#
|
|
24
|
+
# - When you run `python -m git_ew` python will execute
|
|
25
|
+
# `__main__.py` as a script. That means there won't be any
|
|
26
|
+
# `git_ew.__main__` in `sys.modules`.
|
|
27
|
+
# - When you import `__main__` it will get executed again (as a module) because
|
|
28
|
+
# there's no `git_ew.__main__` in `sys.modules`.
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import asyncio
|
|
34
|
+
import logging
|
|
35
|
+
import sys
|
|
36
|
+
from datetime import date
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
from urllib.error import URLError
|
|
40
|
+
|
|
41
|
+
import uvicorn
|
|
42
|
+
|
|
43
|
+
from git_ew._internal import debug
|
|
44
|
+
from git_ew._internal.config import config_command
|
|
45
|
+
from git_ew._internal.database import Database
|
|
46
|
+
from git_ew._internal.mailing_lists.zsh_workers.ingest import ingest_archives
|
|
47
|
+
from git_ew._internal.mailing_lists.zsh_workers.sync_archives import (
|
|
48
|
+
_get_matching_archives,
|
|
49
|
+
download_archive,
|
|
50
|
+
fetch_archive_list,
|
|
51
|
+
get_missing_archives,
|
|
52
|
+
)
|
|
53
|
+
from git_ew._internal.sync import sync_command
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _DebugInfo(argparse.Action):
|
|
57
|
+
def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
|
|
58
|
+
super().__init__(nargs=nargs, **kwargs)
|
|
59
|
+
|
|
60
|
+
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
|
|
61
|
+
print(debug._format_debug_info())
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_archive_date(value: str, *, end_of_year: bool = False) -> date:
|
|
66
|
+
"""Parse a CLI archive date."""
|
|
67
|
+
try:
|
|
68
|
+
if len(value) == 4: # noqa: PLR2004
|
|
69
|
+
year = int(value)
|
|
70
|
+
if end_of_year:
|
|
71
|
+
return date(year, 12, 31)
|
|
72
|
+
return date(year, 1, 1)
|
|
73
|
+
return date.fromisoformat(value)
|
|
74
|
+
except ValueError as error:
|
|
75
|
+
raise argparse.ArgumentTypeError(
|
|
76
|
+
f"invalid date {value!r}; use YYYY or YYYY-MM-DD",
|
|
77
|
+
) from error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_parser() -> argparse.ArgumentParser:
|
|
81
|
+
"""Return the CLI argument parser.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
An argparse parser.
|
|
85
|
+
"""
|
|
86
|
+
parser = argparse.ArgumentParser(
|
|
87
|
+
prog="git-ew",
|
|
88
|
+
description="Git Email Workflow - A web interface for email-based git workflows",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
|
|
91
|
+
parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
|
|
92
|
+
|
|
93
|
+
# Subcommands
|
|
94
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
95
|
+
|
|
96
|
+
# Server command
|
|
97
|
+
server_parser = subparsers.add_parser("serve", help="Start the web server")
|
|
98
|
+
server_parser.add_argument(
|
|
99
|
+
"--host",
|
|
100
|
+
default="127.0.0.1",
|
|
101
|
+
help="Host to bind to (default: 127.0.0.1)",
|
|
102
|
+
)
|
|
103
|
+
server_parser.add_argument(
|
|
104
|
+
"--port",
|
|
105
|
+
type=int,
|
|
106
|
+
default=8000,
|
|
107
|
+
help="Port to bind to (default: 8000)",
|
|
108
|
+
)
|
|
109
|
+
server_parser.add_argument(
|
|
110
|
+
"--reload",
|
|
111
|
+
action="store_true",
|
|
112
|
+
help="Enable auto-reload for development",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Init command
|
|
116
|
+
subparsers.add_parser("init", help="Initialize the database")
|
|
117
|
+
|
|
118
|
+
# Sync command
|
|
119
|
+
subparsers.add_parser("sync", help="Sync emails from configured sources")
|
|
120
|
+
|
|
121
|
+
# Configuration command
|
|
122
|
+
subparsers.add_parser("config", help="Configure email accounts and sources")
|
|
123
|
+
|
|
124
|
+
# Archive ingestion commands
|
|
125
|
+
ingest_parser = subparsers.add_parser("ingest", help="Ingest mailing-list archives")
|
|
126
|
+
ingest_subparsers = ingest_parser.add_subparsers(dest="ingest_source", required=True)
|
|
127
|
+
zsh_workers_parser = ingest_subparsers.add_parser(
|
|
128
|
+
"zsh-workers",
|
|
129
|
+
help="Download and ingest zsh-workers archives",
|
|
130
|
+
)
|
|
131
|
+
zsh_workers_parser.add_argument(
|
|
132
|
+
"--since",
|
|
133
|
+
type=_parse_archive_date,
|
|
134
|
+
help="Only include archives from this date (YYYY or YYYY-MM-DD)",
|
|
135
|
+
)
|
|
136
|
+
zsh_workers_parser.add_argument(
|
|
137
|
+
"--until",
|
|
138
|
+
type=lambda value: _parse_archive_date(value, end_of_year=True),
|
|
139
|
+
help="Only include archives through this date (YYYY or YYYY-MM-DD)",
|
|
140
|
+
)
|
|
141
|
+
zsh_workers_parser.add_argument(
|
|
142
|
+
"--archive-dir",
|
|
143
|
+
type=Path,
|
|
144
|
+
default=Path(".archives"),
|
|
145
|
+
help="Directory for archives (default: .archives)",
|
|
146
|
+
)
|
|
147
|
+
zsh_workers_parser.add_argument(
|
|
148
|
+
"--database",
|
|
149
|
+
default="sqlite:///./git_ew.db",
|
|
150
|
+
help="SQLAlchemy database URL (default: sqlite:///./git_ew.db)",
|
|
151
|
+
)
|
|
152
|
+
zsh_workers_parser.add_argument(
|
|
153
|
+
"--dry-run",
|
|
154
|
+
action="store_true",
|
|
155
|
+
help="Show archives to download without downloading or ingesting",
|
|
156
|
+
)
|
|
157
|
+
zsh_workers_parser.add_argument(
|
|
158
|
+
"-v",
|
|
159
|
+
"--verbose",
|
|
160
|
+
action="store_true",
|
|
161
|
+
help="List archives selected for download",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return parser
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(args: list[str] | None = None) -> int:
|
|
168
|
+
"""Run the main program.
|
|
169
|
+
|
|
170
|
+
This function is executed when you type `git-ew` or `python -m git_ew`.
|
|
171
|
+
|
|
172
|
+
Parameters:
|
|
173
|
+
args: Arguments passed from the command line.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
An exit code.
|
|
177
|
+
"""
|
|
178
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
179
|
+
parser = get_parser()
|
|
180
|
+
opts = parser.parse_args(args=args)
|
|
181
|
+
|
|
182
|
+
if opts.command == "serve":
|
|
183
|
+
# Start the web server
|
|
184
|
+
print(f"Starting git-ew server at http://{opts.host}:{opts.port}")
|
|
185
|
+
print("Press Ctrl+C to stop the server")
|
|
186
|
+
|
|
187
|
+
uvicorn.run(
|
|
188
|
+
"git_ew._internal.app:app",
|
|
189
|
+
host=opts.host,
|
|
190
|
+
port=opts.port,
|
|
191
|
+
reload=opts.reload,
|
|
192
|
+
)
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
if opts.command == "init":
|
|
196
|
+
# Initialize the database
|
|
197
|
+
async def init_database() -> None:
|
|
198
|
+
db = Database()
|
|
199
|
+
await db.init_db()
|
|
200
|
+
print("Database initialized successfully")
|
|
201
|
+
|
|
202
|
+
asyncio.run(init_database())
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
if opts.command == "sync":
|
|
206
|
+
# Sync emails from sources
|
|
207
|
+
return asyncio.run(sync_command())
|
|
208
|
+
|
|
209
|
+
if opts.command == "config":
|
|
210
|
+
asyncio.run(config_command())
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
if opts.command == "ingest" and opts.ingest_source == "zsh-workers":
|
|
214
|
+
opts.archive_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
try:
|
|
216
|
+
available = fetch_archive_list()
|
|
217
|
+
except URLError as error:
|
|
218
|
+
print(f"Error fetching zsh-workers archive list: {error}", file=sys.stderr)
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
matching = _get_matching_archives(available, opts.since, opts.until)
|
|
222
|
+
missing = get_missing_archives(opts.archive_dir, available, opts.since, opts.until)
|
|
223
|
+
print(f"Found {len(available)} archives available")
|
|
224
|
+
print(f"Found {len(matching)} matching archives")
|
|
225
|
+
print(f"Found {len(missing)} matching archives not downloaded")
|
|
226
|
+
if opts.verbose:
|
|
227
|
+
for filename in missing:
|
|
228
|
+
print(f" - {filename}")
|
|
229
|
+
if opts.dry_run:
|
|
230
|
+
print("Dry-run: no archives downloaded or ingested")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
failed = 0
|
|
234
|
+
for filename in missing:
|
|
235
|
+
if not download_archive(filename, opts.archive_dir):
|
|
236
|
+
failed += 1
|
|
237
|
+
if failed:
|
|
238
|
+
print(f"Error: {failed} archive download(s) failed", file=sys.stderr)
|
|
239
|
+
return 1
|
|
240
|
+
|
|
241
|
+
ingest_archives(opts.archive_dir, opts.database, filenames=matching)
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
# No command specified, show help
|
|
245
|
+
parser.print_help()
|
|
246
|
+
return 0
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SPDX-License-Identifier: ISC
|
|
2
|
+
#
|
|
3
|
+
# ISC License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Timothée Mazzucotelli and contributors
|
|
6
|
+
#
|
|
7
|
+
# Permission to use, copy, modify, and/or distribute this software for any
|
|
8
|
+
# purpose with or without fee is hereby granted, provided that the above
|
|
9
|
+
# copyright notice and this permission notice appear in all copies.
|
|
10
|
+
#
|
|
11
|
+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
12
|
+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
13
|
+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
14
|
+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
15
|
+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
16
|
+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
17
|
+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
18
|
+
|
|
19
|
+
# Interactive git-ew configuration wizard.
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
from asyncio import to_thread
|
|
26
|
+
from getpass import getpass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from git_ew._internal.database import Database
|
|
30
|
+
from git_ew._internal.models import EmailSource
|
|
31
|
+
|
|
32
|
+
_logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _prompt_password_config() -> dict[str, str]:
|
|
36
|
+
"""Prompt for direct or command-based password storage."""
|
|
37
|
+
choice = input("Password storage: 1 direct, 2 command (default 1): ").strip() or "1"
|
|
38
|
+
if choice == "1":
|
|
39
|
+
return {"password": getpass("Password/App Password: ").strip()}
|
|
40
|
+
if choice == "2":
|
|
41
|
+
command = input("Password command: ").strip()
|
|
42
|
+
if not command:
|
|
43
|
+
raise ValueError("password command cannot be empty")
|
|
44
|
+
return {"password_command": command}
|
|
45
|
+
raise ValueError("password storage must be 1 or 2")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _prompt_email_config() -> dict[str, Any]:
|
|
49
|
+
"""Collect email delivery settings from the terminal."""
|
|
50
|
+
_logger.info("Email Configuration (for sending replies)")
|
|
51
|
+
_logger.info("-" * 50)
|
|
52
|
+
smtp_host = input("SMTP Host (e.g., smtp.fastmail.com): ").strip()
|
|
53
|
+
smtp_port = input("SMTP Port (default 587): ").strip() or "587"
|
|
54
|
+
from_email = input("Your Email Address: ").strip()
|
|
55
|
+
from_name = input("Your Name: ").strip()
|
|
56
|
+
username = input("SMTP Username (default: same as email): ").strip() or from_email
|
|
57
|
+
use_tls = input("Use TLS? (Y/n): ").strip().lower() != "n"
|
|
58
|
+
|
|
59
|
+
email_config: dict[str, Any] = {
|
|
60
|
+
"smtp_host": smtp_host,
|
|
61
|
+
"smtp_port": int(smtp_port),
|
|
62
|
+
"from_email": from_email,
|
|
63
|
+
"from_name": from_name,
|
|
64
|
+
"username": username,
|
|
65
|
+
"use_tls": use_tls,
|
|
66
|
+
**_prompt_password_config(),
|
|
67
|
+
}
|
|
68
|
+
return email_config
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prompt_email_source() -> EmailSource | None:
|
|
72
|
+
"""Collect an email source from the terminal."""
|
|
73
|
+
_logger.info("Email Source Configuration")
|
|
74
|
+
_logger.info("-" * 50)
|
|
75
|
+
_logger.info("Where should git-ew fetch emails from?")
|
|
76
|
+
_logger.info("1. Maildir (local maildir folder)")
|
|
77
|
+
_logger.info("2. Mbox (mbox archive file)")
|
|
78
|
+
_logger.info("3. IMAP (Fastmail or another IMAP server)")
|
|
79
|
+
_logger.info("4. Skip for now")
|
|
80
|
+
|
|
81
|
+
choice = input("\nChoice (1-4): ").strip()
|
|
82
|
+
if choice == "1":
|
|
83
|
+
source = EmailSource(
|
|
84
|
+
name=input("Source name (e.g., 'my-maildir'): ").strip(),
|
|
85
|
+
source_type="maildir",
|
|
86
|
+
config=json.dumps({"path": input("Path to maildir folder: ").strip()}),
|
|
87
|
+
enabled=True,
|
|
88
|
+
)
|
|
89
|
+
elif choice == "2":
|
|
90
|
+
source = EmailSource(
|
|
91
|
+
name=input("Source name (e.g., 'mailing-list'): ").strip(),
|
|
92
|
+
source_type="mbox",
|
|
93
|
+
config=json.dumps({"path": input("Path to mbox file: ").strip()}),
|
|
94
|
+
enabled=True,
|
|
95
|
+
)
|
|
96
|
+
elif choice == "3":
|
|
97
|
+
folders = input("Folders (comma-separated, default: INBOX,Sent): ").strip() or "INBOX,Sent"
|
|
98
|
+
addresses = input("Mailing-list addresses (default: zsh-workers@zsh.org): ").strip() or "zsh-workers@zsh.org"
|
|
99
|
+
list_ids = (
|
|
100
|
+
input("Mailing-list List-Id values (default: zsh-workers.zsh.org): ").strip() or "zsh-workers.zsh.org"
|
|
101
|
+
)
|
|
102
|
+
source = EmailSource(
|
|
103
|
+
name=input("Source name (e.g., 'fastmail'): ").strip(),
|
|
104
|
+
source_type="imap",
|
|
105
|
+
config=json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"host": input("IMAP Host (default: imap.fastmail.com): ").strip() or "imap.fastmail.com",
|
|
108
|
+
"port": int(input("IMAP Port (default: 993): ").strip() or "993"),
|
|
109
|
+
"username": input("IMAP Username (your Fastmail address): ").strip(),
|
|
110
|
+
"folders": [folder.strip() for folder in folders.split(",") if folder.strip()],
|
|
111
|
+
"mailing_list": {
|
|
112
|
+
"addresses": [address.strip().lower() for address in addresses.split(",") if address.strip()],
|
|
113
|
+
"list_ids": [list_id.strip().lower() for list_id in list_ids.split(",") if list_id.strip()],
|
|
114
|
+
},
|
|
115
|
+
**_prompt_password_config(),
|
|
116
|
+
},
|
|
117
|
+
),
|
|
118
|
+
enabled=True,
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
_logger.info("Skipped email source configuration")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return source
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def config_command() -> None:
|
|
128
|
+
"""Configure email delivery and ingestion sources interactively."""
|
|
129
|
+
_logger.info("=== git-ew Configuration Wizard ===")
|
|
130
|
+
|
|
131
|
+
db = Database()
|
|
132
|
+
_logger.info("Initializing database")
|
|
133
|
+
await db.init_db()
|
|
134
|
+
_logger.info("Database initialized")
|
|
135
|
+
|
|
136
|
+
email_config = await to_thread(_prompt_email_config)
|
|
137
|
+
await db.set_config("email_config", email_config)
|
|
138
|
+
_logger.info("Email configuration saved")
|
|
139
|
+
|
|
140
|
+
source = await to_thread(_prompt_email_source)
|
|
141
|
+
if source is None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
async with db.session() as session:
|
|
145
|
+
session.add(source)
|
|
146
|
+
_logger.info("Email source added")
|
|
147
|
+
_logger.info("=" * 50)
|
|
148
|
+
_logger.info("Configuration complete")
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# SPDX-License-Identifier: ISC
|
|
2
|
+
#
|
|
3
|
+
# ISC License
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) 2026, Timothée Mazzucotelli and contributors
|
|
6
|
+
#
|
|
7
|
+
# Permission to use, copy, modify, and/or distribute this software for any
|
|
8
|
+
# purpose with or without fee is hereby granted, provided that the above
|
|
9
|
+
# copyright notice and this permission notice appear in all copies.
|
|
10
|
+
#
|
|
11
|
+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
12
|
+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
13
|
+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
14
|
+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
15
|
+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
16
|
+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
17
|
+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
18
|
+
|
|
19
|
+
# Database operations for git-ew.
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from contextlib import asynccontextmanager
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from sqlalchemy import select
|
|
29
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
30
|
+
from sqlalchemy.orm import selectinload
|
|
31
|
+
|
|
32
|
+
from git_ew._internal.models import Base, Configuration, EmailSource, Message, Thread
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import AsyncIterator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Database:
|
|
39
|
+
"""Database manager."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, database_url: str = "sqlite+aiosqlite:///./git_ew.db"):
|
|
42
|
+
"""Initialize database manager.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
database_url: Database connection URL.
|
|
46
|
+
"""
|
|
47
|
+
self.engine = create_async_engine(database_url, echo=False)
|
|
48
|
+
"""SQLAlchemy async engine instance."""
|
|
49
|
+
self.session_maker = async_sessionmaker(self.engine, class_=AsyncSession, expire_on_commit=False)
|
|
50
|
+
"""Async session factory."""
|
|
51
|
+
|
|
52
|
+
async def init_db(self) -> None:
|
|
53
|
+
"""Initialize database tables."""
|
|
54
|
+
async with self.engine.begin() as conn:
|
|
55
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
56
|
+
|
|
57
|
+
@asynccontextmanager
|
|
58
|
+
async def session(self) -> AsyncIterator[AsyncSession]:
|
|
59
|
+
"""Get a database session."""
|
|
60
|
+
async with self.session_maker() as session:
|
|
61
|
+
try:
|
|
62
|
+
yield session
|
|
63
|
+
await session.commit()
|
|
64
|
+
except Exception:
|
|
65
|
+
await session.rollback()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
async def get_threads(
|
|
69
|
+
self,
|
|
70
|
+
status: str | None = None,
|
|
71
|
+
limit: int | None = None,
|
|
72
|
+
offset: int = 0,
|
|
73
|
+
) -> list[Thread]:
|
|
74
|
+
"""Get threads with optional filtering.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
status: Filter by status (open/closed).
|
|
78
|
+
limit: Maximum number of threads to return.
|
|
79
|
+
offset: Number of threads to skip.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of threads.
|
|
83
|
+
"""
|
|
84
|
+
async with self.session() as session:
|
|
85
|
+
query = select(Thread).order_by(Thread.updated_at.desc())
|
|
86
|
+
if status:
|
|
87
|
+
query = query.where(Thread.status == status)
|
|
88
|
+
if limit is not None:
|
|
89
|
+
query = query.limit(limit)
|
|
90
|
+
query = query.offset(offset)
|
|
91
|
+
result = await session.execute(query)
|
|
92
|
+
return list(result.scalars().all())
|
|
93
|
+
|
|
94
|
+
async def get_thread(self, thread_id: int) -> Thread | None:
|
|
95
|
+
"""Get a thread by ID with all messages.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
thread_id: Thread ID.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Thread or None if not found.
|
|
102
|
+
"""
|
|
103
|
+
async with self.session() as session:
|
|
104
|
+
query = select(Thread).where(Thread.id == thread_id).options(selectinload(Thread.messages))
|
|
105
|
+
result = await session.execute(query)
|
|
106
|
+
return result.scalar_one_or_none()
|
|
107
|
+
|
|
108
|
+
async def get_thread_by_message_id(self, message_id: str) -> Thread | None:
|
|
109
|
+
"""Get a thread by first message ID.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
message_id: Message ID.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Thread or None if not found.
|
|
116
|
+
"""
|
|
117
|
+
async with self.session() as session:
|
|
118
|
+
query = select(Thread).where(Thread.first_message_id == message_id)
|
|
119
|
+
result = await session.execute(query)
|
|
120
|
+
return result.scalar_one_or_none()
|
|
121
|
+
|
|
122
|
+
async def create_thread(
|
|
123
|
+
self,
|
|
124
|
+
subject: str,
|
|
125
|
+
first_message_id: str,
|
|
126
|
+
*,
|
|
127
|
+
is_patch: bool = False,
|
|
128
|
+
) -> Thread:
|
|
129
|
+
"""Create a new thread.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
subject: Thread subject.
|
|
133
|
+
first_message_id: ID of the first message.
|
|
134
|
+
is_patch: Whether this thread contains patches.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Created thread.
|
|
138
|
+
"""
|
|
139
|
+
now = datetime.now(UTC)
|
|
140
|
+
thread = Thread(
|
|
141
|
+
subject=subject,
|
|
142
|
+
first_message_id=first_message_id,
|
|
143
|
+
created_at=now,
|
|
144
|
+
updated_at=now,
|
|
145
|
+
is_patch=is_patch,
|
|
146
|
+
status="open",
|
|
147
|
+
)
|
|
148
|
+
async with self.session() as session:
|
|
149
|
+
session.add(thread)
|
|
150
|
+
await session.flush()
|
|
151
|
+
await session.refresh(thread)
|
|
152
|
+
return thread
|
|
153
|
+
|
|
154
|
+
async def create_message( # noqa: PLR0917
|
|
155
|
+
self,
|
|
156
|
+
message_id: str,
|
|
157
|
+
thread_id: int,
|
|
158
|
+
from_email: str,
|
|
159
|
+
from_name: str,
|
|
160
|
+
subject: str,
|
|
161
|
+
date: datetime,
|
|
162
|
+
body: str,
|
|
163
|
+
*,
|
|
164
|
+
in_reply_to: str | None = None,
|
|
165
|
+
is_patch: bool = False,
|
|
166
|
+
patch_content: str | None = None,
|
|
167
|
+
raw_email: str | None = None,
|
|
168
|
+
) -> Message:
|
|
169
|
+
"""Create a new message.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
message_id: Unique message ID.
|
|
173
|
+
thread_id: Thread ID this message belongs to.
|
|
174
|
+
from_email: Sender email.
|
|
175
|
+
from_name: Sender name.
|
|
176
|
+
subject: Message subject.
|
|
177
|
+
date: Message date.
|
|
178
|
+
body: Message body.
|
|
179
|
+
in_reply_to: ID of message this replies to.
|
|
180
|
+
is_patch: Whether this message contains a patch.
|
|
181
|
+
patch_content: Patch content if applicable.
|
|
182
|
+
raw_email: Raw email content.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Created message.
|
|
186
|
+
"""
|
|
187
|
+
message = Message(
|
|
188
|
+
message_id=message_id,
|
|
189
|
+
thread_id=thread_id,
|
|
190
|
+
from_email=from_email,
|
|
191
|
+
from_name=from_name,
|
|
192
|
+
subject=subject,
|
|
193
|
+
date=date,
|
|
194
|
+
body=body,
|
|
195
|
+
in_reply_to=in_reply_to,
|
|
196
|
+
is_patch=is_patch,
|
|
197
|
+
patch_content=patch_content,
|
|
198
|
+
raw_email=raw_email,
|
|
199
|
+
)
|
|
200
|
+
async with self.session() as session:
|
|
201
|
+
session.add(message)
|
|
202
|
+
|
|
203
|
+
# Update thread's updated_at
|
|
204
|
+
thread = await session.get(Thread, thread_id)
|
|
205
|
+
if thread:
|
|
206
|
+
thread.updated_at = datetime.now(UTC)
|
|
207
|
+
|
|
208
|
+
await session.flush()
|
|
209
|
+
await session.refresh(message)
|
|
210
|
+
return message
|
|
211
|
+
|
|
212
|
+
async def get_message_by_id(self, message_id: str) -> Message | None:
|
|
213
|
+
"""Get a message by its message ID.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
message_id: Message ID.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Message or None if not found.
|
|
220
|
+
"""
|
|
221
|
+
async with self.session() as session:
|
|
222
|
+
query = select(Message).where(Message.message_id == message_id)
|
|
223
|
+
result = await session.execute(query)
|
|
224
|
+
return result.scalar_one_or_none()
|
|
225
|
+
|
|
226
|
+
async def update_message_patch(self, message_id: str, patch_content: str) -> None:
|
|
227
|
+
"""Add patch content to an existing message."""
|
|
228
|
+
async with self.session() as session:
|
|
229
|
+
query = select(Message).where(Message.message_id == message_id)
|
|
230
|
+
result = await session.execute(query)
|
|
231
|
+
message = result.scalar_one_or_none()
|
|
232
|
+
if message and not message.patch_content:
|
|
233
|
+
message.patch_content = patch_content
|
|
234
|
+
message.is_patch = True
|
|
235
|
+
thread = await session.get(Thread, message.thread_id)
|
|
236
|
+
if thread:
|
|
237
|
+
thread.is_patch = True
|
|
238
|
+
|
|
239
|
+
async def get_config(self, key: str, default: Any = None) -> Any:
|
|
240
|
+
"""Get a configuration value.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
key: Configuration key.
|
|
244
|
+
default: Default value if key not found.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Configuration value or default.
|
|
248
|
+
"""
|
|
249
|
+
async with self.session() as session:
|
|
250
|
+
query = select(Configuration).where(Configuration.key == key)
|
|
251
|
+
result = await session.execute(query)
|
|
252
|
+
config = result.scalar_one_or_none()
|
|
253
|
+
if config:
|
|
254
|
+
return json.loads(config.value)
|
|
255
|
+
return default
|
|
256
|
+
|
|
257
|
+
async def set_config(self, key: str, value: Any) -> None:
|
|
258
|
+
"""Set a configuration value.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
key: Configuration key.
|
|
262
|
+
value: Configuration value.
|
|
263
|
+
"""
|
|
264
|
+
async with self.session() as session:
|
|
265
|
+
query = select(Configuration).where(Configuration.key == key)
|
|
266
|
+
result = await session.execute(query)
|
|
267
|
+
config = result.scalar_one_or_none()
|
|
268
|
+
|
|
269
|
+
if config:
|
|
270
|
+
config.value = json.dumps(value)
|
|
271
|
+
else:
|
|
272
|
+
config = Configuration(key=key, value=json.dumps(value))
|
|
273
|
+
session.add(config)
|
|
274
|
+
|
|
275
|
+
async def get_email_sources(self) -> list[EmailSource]:
|
|
276
|
+
"""Get all email sources.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
List of email sources.
|
|
280
|
+
"""
|
|
281
|
+
async with self.session() as session:
|
|
282
|
+
query = select(EmailSource)
|
|
283
|
+
result = await session.execute(query)
|
|
284
|
+
return list(result.scalars().all())
|
|
285
|
+
|
|
286
|
+
async def update_thread_status(self, thread_id: int, status: str) -> None:
|
|
287
|
+
"""Update thread status.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
thread_id: Thread ID.
|
|
291
|
+
status: New status (open/closed).
|
|
292
|
+
"""
|
|
293
|
+
async with self.session() as session:
|
|
294
|
+
thread = await session.get(Thread, thread_id)
|
|
295
|
+
if thread:
|
|
296
|
+
thread.status = status
|
|
297
|
+
thread.updated_at = datetime.now(UTC)
|