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.
@@ -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
+ ```
@@ -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)
@@ -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,2 @@
1
+ [console_scripts]
2
+ daily-summary-agent = daily_summary_agent.cli:main
@@ -0,0 +1,8 @@
1
+ google-genai>=1.0.0
2
+ google-api-python-client>=2.100.0
3
+ google-auth-httplib2>=0.2.0
4
+ google-auth-oauthlib>=1.2.0
5
+ PyGithub>=2.1.0
6
+ jira>=3.6.0
7
+ APScheduler>=3.10.0
8
+ python-dotenv>=1.0.0
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+