daily-summary-agent 0.1.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.
- daily_summary_agent-0.1.0/PKG-INFO +13 -0
- daily_summary_agent-0.1.0/README.md +119 -0
- daily_summary_agent-0.1.0/daily_summary_agent/__init__.py +0 -0
- daily_summary_agent-0.1.0/daily_summary_agent/agent.py +79 -0
- daily_summary_agent-0.1.0/daily_summary_agent/cli.py +100 -0
- daily_summary_agent-0.1.0/daily_summary_agent/config.py +59 -0
- daily_summary_agent-0.1.0/daily_summary_agent/integrations/__init__.py +0 -0
- daily_summary_agent-0.1.0/daily_summary_agent/integrations/calendar.py +97 -0
- daily_summary_agent-0.1.0/daily_summary_agent/integrations/email_sender.py +65 -0
- daily_summary_agent-0.1.0/daily_summary_agent/integrations/github.py +73 -0
- daily_summary_agent-0.1.0/daily_summary_agent/integrations/jira_client.py +49 -0
- daily_summary_agent-0.1.0/daily_summary_agent/scheduler.py +125 -0
- daily_summary_agent-0.1.0/daily_summary_agent.egg-info/PKG-INFO +13 -0
- daily_summary_agent-0.1.0/daily_summary_agent.egg-info/SOURCES.txt +18 -0
- daily_summary_agent-0.1.0/daily_summary_agent.egg-info/dependency_links.txt +1 -0
- daily_summary_agent-0.1.0/daily_summary_agent.egg-info/entry_points.txt +2 -0
- daily_summary_agent-0.1.0/daily_summary_agent.egg-info/requires.txt +8 -0
- daily_summary_agent-0.1.0/daily_summary_agent.egg-info/top_level.txt +1 -0
- daily_summary_agent-0.1.0/pyproject.toml +26 -0
- daily_summary_agent-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daily-summary-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered standup email agent — summarizes your GitHub commits and Jira tickets before every meeting.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: google-genai>=1.0.0
|
|
7
|
+
Requires-Dist: google-api-python-client>=2.100.0
|
|
8
|
+
Requires-Dist: google-auth-httplib2>=0.2.0
|
|
9
|
+
Requires-Dist: google-auth-oauthlib>=1.2.0
|
|
10
|
+
Requires-Dist: PyGithub>=2.1.0
|
|
11
|
+
Requires-Dist: jira>=3.6.0
|
|
12
|
+
Requires-Dist: APScheduler>=3.10.0
|
|
13
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Daily Summary Agent
|
|
2
|
+
|
|
3
|
+
Automatically emails you a standup summary before every meeting — what you worked on (from GitHub commits), what's in progress (from Jira), and any blockers. Powered by Gemini.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
1. Runs in the background, checking your Google Calendar every 5 minutes
|
|
8
|
+
2. When a meeting is ~1 hour away, it fetches your recent GitHub commits and active Jira tickets
|
|
9
|
+
3. Generates a concise standup summary using Gemini
|
|
10
|
+
4. Emails it to you before the meeting starts
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
### 1. Prerequisites
|
|
17
|
+
|
|
18
|
+
- Python 3.11+
|
|
19
|
+
- A [Gemini API key](https://aistudio.google.com/app/apikey)
|
|
20
|
+
- A [GitHub Personal Access Token](https://github.com/settings/tokens) with `repo` scope
|
|
21
|
+
- A Google account with Calendar access
|
|
22
|
+
- A Gmail account with [App Passwords](https://myaccount.google.com/apppasswords) enabled (2FA required)
|
|
23
|
+
- _(Optional)_ A Jira account
|
|
24
|
+
|
|
25
|
+
### 2. Clone and install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/naga-a11y/daily-summary-agent.git
|
|
29
|
+
cd daily-summary-agent
|
|
30
|
+
|
|
31
|
+
python3 -m venv env
|
|
32
|
+
source env/bin/activate # Windows: env\Scripts\activate
|
|
33
|
+
|
|
34
|
+
pip install -r requirements.txt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 3. Configure environment
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cp .env.example .env
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open `.env` and fill in:
|
|
44
|
+
|
|
45
|
+
| Variable | Where to get it |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `GEMINI_API_KEY` | [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) |
|
|
48
|
+
| `GITHUB_TOKEN` | [github.com/settings/tokens](https://github.com/settings/tokens) → Generate new token (classic) → `repo` scope |
|
|
49
|
+
| `GITHUB_USERNAME` | Your GitHub handle (e.g. `john-doe`) |
|
|
50
|
+
| `SMTP_USERNAME` | Your Gmail address |
|
|
51
|
+
| `SMTP_PASSWORD` | Gmail App Password (not your main password) |
|
|
52
|
+
| `EMAIL_FROM` | Your Gmail address |
|
|
53
|
+
| `EMAIL_TO` | Where to send summaries (can be same as FROM) |
|
|
54
|
+
|
|
55
|
+
Jira variables are optional — leave them blank to skip ticket fetching.
|
|
56
|
+
|
|
57
|
+
### 4. Connect Google Calendar
|
|
58
|
+
|
|
59
|
+
Download OAuth credentials from Google Cloud Console:
|
|
60
|
+
|
|
61
|
+
1. Go to [console.cloud.google.com](https://console.cloud.google.com)
|
|
62
|
+
2. Create a project → **APIs & Services → Enable APIs** → enable **Google Calendar API**
|
|
63
|
+
3. **APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID**
|
|
64
|
+
4. Choose **Desktop App**, download the JSON file
|
|
65
|
+
5. Save it as `credentials.json` in the project root
|
|
66
|
+
|
|
67
|
+
### 5. First run (browser auth)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python3 main.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
On first run, a browser window will open asking you to grant Google Calendar access. After you approve, `token.json` is saved automatically and all future runs are silent.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Running
|
|
78
|
+
|
|
79
|
+
### Directly
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
source env/bin/activate
|
|
83
|
+
python3 main.py
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### With Docker
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Run once locally first to generate token.json via browser auth (step 5 above)
|
|
90
|
+
# Then:
|
|
91
|
+
docker compose up -d
|
|
92
|
+
docker compose logs -f
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
| Variable | Default | Description |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `MEETING_ALERT_MINUTES` | `60` | Send email this many minutes before the meeting |
|
|
102
|
+
| `CHECK_INTERVAL_MINUTES` | `5` | How often to poll Google Calendar |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Project structure
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
main.py # Entry point
|
|
110
|
+
config.py # Environment variable loading & validation
|
|
111
|
+
src/
|
|
112
|
+
agent.py # Gemini summarization
|
|
113
|
+
scheduler.py # APScheduler polling loop
|
|
114
|
+
integrations/
|
|
115
|
+
calendar.py # Google Calendar
|
|
116
|
+
github.py # GitHub commits
|
|
117
|
+
jira_client.py # Jira tickets
|
|
118
|
+
email_sender.py # SMTP email
|
|
119
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from google import genai
|
|
4
|
+
|
|
5
|
+
from . import config
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_client = genai.Client(api_key=config.GEMINI_API_KEY)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def generate_standup_summary(
|
|
13
|
+
meeting_name: str,
|
|
14
|
+
meeting_time: str,
|
|
15
|
+
commits: list[dict],
|
|
16
|
+
tickets: list[dict],
|
|
17
|
+
) -> tuple[str, str]:
|
|
18
|
+
"""
|
|
19
|
+
Generate a standup-ready summary using Gemini.
|
|
20
|
+
Returns (html_content, plain_text_content).
|
|
21
|
+
"""
|
|
22
|
+
commits_block = (
|
|
23
|
+
"\n".join(
|
|
24
|
+
f" - [{c['sha']}] {c['repo']}: {c['message']}"
|
|
25
|
+
for c in commits
|
|
26
|
+
)
|
|
27
|
+
if commits
|
|
28
|
+
else " (no commits found since yesterday)"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
tickets_block = (
|
|
32
|
+
"\n".join(
|
|
33
|
+
f" - {t['key']} [{t['status']}] ({t['priority']}): {t['summary']}"
|
|
34
|
+
for t in tickets
|
|
35
|
+
)
|
|
36
|
+
if tickets
|
|
37
|
+
else " (no active Jira tickets found)"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
prompt = f"""You are helping a software engineer prepare for their standup meeting.
|
|
41
|
+
|
|
42
|
+
Meeting: {meeting_name}
|
|
43
|
+
Starting at: {meeting_time}
|
|
44
|
+
|
|
45
|
+
--- GitHub commits (since yesterday midnight) ---
|
|
46
|
+
{commits_block}
|
|
47
|
+
|
|
48
|
+
--- Active Jira tickets ---
|
|
49
|
+
{tickets_block}
|
|
50
|
+
|
|
51
|
+
Write a concise standup summary the engineer can read aloud or copy-paste into Slack.
|
|
52
|
+
Structure it with exactly three sections:
|
|
53
|
+
1. **What I did** — summarize the commits in plain language, grouped by theme if possible
|
|
54
|
+
2. **What I'm working on next** — based on in-progress Jira tickets
|
|
55
|
+
3. **Blockers** — flag anything that looks stuck or needs attention; write "None" if there are no obvious blockers
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
- Keep each section to 2-4 bullet points max
|
|
59
|
+
- Use past tense for "What I did", present/future tense for the rest
|
|
60
|
+
- Do not include ticket keys or commit SHAs in the spoken summary — keep it human
|
|
61
|
+
|
|
62
|
+
Respond with two versions separated by the exact delimiter "---PLAINTEXT---":
|
|
63
|
+
1. First: an HTML version using only <h3>, <ul>, <li>, <strong>, <a> tags (no <html>/<body> wrapper)
|
|
64
|
+
2. Second: a plain-text version
|
|
65
|
+
|
|
66
|
+
HTML version first, then the delimiter, then plain text."""
|
|
67
|
+
|
|
68
|
+
response = _client.models.generate_content(
|
|
69
|
+
model="gemini-2.5-pro",
|
|
70
|
+
contents=prompt,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
full_text = response.text or ""
|
|
74
|
+
|
|
75
|
+
if "---PLAINTEXT---" in full_text:
|
|
76
|
+
html_part, plain_part = full_text.split("---PLAINTEXT---", 1)
|
|
77
|
+
return html_part.strip(), plain_part.strip()
|
|
78
|
+
|
|
79
|
+
return full_text.strip(), ""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".daily-summary-agent"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.env"
|
|
10
|
+
CREDS_FILE = CONFIG_DIR / "credentials.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _write_config(values: dict) -> None:
|
|
14
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
lines = [f"{k}={v}" for k, v in values.items() if v]
|
|
16
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
|
17
|
+
CONFIG_FILE.chmod(0o600)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def setup() -> None:
|
|
21
|
+
print("Daily Summary Agent — Setup\n")
|
|
22
|
+
|
|
23
|
+
values = {}
|
|
24
|
+
|
|
25
|
+
# Gemini
|
|
26
|
+
print("1. Gemini API key")
|
|
27
|
+
print(" Get it at: https://aistudio.google.com/app/apikey")
|
|
28
|
+
values["GEMINI_API_KEY"] = getpass.getpass(" API key: ").strip()
|
|
29
|
+
|
|
30
|
+
# GitHub
|
|
31
|
+
print("\n2. GitHub")
|
|
32
|
+
print(" Get a token at: https://github.com/settings/tokens → repo scope")
|
|
33
|
+
values["GITHUB_TOKEN"] = getpass.getpass(" Personal Access Token: ").strip()
|
|
34
|
+
values["GITHUB_USERNAME"] = input(" Username: ").strip()
|
|
35
|
+
|
|
36
|
+
# Email
|
|
37
|
+
print("\n3. Gmail")
|
|
38
|
+
print(" Generate an App Password at: https://myaccount.google.com/apppasswords")
|
|
39
|
+
email = input(" Gmail address: ").strip()
|
|
40
|
+
values["SMTP_USERNAME"] = email
|
|
41
|
+
values["EMAIL_FROM"] = email
|
|
42
|
+
values["EMAIL_TO"] = email
|
|
43
|
+
values["SMTP_PASSWORD"] = getpass.getpass(" App Password: ").strip()
|
|
44
|
+
|
|
45
|
+
# Jira (optional)
|
|
46
|
+
print("\n4. Jira (optional — press Enter to skip)")
|
|
47
|
+
jira_server = input(" Server URL (e.g. https://company.atlassian.net): ").strip()
|
|
48
|
+
if jira_server:
|
|
49
|
+
values["JIRA_SERVER"] = jira_server
|
|
50
|
+
values["JIRA_EMAIL"] = input(" Email: ").strip()
|
|
51
|
+
values["JIRA_API_TOKEN"] = getpass.getpass(" API token: ").strip()
|
|
52
|
+
values["JIRA_USERNAME"] = input(" Username: ").strip()
|
|
53
|
+
|
|
54
|
+
# Google Calendar credentials.json
|
|
55
|
+
print("\n5. Google Calendar credentials")
|
|
56
|
+
print(" Download from: https://console.cloud.google.com")
|
|
57
|
+
print(" APIs & Services → Credentials → OAuth 2.0 Client ID → Desktop App → Download JSON")
|
|
58
|
+
creds_path = input(" Path to downloaded credentials.json: ").strip()
|
|
59
|
+
creds_path = Path(creds_path).expanduser()
|
|
60
|
+
if creds_path.exists():
|
|
61
|
+
shutil.copy(creds_path, CREDS_FILE)
|
|
62
|
+
values["GOOGLE_CREDENTIALS_FILE"] = str(CREDS_FILE)
|
|
63
|
+
values["GOOGLE_TOKEN_FILE"] = str(CONFIG_DIR / "token.json")
|
|
64
|
+
print(f" Copied to {CREDS_FILE}")
|
|
65
|
+
else:
|
|
66
|
+
print(f" File not found — copy it manually to {CREDS_FILE} before starting.")
|
|
67
|
+
|
|
68
|
+
_write_config(values)
|
|
69
|
+
print(f"\nConfig saved to {CONFIG_FILE}")
|
|
70
|
+
print("\nRun 'daily-summary-agent start' to launch.")
|
|
71
|
+
print("A browser window will open on first run for Google Calendar access.\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def start() -> None:
|
|
75
|
+
logging.basicConfig(
|
|
76
|
+
level=logging.INFO,
|
|
77
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
78
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
79
|
+
handlers=[logging.StreamHandler(sys.stdout)],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not CONFIG_FILE.exists():
|
|
83
|
+
print("No configuration found. Run 'daily-summary-agent setup' first.")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
from daily_summary_agent.scheduler import run
|
|
87
|
+
run()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> None:
|
|
91
|
+
command = sys.argv[1] if len(sys.argv) > 1 else None
|
|
92
|
+
|
|
93
|
+
if command == "setup":
|
|
94
|
+
setup()
|
|
95
|
+
elif command == "start":
|
|
96
|
+
start()
|
|
97
|
+
else:
|
|
98
|
+
print("Usage:")
|
|
99
|
+
print(" daily-summary-agent setup — first-time configuration wizard")
|
|
100
|
+
print(" daily-summary-agent start — start the agent")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".daily-summary-agent"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.env"
|
|
9
|
+
|
|
10
|
+
# Load from ~/.daily-summary-agent/config.env (installed) or .env (local dev)
|
|
11
|
+
load_dotenv(CONFIG_FILE)
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
# Google Calendar
|
|
15
|
+
GOOGLE_CREDENTIALS_FILE = os.getenv("GOOGLE_CREDENTIALS_FILE", str(CONFIG_DIR / "credentials.json"))
|
|
16
|
+
GOOGLE_TOKEN_FILE = os.getenv("GOOGLE_TOKEN_FILE", str(CONFIG_DIR / "token.json"))
|
|
17
|
+
|
|
18
|
+
# GitHub
|
|
19
|
+
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
|
20
|
+
GITHUB_USERNAME = os.getenv("GITHUB_USERNAME")
|
|
21
|
+
|
|
22
|
+
# Jira (optional)
|
|
23
|
+
JIRA_SERVER = os.getenv("JIRA_SERVER")
|
|
24
|
+
JIRA_EMAIL = os.getenv("JIRA_EMAIL")
|
|
25
|
+
JIRA_API_TOKEN = os.getenv("JIRA_API_TOKEN")
|
|
26
|
+
JIRA_USERNAME = os.getenv("JIRA_USERNAME")
|
|
27
|
+
|
|
28
|
+
# Email
|
|
29
|
+
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
|
30
|
+
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
31
|
+
SMTP_USERNAME = os.getenv("SMTP_USERNAME")
|
|
32
|
+
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
|
|
33
|
+
EMAIL_FROM = os.getenv("EMAIL_FROM")
|
|
34
|
+
EMAIL_TO = os.getenv("EMAIL_TO")
|
|
35
|
+
|
|
36
|
+
# Gemini
|
|
37
|
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
38
|
+
|
|
39
|
+
# Scheduler
|
|
40
|
+
CHECK_INTERVAL_MINUTES = int(os.getenv("CHECK_INTERVAL_MINUTES", "5"))
|
|
41
|
+
MEETING_ALERT_MINUTES = int(os.getenv("MEETING_ALERT_MINUTES", "60"))
|
|
42
|
+
|
|
43
|
+
_REQUIRED = {
|
|
44
|
+
"GEMINI_API_KEY": GEMINI_API_KEY,
|
|
45
|
+
"GITHUB_TOKEN": GITHUB_TOKEN,
|
|
46
|
+
"GITHUB_USERNAME": GITHUB_USERNAME,
|
|
47
|
+
"SMTP_USERNAME": SMTP_USERNAME,
|
|
48
|
+
"SMTP_PASSWORD": SMTP_PASSWORD,
|
|
49
|
+
"EMAIL_FROM": EMAIL_FROM,
|
|
50
|
+
"EMAIL_TO": EMAIL_TO,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate() -> None:
|
|
55
|
+
missing = [k for k, v in _REQUIRED.items() if not v]
|
|
56
|
+
if missing:
|
|
57
|
+
print(f"ERROR: Missing required config: {', '.join(missing)}", file=sys.stderr)
|
|
58
|
+
print(f"Run 'daily-summary-agent setup' to configure.", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from google.auth.transport.requests import Request
|
|
6
|
+
from google.oauth2.credentials import Credentials
|
|
7
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
8
|
+
from googleapiclient.discovery import build
|
|
9
|
+
|
|
10
|
+
from .. import config
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_credentials() -> Credentials:
|
|
18
|
+
creds = None
|
|
19
|
+
|
|
20
|
+
if os.path.exists(config.GOOGLE_TOKEN_FILE):
|
|
21
|
+
creds = Credentials.from_authorized_user_file(config.GOOGLE_TOKEN_FILE, SCOPES)
|
|
22
|
+
|
|
23
|
+
if not creds or not creds.valid:
|
|
24
|
+
if creds and creds.expired and creds.refresh_token:
|
|
25
|
+
creds.refresh(Request())
|
|
26
|
+
else:
|
|
27
|
+
if not os.path.exists(config.GOOGLE_CREDENTIALS_FILE):
|
|
28
|
+
raise FileNotFoundError(
|
|
29
|
+
f"Google credentials file not found: {config.GOOGLE_CREDENTIALS_FILE}\n"
|
|
30
|
+
"Run 'daily-summary-agent setup' to configure."
|
|
31
|
+
)
|
|
32
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
33
|
+
config.GOOGLE_CREDENTIALS_FILE, SCOPES
|
|
34
|
+
)
|
|
35
|
+
creds = flow.run_local_server(port=0)
|
|
36
|
+
|
|
37
|
+
os.makedirs(os.path.dirname(config.GOOGLE_TOKEN_FILE), exist_ok=True)
|
|
38
|
+
with open(config.GOOGLE_TOKEN_FILE, "w") as f:
|
|
39
|
+
f.write(creds.to_json())
|
|
40
|
+
logger.info("Google Calendar token saved to %s", config.GOOGLE_TOKEN_FILE)
|
|
41
|
+
|
|
42
|
+
return creds
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_todays_meetings() -> list[dict]:
|
|
46
|
+
"""Return all meetings scheduled for today (local time)."""
|
|
47
|
+
service = build("calendar", "v3", credentials=_get_credentials())
|
|
48
|
+
|
|
49
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
50
|
+
local_now = now.astimezone()
|
|
51
|
+
start_of_day = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
52
|
+
end_of_day = local_now.replace(hour=23, minute=59, second=59, microsecond=0)
|
|
53
|
+
|
|
54
|
+
result = (
|
|
55
|
+
service.events()
|
|
56
|
+
.list(
|
|
57
|
+
calendarId="primary",
|
|
58
|
+
timeMin=start_of_day.isoformat(),
|
|
59
|
+
timeMax=end_of_day.isoformat(),
|
|
60
|
+
singleEvents=True,
|
|
61
|
+
orderBy="startTime",
|
|
62
|
+
)
|
|
63
|
+
.execute()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
events = result.get("items", [])
|
|
67
|
+
logger.info(
|
|
68
|
+
"Today's meetings (%d): %s",
|
|
69
|
+
len(events),
|
|
70
|
+
", ".join(e.get("summary", "(no title)") for e in events) or "none",
|
|
71
|
+
)
|
|
72
|
+
return events
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_upcoming_meetings(window_start_minutes: int, window_end_minutes: int) -> list[dict]:
|
|
76
|
+
"""Return meetings whose start time falls between window_start and window_end minutes from now."""
|
|
77
|
+
service = build("calendar", "v3", credentials=_get_credentials())
|
|
78
|
+
|
|
79
|
+
now = datetime.datetime.utcnow()
|
|
80
|
+
time_min = (now + datetime.timedelta(minutes=window_start_minutes)).isoformat() + "Z"
|
|
81
|
+
time_max = (now + datetime.timedelta(minutes=window_end_minutes)).isoformat() + "Z"
|
|
82
|
+
|
|
83
|
+
result = (
|
|
84
|
+
service.events()
|
|
85
|
+
.list(
|
|
86
|
+
calendarId="primary",
|
|
87
|
+
timeMin=time_min,
|
|
88
|
+
timeMax=time_max,
|
|
89
|
+
singleEvents=True,
|
|
90
|
+
orderBy="startTime",
|
|
91
|
+
)
|
|
92
|
+
.execute()
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
events = result.get("items", [])
|
|
96
|
+
logger.info("Found %d meeting(s) in the alert window.", len(events))
|
|
97
|
+
return events
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import smtplib
|
|
3
|
+
from email.mime.multipart import MIMEMultipart
|
|
4
|
+
from email.mime.text import MIMEText
|
|
5
|
+
|
|
6
|
+
from .. import config
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_EMAIL_TEMPLATE = """<!DOCTYPE html>
|
|
11
|
+
<html>
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="UTF-8">
|
|
14
|
+
<style>
|
|
15
|
+
body {{
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
17
|
+
max-width: 640px;
|
|
18
|
+
margin: 0 auto;
|
|
19
|
+
padding: 24px;
|
|
20
|
+
color: #24292f;
|
|
21
|
+
background: #ffffff;
|
|
22
|
+
}}
|
|
23
|
+
h2 {{ color: #1a1a2e; border-bottom: 2px solid #e6edf3; padding-bottom: 10px; }}
|
|
24
|
+
h3 {{ color: #0969da; margin-top: 24px; }}
|
|
25
|
+
ul {{ padding-left: 20px; line-height: 1.7; }}
|
|
26
|
+
li {{ margin: 4px 0; }}
|
|
27
|
+
a {{ color: #0969da; text-decoration: none; }}
|
|
28
|
+
a:hover {{ text-decoration: underline; }}
|
|
29
|
+
.footer {{
|
|
30
|
+
margin-top: 36px;
|
|
31
|
+
padding-top: 16px;
|
|
32
|
+
border-top: 1px solid #e6edf3;
|
|
33
|
+
color: #8b949e;
|
|
34
|
+
font-size: 12px;
|
|
35
|
+
}}
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
{content}
|
|
40
|
+
<div class="footer">
|
|
41
|
+
Generated by <a href="https://github.com/naga-a11y/daily-summary-agent">Daily Summary Agent</a>
|
|
42
|
+
</div>
|
|
43
|
+
</body>
|
|
44
|
+
</html>"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def send_email(subject: str, html_content: str, plain_content: str = "") -> None:
|
|
48
|
+
msg = MIMEMultipart("alternative")
|
|
49
|
+
msg["Subject"] = subject
|
|
50
|
+
msg["From"] = config.EMAIL_FROM
|
|
51
|
+
msg["To"] = config.EMAIL_TO
|
|
52
|
+
|
|
53
|
+
html_body = _EMAIL_TEMPLATE.format(content=html_content)
|
|
54
|
+
|
|
55
|
+
if plain_content:
|
|
56
|
+
msg.attach(MIMEText(plain_content, "plain", "utf-8"))
|
|
57
|
+
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
|
58
|
+
|
|
59
|
+
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as server:
|
|
60
|
+
server.ehlo()
|
|
61
|
+
server.starttls()
|
|
62
|
+
server.login(config.SMTP_USERNAME, config.SMTP_PASSWORD)
|
|
63
|
+
server.sendmail(config.EMAIL_FROM, [config.EMAIL_TO], msg.as_string())
|
|
64
|
+
|
|
65
|
+
logger.info("Email sent to %s: %s", config.EMAIL_TO, subject)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import itertools
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import github
|
|
6
|
+
from github import Github, GithubException
|
|
7
|
+
|
|
8
|
+
from .. import config
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_recent_commits() -> list[dict]:
|
|
14
|
+
"""
|
|
15
|
+
Return commits pushed by the configured user from yesterday midnight (local time) until now.
|
|
16
|
+
Checks all branches so feature-branch / PR commits are included.
|
|
17
|
+
"""
|
|
18
|
+
if not config.GITHUB_TOKEN or not config.GITHUB_USERNAME:
|
|
19
|
+
logger.warning("GitHub credentials not configured — skipping commit fetch.")
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
g = Github(auth=github.Auth.Token(config.GITHUB_TOKEN))
|
|
23
|
+
|
|
24
|
+
local_now = datetime.datetime.now().astimezone()
|
|
25
|
+
yesterday_midnight = (local_now - datetime.timedelta(days=1)).replace(
|
|
26
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
27
|
+
)
|
|
28
|
+
since = yesterday_midnight.astimezone(datetime.timezone.utc)
|
|
29
|
+
|
|
30
|
+
pushed_repos = set()
|
|
31
|
+
try:
|
|
32
|
+
user = g.get_user(config.GITHUB_USERNAME)
|
|
33
|
+
for event in user.get_events():
|
|
34
|
+
event_time = event.created_at.replace(tzinfo=datetime.timezone.utc)
|
|
35
|
+
if event_time < since:
|
|
36
|
+
break
|
|
37
|
+
if event.type == "PushEvent":
|
|
38
|
+
pushed_repos.add(event.repo.name)
|
|
39
|
+
except GithubException as e:
|
|
40
|
+
logger.error("GitHub Events API error: %s", e)
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
seen_shas = set()
|
|
44
|
+
commits = []
|
|
45
|
+
for repo_name in pushed_repos:
|
|
46
|
+
try:
|
|
47
|
+
repo = g.get_repo(repo_name)
|
|
48
|
+
for branch in repo.get_branches():
|
|
49
|
+
for commit in itertools.islice(
|
|
50
|
+
repo.get_commits(sha=branch.name, author=config.GITHUB_USERNAME, since=since),
|
|
51
|
+
50,
|
|
52
|
+
):
|
|
53
|
+
if commit.sha in seen_shas:
|
|
54
|
+
continue
|
|
55
|
+
seen_shas.add(commit.sha)
|
|
56
|
+
commits.append(
|
|
57
|
+
{
|
|
58
|
+
"repo": repo_name,
|
|
59
|
+
"sha": commit.sha[:7],
|
|
60
|
+
"message": commit.commit.message.split("\n")[0],
|
|
61
|
+
"url": commit.html_url,
|
|
62
|
+
"timestamp": commit.commit.author.date.isoformat(),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
except GithubException as e:
|
|
66
|
+
logger.warning("Could not fetch commits for %s: %s", repo_name, e)
|
|
67
|
+
|
|
68
|
+
logger.info(
|
|
69
|
+
"Fetched %d commit(s) from GitHub (since %s).",
|
|
70
|
+
len(commits),
|
|
71
|
+
yesterday_midnight.strftime("%Y-%m-%d %H:%M %Z"),
|
|
72
|
+
)
|
|
73
|
+
return commits
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from jira import JIRA, JIRAError
|
|
4
|
+
|
|
5
|
+
from .. import config
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_client() -> JIRA:
|
|
11
|
+
return JIRA(
|
|
12
|
+
server=config.JIRA_SERVER,
|
|
13
|
+
basic_auth=(config.JIRA_EMAIL, config.JIRA_API_TOKEN),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_active_tickets() -> list[dict]:
|
|
18
|
+
"""Return open tickets assigned to the configured user, ordered by last update."""
|
|
19
|
+
if not config.JIRA_SERVER or not config.JIRA_EMAIL or not config.JIRA_API_TOKEN:
|
|
20
|
+
logger.warning("Jira credentials not configured — skipping ticket fetch.")
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
client = _get_client()
|
|
25
|
+
jql = (
|
|
26
|
+
f'assignee = "{config.JIRA_USERNAME}" '
|
|
27
|
+
f'AND status NOT IN (Done, Closed, Resolved, Cancelled) '
|
|
28
|
+
f'ORDER BY updated DESC'
|
|
29
|
+
)
|
|
30
|
+
issues = client.search_issues(jql, maxResults=25)
|
|
31
|
+
|
|
32
|
+
tickets = []
|
|
33
|
+
for issue in issues:
|
|
34
|
+
tickets.append(
|
|
35
|
+
{
|
|
36
|
+
"key": issue.key,
|
|
37
|
+
"summary": issue.fields.summary,
|
|
38
|
+
"status": issue.fields.status.name,
|
|
39
|
+
"priority": getattr(issue.fields.priority, "name", "—"),
|
|
40
|
+
"url": f"{config.JIRA_SERVER}/browse/{issue.key}",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger.info("Fetched %d Jira ticket(s).", len(tickets))
|
|
45
|
+
return tickets
|
|
46
|
+
|
|
47
|
+
except JIRAError as e:
|
|
48
|
+
logger.error("Jira API error: %s", e.text)
|
|
49
|
+
return []
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from apscheduler.schedulers.blocking import BlockingScheduler
|
|
9
|
+
|
|
10
|
+
from . import config
|
|
11
|
+
from .agent import generate_standup_summary
|
|
12
|
+
from .integrations.calendar import get_todays_meetings, get_upcoming_meetings
|
|
13
|
+
from .integrations.email_sender import send_email
|
|
14
|
+
from .integrations.github import get_recent_commits
|
|
15
|
+
from .integrations.jira_client import get_active_tickets
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_SENT_FILE = os.path.join(config.CONFIG_DIR, "sent_meetings.json")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_sent() -> dict:
|
|
23
|
+
if os.path.exists(_SENT_FILE):
|
|
24
|
+
try:
|
|
25
|
+
with open(_SENT_FILE, encoding="utf-8") as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
logger.warning("sent_meetings.json is corrupt — resetting.")
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_sent(sent: dict) -> None:
|
|
33
|
+
os.makedirs(config.CONFIG_DIR, exist_ok=True)
|
|
34
|
+
with open(_SENT_FILE, "w", encoding="utf-8") as f:
|
|
35
|
+
json.dump(sent, f, indent=2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _meeting_display_time(event: dict) -> str:
|
|
39
|
+
start = event.get("start", {})
|
|
40
|
+
dt_str = start.get("dateTime") or start.get("date", "")
|
|
41
|
+
try:
|
|
42
|
+
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
|
43
|
+
return dt.astimezone().strftime("%I:%M %p")
|
|
44
|
+
except ValueError:
|
|
45
|
+
return dt_str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_and_send() -> None:
|
|
49
|
+
alert = config.MEETING_ALERT_MINUTES
|
|
50
|
+
slack = config.CHECK_INTERVAL_MINUTES // 2 + 1
|
|
51
|
+
events = get_upcoming_meetings(
|
|
52
|
+
window_start_minutes=alert - slack,
|
|
53
|
+
window_end_minutes=alert + slack,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not events:
|
|
57
|
+
logger.debug("No meetings in alert window.")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
sent = _load_sent()
|
|
61
|
+
commits = None
|
|
62
|
+
tickets = None
|
|
63
|
+
|
|
64
|
+
for event in events:
|
|
65
|
+
meeting_id = event.get("id")
|
|
66
|
+
if not meeting_id or meeting_id in sent:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
meeting_name = event.get("summary", "Standup")
|
|
70
|
+
logger.info("Preparing summary for: %s", meeting_name)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
if commits is None:
|
|
74
|
+
commits = get_recent_commits()
|
|
75
|
+
if tickets is None:
|
|
76
|
+
tickets = get_active_tickets()
|
|
77
|
+
|
|
78
|
+
meeting_time = _meeting_display_time(event)
|
|
79
|
+
html_content, plain_content = generate_standup_summary(
|
|
80
|
+
meeting_name=meeting_name,
|
|
81
|
+
meeting_time=meeting_time,
|
|
82
|
+
commits=commits,
|
|
83
|
+
tickets=tickets,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
subject = f"Standup prep: {meeting_name} at {meeting_time}"
|
|
87
|
+
send_email(subject=subject, html_content=html_content, plain_content=plain_content)
|
|
88
|
+
|
|
89
|
+
sent[meeting_id] = datetime.now(timezone.utc).isoformat()
|
|
90
|
+
_save_sent(sent)
|
|
91
|
+
logger.info("Summary sent for meeting: %s", meeting_name)
|
|
92
|
+
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("Failed to send summary for %s — will retry next cycle.", meeting_name)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run() -> None:
|
|
98
|
+
config.validate()
|
|
99
|
+
|
|
100
|
+
logger.info(
|
|
101
|
+
"Scheduler starting — checking every %d min, alerting %d min before meetings.",
|
|
102
|
+
config.CHECK_INTERVAL_MINUTES,
|
|
103
|
+
config.MEETING_ALERT_MINUTES,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
scheduler = BlockingScheduler(timezone="UTC")
|
|
107
|
+
|
|
108
|
+
def _shutdown(signum, _frame):
|
|
109
|
+
logger.info("Received signal %d — shutting down.", signum)
|
|
110
|
+
scheduler.shutdown(wait=False)
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
|
|
113
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
114
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
115
|
+
|
|
116
|
+
get_todays_meetings()
|
|
117
|
+
check_and_send()
|
|
118
|
+
|
|
119
|
+
scheduler.add_job(
|
|
120
|
+
check_and_send,
|
|
121
|
+
"interval",
|
|
122
|
+
minutes=config.CHECK_INTERVAL_MINUTES,
|
|
123
|
+
id="meeting_check",
|
|
124
|
+
)
|
|
125
|
+
scheduler.start()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daily-summary-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered standup email agent — summarizes your GitHub commits and Jira tickets before every meeting.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: google-genai>=1.0.0
|
|
7
|
+
Requires-Dist: google-api-python-client>=2.100.0
|
|
8
|
+
Requires-Dist: google-auth-httplib2>=0.2.0
|
|
9
|
+
Requires-Dist: google-auth-oauthlib>=1.2.0
|
|
10
|
+
Requires-Dist: PyGithub>=2.1.0
|
|
11
|
+
Requires-Dist: jira>=3.6.0
|
|
12
|
+
Requires-Dist: APScheduler>=3.10.0
|
|
13
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
daily_summary_agent/__init__.py
|
|
4
|
+
daily_summary_agent/agent.py
|
|
5
|
+
daily_summary_agent/cli.py
|
|
6
|
+
daily_summary_agent/config.py
|
|
7
|
+
daily_summary_agent/scheduler.py
|
|
8
|
+
daily_summary_agent.egg-info/PKG-INFO
|
|
9
|
+
daily_summary_agent.egg-info/SOURCES.txt
|
|
10
|
+
daily_summary_agent.egg-info/dependency_links.txt
|
|
11
|
+
daily_summary_agent.egg-info/entry_points.txt
|
|
12
|
+
daily_summary_agent.egg-info/requires.txt
|
|
13
|
+
daily_summary_agent.egg-info/top_level.txt
|
|
14
|
+
daily_summary_agent/integrations/__init__.py
|
|
15
|
+
daily_summary_agent/integrations/calendar.py
|
|
16
|
+
daily_summary_agent/integrations/email_sender.py
|
|
17
|
+
daily_summary_agent/integrations/github.py
|
|
18
|
+
daily_summary_agent/integrations/jira_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
daily_summary_agent
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "daily-summary-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AI-powered standup email agent — summarizes your GitHub commits and Jira tickets before every meeting."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"google-genai>=1.0.0",
|
|
12
|
+
"google-api-python-client>=2.100.0",
|
|
13
|
+
"google-auth-httplib2>=0.2.0",
|
|
14
|
+
"google-auth-oauthlib>=1.2.0",
|
|
15
|
+
"PyGithub>=2.1.0",
|
|
16
|
+
"jira>=3.6.0",
|
|
17
|
+
"APScheduler>=3.10.0",
|
|
18
|
+
"python-dotenv>=1.0.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
daily-summary-agent = "daily_summary_agent.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["daily_summary_agent*"]
|