standupguy 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.
standupguy/__init__.py ADDED
File without changes
standupguy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
standupguy/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """StandupGuy: kill the tracker trap without leaking data.
2
+
3
+ Reads local git history and drafts your standup notes, weekly summary,
4
+ and sprint-review bullets as Markdown. Deterministic digest works fully
5
+ offline; --draft uses YOUR local model endpoint (Ollama/freellmapi).
6
+ Your history never touches a cloud SaaS.
7
+
8
+ standupguy # yesterday's digest
9
+ standupguy --week # last 7 days
10
+ standupguy --draft # LLM prose: Yesterday / Today / Blockers
11
+ standupguy --sprint # sprint-review bullets
12
+ standupguy mcp # MCP server: ask "what did I ship this week"
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ from . import digest as digest_mod
23
+ from . import draft as draft_mod
24
+ from . import gitlog as gitlog_mod
25
+ from . import mcp as mcp_mod
26
+ from . import push as push_mod
27
+ from . import serve as serve_mod
28
+
29
+ VERSION = "0.1.0"
30
+
31
+
32
+ def _resolve_author(args, root: str) -> str | None:
33
+ if args.all:
34
+ return None
35
+ if args.author:
36
+ return args.author
37
+ return gitlog_mod.default_author(root) or None
38
+
39
+
40
+ def cmd_digest(args) -> int:
41
+ root = args.root
42
+ author = _resolve_author(args, root)
43
+ if args.week or args.sprint:
44
+ md = digest_mod.weekly_markdown(root, author)
45
+ label = "week"
46
+ else:
47
+ since = args.since or "yesterday"
48
+ label = f"since {since}"
49
+ md = digest_mod.digest_markdown(root, since, label, author)
50
+ if args.draft:
51
+ try:
52
+ md = draft_mod.draft(
53
+ md, base_url=args.base_url, model=args.model) + "\n"
54
+ except draft_mod.DraftError as e:
55
+ print(f"error: {e}", file=sys.stderr)
56
+ return 2
57
+ if args.format == "json":
58
+ commits = gitlog_mod.commits_since(
59
+ root, "7 days ago" if (args.week or args.sprint) else
60
+ (args.since or "yesterday"), author)
61
+ print(json.dumps({
62
+ "repo": gitlog_mod.repo_name(root), "label": label,
63
+ "in_progress": gitlog_mod.uncommitted(root),
64
+ "commits": [{"sha": c.sha, "author": c.author, "date": c.date,
65
+ "subject": c.subject, "files": c.files,
66
+ "pr": c.pr_number} for c in commits],
67
+ }, indent=2))
68
+ return 0
69
+ if args.push_slack:
70
+ webhook = args.push_slack if args.push_slack is not True else \
71
+ os.environ.get("AUTOSTANDUP_SLACK_WEBHOOK", "")
72
+ if not webhook:
73
+ print("error: no webhook — pass --push-slack <url> or set "
74
+ "AUTOSTANDUP_SLACK_WEBHOOK", file=sys.stderr)
75
+ return 2
76
+ try:
77
+ push_mod.push_slack(webhook, md)
78
+ print("pushed to Slack ✓")
79
+ except push_mod.PushError as e:
80
+ print(f"error: {e}", file=sys.stderr)
81
+ return 2
82
+ return 0
83
+ sys.stdout.write(md)
84
+ return 0
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ p = argparse.ArgumentParser(prog="standupguy", description=__doc__)
89
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
90
+ p.add_argument("--root", default=os.getcwd(), help="Repo path.")
91
+ p.add_argument("--author", default=None, help="Filter by author.")
92
+ p.add_argument("--all", action="store_true",
93
+ help="Include all authors (default: your git user).")
94
+ p.add_argument("--since", default=None,
95
+ help="git date: 'yesterday' (default), '2026-10-01', …")
96
+ p.add_argument("--week", action="store_true", help="Last 7 days.")
97
+ p.add_argument("--sprint", action="store_true",
98
+ help="Sprint-review bullets (weekly + highlights).")
99
+ p.add_argument("--draft", action="store_true",
100
+ help="Draft Yesterday/Today/Blockers prose via local model.")
101
+ p.add_argument("--format", choices=["markdown", "json"],
102
+ default="markdown")
103
+ p.add_argument("--push-slack", nargs="?", const=True, default=None,
104
+ metavar="WEBHOOK_URL",
105
+ help="POST the notes to a Slack webhook "
106
+ "(or AUTOSTANDUP_SLACK_WEBHOOK).")
107
+ p.add_argument("--base-url", default=None)
108
+ p.add_argument("--model", default=None)
109
+ p.add_argument("cmd", nargs="?", choices=["mcp", "serve"],
110
+ help="Run the MCP server (mcp) or HTTP bridge (serve) "
111
+ "for n8n / workflow tools.")
112
+ p.add_argument("--port", type=int, default=8765,
113
+ help="Port for `serve` (default 8765).")
114
+ p.add_argument("--host", default="127.0.0.1",
115
+ help="Bind host for `serve` (default 127.0.0.1).")
116
+ return p
117
+
118
+
119
+ def main(argv=None) -> int:
120
+ args = build_parser().parse_args(argv)
121
+ if args.cmd == "mcp":
122
+ return mcp_mod.serve(args.root)
123
+ if args.cmd == "serve":
124
+ return serve_mod.serve(args.root, args.host, args.port)
125
+ try:
126
+ gitlog_mod._git(args.root, "rev-parse", "--git-dir")
127
+ except RuntimeError:
128
+ print("error: not a git repo", file=sys.stderr)
129
+ return 2
130
+ return cmd_digest(args)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
standupguy/digest.py ADDED
@@ -0,0 +1,95 @@
1
+ """Digest: turn commits into grouped Markdown. Deterministic, offline.
2
+
3
+ Groups by conventional-commit prefix (feat/fix/docs/refactor/test/chore),
4
+ falls back to an 'other' bucket. Merge commits surface as shipped PRs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import OrderedDict
11
+
12
+ from . import gitlog as gitlog_mod
13
+
14
+ _PREFIX_RE = re.compile(r"^(\w+)(?:\(.+\))?[:!]\s*(.+)$", re.IGNORECASE)
15
+
16
+ GROUP_ORDER = ["feat", "fix", "refactor", "docs", "test", "chore", "other"]
17
+ GROUP_TITLES = {
18
+ "feat": "Features", "fix": "Fixes", "refactor": "Refactors",
19
+ "docs": "Docs", "test": "Tests", "chore": "Chores", "other": "Other",
20
+ }
21
+
22
+
23
+ def classify(subject: str) -> tuple[str, str]:
24
+ """(group, cleaned subject)."""
25
+ m = _PREFIX_RE.match(subject.strip())
26
+ if m and m.group(1).lower() in GROUP_TITLES:
27
+ return m.group(1).lower(), m.group(2).strip()
28
+ return "other", subject.strip()
29
+
30
+
31
+ def group_commits(commits: list[gitlog_mod.Commit]
32
+ ) -> "OrderedDict[str, list[gitlog_mod.Commit]]":
33
+ groups: OrderedDict[str, list] = OrderedDict(
34
+ (g, []) for g in GROUP_ORDER)
35
+ for c in commits:
36
+ g, _ = classify(c.subject)
37
+ groups[g].append(c)
38
+ return groups
39
+
40
+
41
+ def _commit_line(c: gitlog_mod.Commit) -> str:
42
+ _, clean = classify(c.subject)
43
+ files = f" ({len(c.files)} files)" if c.files else ""
44
+ pr = f" [PR #{c.pr_number}]" if c.pr_number else ""
45
+ return f"- {clean}{pr}{files} `{c.sha[:7]}`"
46
+
47
+
48
+ def digest_markdown(root: str, since: str, label: str,
49
+ author: str | None = None) -> str:
50
+ commits = gitlog_mod.commits_since(root, since, author)
51
+ in_progress = gitlog_mod.uncommitted(root)
52
+ repo = gitlog_mod.repo_name(root)
53
+ lines = [f"# Standup — {repo}", f"_{label}_", ""]
54
+ if not commits and not in_progress:
55
+ lines.append("No commits or uncommitted changes in range. "
56
+ "Touch grass. 🌱")
57
+ return "\n".join(lines) + "\n"
58
+ if commits:
59
+ lines.append("## Shipped")
60
+ for group, items in group_commits(commits).items():
61
+ if not items:
62
+ continue
63
+ lines.append(f"### {GROUP_TITLES[group]}")
64
+ lines.extend(_commit_line(c) for c in items)
65
+ lines.append("")
66
+ if in_progress:
67
+ lines.append("## In progress (uncommitted)")
68
+ lines.extend(f"- `{p}`" for p in sorted(in_progress)[:20])
69
+ if len(in_progress) > 20:
70
+ lines.append(f"- …and {len(in_progress) - 20} more")
71
+ lines.append("")
72
+ lines.append("## Blockers")
73
+ lines.append("_none reported — fill in yours_")
74
+ lines.append("")
75
+ return "\n".join(lines)
76
+
77
+
78
+ def weekly_markdown(root: str, author: str | None = None) -> str:
79
+ commits = gitlog_mod.commits_since(root, "7 days ago", author)
80
+ repo = gitlog_mod.repo_name(root)
81
+ by_day: OrderedDict[str, list] = OrderedDict()
82
+ for c in commits:
83
+ by_day.setdefault(c.date, []).append(c)
84
+ lines = [f"# Weekly summary — {repo}",
85
+ f"_{len(commits)} commits in the last 7 days_", ""]
86
+ for day in sorted(by_day):
87
+ lines.append(f"## {day}")
88
+ for group, items in group_commits(by_day[day]).items():
89
+ if items:
90
+ lines.append(f"### {GROUP_TITLES[group]}")
91
+ lines.extend(_commit_line(c) for c in items)
92
+ lines.append("")
93
+ lines.append("## Highlights for sprint review")
94
+ lines.append("_pick 3–5 bullets from above_")
95
+ return "\n".join(lines) + "\n"
standupguy/draft.py ADDED
@@ -0,0 +1,100 @@
1
+ """Draft: turn a digest into Yesterday/Today/Blockers prose via a local model.
2
+
3
+ Local-first: the default endpoint is Ollama on localhost. Point
4
+ AUTOSTANDUP_BASE_URL at freellmapi or any OpenAI-compatible endpoint.
5
+ Your git history never leaves the machine except to the endpoint YOU
6
+ configured. No cloud SaaS, no data-leak ceremony.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import urllib.request
14
+ import urllib.error
15
+
16
+
17
+ class DraftError(RuntimeError):
18
+ pass
19
+
20
+
21
+ # Largest digest (characters) sent to the model for --draft. A full-repo
22
+ # digest can be hundreds of KB (the Round 2 panel measured 569KB/142k
23
+ # tokens on one repo), so the digest is truncated for the MODEL CALL only;
24
+ # the digest printed to the terminal is untouched.
25
+ MAX_DIGEST_CHARS = 100_000
26
+
27
+ TRUNCATION_NOTE = (
28
+ "\n\n[Note: this digest was truncated to 100,000 characters for the "
29
+ "model call. The full digest is still available above.]")
30
+
31
+
32
+ DRAFT_TEMPLATE = """You are writing a developer's daily standup notes. Be terse.
33
+ Use exactly these three sections:
34
+
35
+ Yesterday:
36
+ - 2-5 bullets of what was accomplished, in plain past tense.
37
+
38
+ Today:
39
+ - 2-4 bullets of what is in progress or planned next, inferred from the
40
+ in-progress files and the trajectory of yesterday's work.
41
+
42
+ Blockers:
43
+ - "None reported." unless the digest clearly shows something stuck.
44
+
45
+ Rules: no fluff, no emojis, no inventing work that isn't in the digest.
46
+ If the digest is thin, say so in one line and stop.
47
+
48
+ Digest:
49
+ ---
50
+ {digest}
51
+ ---
52
+ """
53
+
54
+
55
+ def _cfg(base_url=None, api_key=None, model=None):
56
+ return {
57
+ "base_url": (base_url or os.environ.get("AUTOSTANDUP_BASE_URL")
58
+ or "http://localhost:11434/v1").rstrip("/"),
59
+ "api_key": api_key or os.environ.get("AUTOSTANDUP_API_KEY") or "ollama",
60
+ "model": model or os.environ.get("AUTOSTANDUP_MODEL") or "llama3.1",
61
+ }
62
+
63
+
64
+ def _cap_digest(digest_md: str) -> str:
65
+ """Truncate the digest for the model call; the terminal copy is full.
66
+
67
+ Returns the digest unchanged when it fits; otherwise keeps the first
68
+ MAX_DIGEST_CHARS characters and appends a note saying so. The note is
69
+ plain text without {} so DRAFT_TEMPLATE.format() stays safe.
70
+ """
71
+ if len(digest_md) <= MAX_DIGEST_CHARS:
72
+ return digest_md
73
+ return digest_md[:MAX_DIGEST_CHARS] + TRUNCATION_NOTE
74
+
75
+
76
+ def draft(digest_md: str, *, base_url=None, api_key=None, model=None,
77
+ timeout: int = 120) -> str:
78
+ digest_md = _cap_digest(digest_md)
79
+ cfg = _cfg(base_url, api_key, model)
80
+ body = json.dumps({
81
+ "model": cfg["model"],
82
+ "messages": [{"role": "user",
83
+ "content": DRAFT_TEMPLATE.format(digest=digest_md)}],
84
+ "temperature": 0.3,
85
+ }).encode()
86
+ req = urllib.request.Request(
87
+ cfg["base_url"] + "/chat/completions", data=body,
88
+ headers={"Content-Type": "application/json",
89
+ "Authorization": f"Bearer {cfg['api_key']}"})
90
+ try:
91
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
92
+ data = json.loads(resp.read().decode())
93
+ except urllib.error.URLError as e:
94
+ raise DraftError(
95
+ f"cannot reach model at {cfg['base_url']} "
96
+ f"(set AUTOSTANDUP_BASE_URL): {e.reason}")
97
+ try:
98
+ return data["choices"][0]["message"]["content"].strip()
99
+ except (KeyError, IndexError, TypeError) as e:
100
+ raise DraftError(f"unexpected response shape: {e}")
standupguy/gitlog.py ADDED
@@ -0,0 +1,122 @@
1
+ """Git ground truth: commits, files, merges, uncommitted work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import re
7
+ import subprocess
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class Commit:
12
+ sha: str
13
+ author: str
14
+ email: str
15
+ date: str # YYYY-MM-DD
16
+ subject: str
17
+ files: list[str]
18
+ pr_number: int | None = None
19
+
20
+
21
+ def _git(root: str, *args: str, env: dict | None = None) -> str:
22
+ import os
23
+ full_env = dict(os.environ)
24
+ if env:
25
+ full_env.update(env)
26
+ p = subprocess.run(["git", "-C", root, *args], capture_output=True,
27
+ text=True, timeout=60, env=full_env)
28
+ if p.returncode != 0:
29
+ raise RuntimeError(f"git {' '.join(args)} failed: {p.stderr.strip()[:200]}")
30
+ return p.stdout
31
+
32
+
33
+ _MERGE_RE = re.compile(r"merge pull request #(\d+)", re.IGNORECASE)
34
+
35
+
36
+ def _since_to_arg(since: str) -> str:
37
+ """Normalize `since` to an unambiguous form for git.
38
+
39
+ Git's approxidate parses a bare "2026-10-14" (and words like
40
+ "yesterday") as NOON, not midnight — silently dropping morning
41
+ commits. Rewrite the common forms to explicit midnight strings,
42
+ which git handles correctly. Anything else passes through.
43
+ """
44
+ from datetime import datetime, timedelta
45
+ s = since.strip()
46
+ now = datetime.now().astimezone()
47
+ m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", s)
48
+ if m:
49
+ return f"{s} 00:00"
50
+ if s.lower() == "yesterday":
51
+ day = (now - timedelta(days=1)).strftime("%Y-%m-%d")
52
+ return f"{day} 00:00"
53
+ m = re.fullmatch(r"(\d+)\s+days?\s+ago", s.lower())
54
+ if m:
55
+ day = (now - timedelta(days=int(m.group(1)))).strftime("%Y-%m-%d")
56
+ return f"{day} 00:00"
57
+ return since
58
+
59
+
60
+ def commits_since(root: str, since: str, author: str | None = None
61
+ ) -> list[Commit]:
62
+ """since: 'yesterday', '7 days ago', '2026-10-01', or git date syntax."""
63
+ since = _since_to_arg(since)
64
+ args = ["log", f"--since={since}", "--date=short",
65
+ "--pretty=format:COMMIT:%H|%an|%ae|%ad|%s",
66
+ "--name-only", "HEAD"]
67
+ if author:
68
+ args.append(f"--author={author}")
69
+ try:
70
+ out = _git(root, *args)
71
+ except RuntimeError as e:
72
+ # empty repo (no commits yet) -> no history, not an error
73
+ if "unknown revision" in str(e):
74
+ return []
75
+ raise
76
+ commits: list[Commit] = []
77
+ cur: Commit | None = None
78
+ for line in out.splitlines():
79
+ if line.startswith("COMMIT:"):
80
+ if cur:
81
+ commits.append(cur)
82
+ _, rest = line.split("COMMIT:", 1)
83
+ sha, an, ae, ad, subject = rest.split("|", 4)
84
+ m = _MERGE_RE.search(subject)
85
+ cur = Commit(sha=sha, author=an, email=ae, date=ad,
86
+ subject=subject, files=[],
87
+ pr_number=int(m.group(1)) if m else None)
88
+ elif cur and line.strip():
89
+ cur.files.append(line.strip())
90
+ if cur:
91
+ commits.append(cur)
92
+ return commits
93
+
94
+
95
+ def uncommitted(root: str) -> list[str]:
96
+ """Repo-relative paths with uncommitted changes."""
97
+ out = _git(root, "status", "--porcelain")
98
+ paths = []
99
+ for line in out.splitlines():
100
+ line = line.rstrip()
101
+ if not line:
102
+ continue
103
+ # format: XY <path> [-> <newpath>]
104
+ path = line[3:].split(" -> ")[-1].strip().strip('"')
105
+ paths.append(path)
106
+ return paths
107
+
108
+
109
+ def default_author(root: str) -> str:
110
+ try:
111
+ return _git(root, "config", "user.email").strip()
112
+ except RuntimeError:
113
+ return ""
114
+
115
+
116
+ def repo_name(root: str) -> str:
117
+ import os
118
+ try:
119
+ out = _git(root, "rev-parse", "--show-toplevel").strip()
120
+ return os.path.basename(out)
121
+ except RuntimeError:
122
+ return root
standupguy/mcp.py ADDED
@@ -0,0 +1,87 @@
1
+ """Stdio MCP server: let your coding agent ask what you shipped.
2
+
3
+ Tools:
4
+ what_did_i_ship {since?, author?} -> standup digest markdown
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from . import digest as digest_mod
14
+
15
+ SERVER_INFO = {"name": "standupguy", "version": "0.1.0"}
16
+
17
+
18
+ def handle(request: dict, root: str) -> dict | None:
19
+ req_id = request.get("id")
20
+ method = request.get("method")
21
+
22
+ def ok(result):
23
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
24
+
25
+ def err(code, message):
26
+ return {"jsonrpc": "2.0", "id": req_id,
27
+ "error": {"code": code, "message": message}}
28
+
29
+ if method == "initialize":
30
+ return ok({"protocolVersion": "2024-11-05",
31
+ "capabilities": {"tools": {}},
32
+ "serverInfo": SERVER_INFO})
33
+ if method == "notifications/initialized":
34
+ return None
35
+ if method == "tools/list":
36
+ return ok({"tools": [{
37
+ "name": "what_did_i_ship",
38
+ "description": ("Get a Markdown digest of what was shipped in a "
39
+ "git repo since a date: commits grouped by type, "
40
+ "in-progress files. Local-only, no cloud."),
41
+ "inputSchema": {"type": "object", "properties": {
42
+ "since": {"type": "string",
43
+ "description": "git date: 'yesterday', '7 days ago', "
44
+ "'2026-10-01' (default: yesterday)"},
45
+ "author": {"type": "string",
46
+ "description": "filter by author (default: repo user)"},
47
+ "root": {"type": "string",
48
+ "description": "repo path (default: server's root)"}}},
49
+ }]})
50
+ if method == "tools/call":
51
+ params = request.get("params", {})
52
+ if params.get("name") != "what_did_i_ship":
53
+ return err(-32602, f"unknown tool: {params.get('name')}")
54
+ args = params.get("arguments", {})
55
+ since = args.get("since") or "yesterday"
56
+ r = args.get("root") or root
57
+ try:
58
+ md = digest_mod.digest_markdown(
59
+ r, since, f"since {since}", args.get("author"))
60
+ except Exception as e:
61
+ return ok({"content": [{"type": "text",
62
+ "text": json.dumps({"error": str(e)})}],
63
+ "isError": True})
64
+ return ok({"content": [{"type": "text", "text": md}]})
65
+ return err(-32601, f"unknown method: {method}")
66
+
67
+
68
+ def serve(root: str | None = None) -> int:
69
+ root = root or os.getcwd()
70
+ for line in sys.stdin:
71
+ line = line.strip()
72
+ if not line:
73
+ continue
74
+ try:
75
+ request = json.loads(line)
76
+ except ValueError:
77
+ continue
78
+ try:
79
+ response = handle(request, root)
80
+ except Exception as e:
81
+ rid = request.get("id") if isinstance(request, dict) else None
82
+ response = {"jsonrpc": "2.0", "id": rid,
83
+ "error": {"code": -32603, "message": str(e)}}
84
+ if response is not None:
85
+ sys.stdout.write(json.dumps(response) + "\n")
86
+ sys.stdout.flush()
87
+ return 0
standupguy/push.py ADDED
@@ -0,0 +1,79 @@
1
+ """Slack push: POST the notes to an incoming webhook. Opt-in only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import unicodedata
8
+ import urllib.request
9
+ import urllib.error
10
+
11
+ # Max characters per line of pushed text. Commit subjects flow through the
12
+ # digest and (optionally) the LLM draft into Slack, so a hostile commit
13
+ # message is an indirect prompt-injection vector against the team channel.
14
+ # Long commit-derived lines are truncated here before anything is posted.
15
+ MAX_PUSH_LINE_CHARS = 500
16
+
17
+ # Slack mass-mentions we refuse to forward verbatim. Both forms are covered:
18
+ # the plain @channel style and Slack's markup <!channel> style (which
19
+ # plain-@channel filters miss but Slack still renders as a mass mention),
20
+ # including Slack's optional display label (<!channel|channel>).
21
+ # The word boundary keeps us from rewriting words like @channeling.
22
+ _MASS_MENTION_RE = re.compile(
23
+ r"<!((?:channel|here|everyone))(?:\|[^<>]*)?>"
24
+ r"|@(channel|here|everyone)\b",
25
+ re.IGNORECASE)
26
+
27
+
28
+ def sanitize_slack_text(text: str) -> str:
29
+ """Neutralize risky content before it reaches the team Slack channel.
30
+
31
+ - Strips control characters (keeps tab/newline) so hostile messages
32
+ can't smuggle terminal escapes or invisible layout tricks.
33
+ - Truncates each line to MAX_PUSH_LINE_CHARS (500) so a giant commit
34
+ message can't become a giant injected payload.
35
+ - Rewrites mass mentions as ``[at]channel``-style text: an injected
36
+ ``@channel`` or ``<!channel>`` must never ping the whole team. We chose
37
+ the visible ``[at]`` rewrite over a zero-width-space insertion because
38
+ it is unambiguous in logs, diffs, and every Slack client (zero-width
39
+ characters can be dropped or re-normalized downstream).
40
+ """
41
+ clean_lines = []
42
+ for line in text.splitlines():
43
+ line = "".join(
44
+ ch for ch in line
45
+ if ch == "\t" or unicodedata.category(ch) != "Cc"
46
+ )
47
+ if len(line) > MAX_PUSH_LINE_CHARS:
48
+ line = line[:MAX_PUSH_LINE_CHARS] + " […]"
49
+ line = _MASS_MENTION_RE.sub(
50
+ lambda m: "[at]" + (m.group(1) or m.group(2)).lower(), line)
51
+ clean_lines.append(line)
52
+ return "\n".join(clean_lines)
53
+
54
+
55
+ class PushError(RuntimeError):
56
+ pass
57
+
58
+
59
+ def push_slack(webhook_url: str, markdown: str, timeout: int = 30) -> None:
60
+ """POST markdown to a Slack incoming webhook.
61
+
62
+ The markdown is passed through :func:`sanitize_slack_text` first:
63
+ control chars stripped, lines truncated at 500 chars, mass mentions
64
+ (@channel/@here/@everyone and <!channel>/<!here>/<!everyone>)
65
+ neutralized to ``[at]channel``-style text —
66
+ so raw commit messages can never ping the channel or inject
67
+ formatting surprises.
68
+ """
69
+ if not webhook_url.startswith("https://hooks.slack.com/"):
70
+ raise PushError("refusing: not a Slack webhook URL")
71
+ body = json.dumps({"text": sanitize_slack_text(markdown)}).encode()
72
+ req = urllib.request.Request(webhook_url, data=body,
73
+ headers={"Content-Type": "application/json"})
74
+ try:
75
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
76
+ if resp.status not in (200, 201, 204):
77
+ raise PushError(f"Slack returned HTTP {resp.status}")
78
+ except urllib.error.URLError as e:
79
+ raise PushError(f"Slack push failed: {e.reason}")
standupguy/serve.py ADDED
@@ -0,0 +1,125 @@
1
+ """HTTP bridge for workflow tools (n8n, Activepieces, etc.).
2
+
3
+ n8n can't easily call a CLI on a schedule and parse stdout — but its
4
+ HTTP Request node can hit this. Run `standupguy serve`, then point
5
+ n8n at http://localhost:8765/digest.
6
+
7
+ Endpoints:
8
+ GET /health -> {"ok": true}
9
+ GET /digest?since=&author=&format= -> standup digest (json|markdown)
10
+ GET /week?author=&format= -> weekly summary (json|markdown)
11
+
12
+ Binds 127.0.0.1 by default. Only open --host 0.0.0.0 if you know why.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
+ from urllib.parse import urlparse, parse_qs
21
+
22
+ from . import digest as digest_mod
23
+ from . import gitlog as gitlog_mod
24
+
25
+
26
+ # Per-connection socket timeout (seconds). A connection that goes quiet for
27
+ # longer than this gets closed instead of holding a thread — and with the
28
+ # single-threaded HTTPServer it previously held the WHOLE bridge hostage.
29
+ CONNECTION_TIMEOUT = 30
30
+
31
+
32
+ class ThreadedBridgeServer(ThreadingHTTPServer):
33
+ """ThreadingHTTPServer with a per-connection socket timeout.
34
+
35
+ One thread per connection so a stalled peer can never wedge the
36
+ bridge; the socket timeout bounds how long a quiet connection can
37
+ linger.
38
+ """
39
+
40
+ def get_request(self):
41
+ sock, addr = self.socket.accept()
42
+ sock.settimeout(CONNECTION_TIMEOUT)
43
+ return sock, addr
44
+
45
+
46
+ def make_server(root: str, host: str = "127.0.0.1",
47
+ port: int = 8765) -> ThreadedBridgeServer:
48
+ """Build the configured bridge server (used by serve() and by tests)."""
49
+ Handler.root = root
50
+ return ThreadedBridgeServer((host, port), Handler)
51
+
52
+
53
+ def _payload(root: str, query: dict, weekly: bool) -> tuple[int, str, str]:
54
+ """(status, content_type, body)."""
55
+ author = query.get("author", [None])[0] or None
56
+ fmt = (query.get("format", ["json"])[0] or "json").lower()
57
+ if weekly:
58
+ md = digest_mod.weekly_markdown(root, author)
59
+ commits = gitlog_mod.commits_since(root, "7 days ago", author)
60
+ label = "week"
61
+ else:
62
+ since = query.get("since", ["yesterday"])[0] or "yesterday"
63
+ md = digest_mod.digest_markdown(root, since, f"since {since}", author)
64
+ commits = gitlog_mod.commits_since(root, since, author)
65
+ label = f"since {since}"
66
+ if fmt == "markdown":
67
+ return 200, "text/markdown; charset=utf-8", md
68
+ body = json.dumps({
69
+ "repo": gitlog_mod.repo_name(root), "label": label,
70
+ "in_progress": gitlog_mod.uncommitted(root),
71
+ "commits": [{"sha": c.sha, "author": c.author, "date": c.date,
72
+ "subject": c.subject, "files": c.files,
73
+ "pr": c.pr_number} for c in commits],
74
+ "markdown": md,
75
+ })
76
+ return 200, "application/json", body
77
+
78
+
79
+ class Handler(BaseHTTPRequestHandler):
80
+ root = os.getcwd()
81
+
82
+ def _send(self, status: int, ctype: str, body: str) -> None:
83
+ data = body.encode()
84
+ self.send_response(status)
85
+ self.send_header("Content-Type", ctype)
86
+ self.send_header("Content-Length", str(len(data)))
87
+ self.end_headers()
88
+ self.wfile.write(data)
89
+
90
+ def do_GET(self) -> None: # noqa: N802
91
+ parsed = urlparse(self.path)
92
+ query = parse_qs(parsed.query)
93
+ if parsed.path == "/health":
94
+ self._send(200, "application/json", '{"ok": true}')
95
+ elif parsed.path == "/digest":
96
+ try:
97
+ s, t, b = _payload(self.root, query, weekly=False)
98
+ self._send(s, t, b)
99
+ except Exception as e:
100
+ self._send(500, "application/json",
101
+ json.dumps({"error": str(e)}))
102
+ elif parsed.path == "/week":
103
+ try:
104
+ s, t, b = _payload(self.root, query, weekly=True)
105
+ self._send(s, t, b)
106
+ except Exception as e:
107
+ self._send(500, "application/json",
108
+ json.dumps({"error": str(e)}))
109
+ else:
110
+ self._send(404, "application/json",
111
+ '{"error": "use /digest, /week, or /health"}')
112
+
113
+ def log_message(self, *args): # noqa: ANN001, ANN202
114
+ pass
115
+
116
+
117
+ def serve(root: str, host: str = "127.0.0.1", port: int = 8765) -> int:
118
+ server = make_server(root, host, port)
119
+ print(f"standupguy serving {root} on http://{host}:{port} "
120
+ f"(/digest, /week, /health)")
121
+ try:
122
+ server.serve_forever()
123
+ except KeyboardInterrupt:
124
+ pass
125
+ return 0
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: standupguy
3
+ Version: 0.1.0
4
+ Summary: Local-first standup notes from git ground truth.
5
+ Author: Calas Automations Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/calasdan-sketch/standupguy
8
+ Project-URL: Issues, https://github.com/calasdan-sketch/standupguy/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # StandupGuy (`standupguy`)
15
+
16
+ Kill the tracker trap without leaking data.
17
+
18
+ Engineers lose up to 2/5 of the week to reporting busywork — duplicating
19
+ updates across Jira, Slack, Confluence, and slide decks. AI note-takers are
20
+ cloud SaaS with data-leak concerns. `standupguy` is the local-first
21
+ alternative: it reads your **git history** (ground truth) and drafts your
22
+ standup notes, weekly summary, and sprint-review bullets as Markdown.
23
+ **Your history never leaves your laptop** — except to the model endpoint
24
+ *you* configure (Ollama on localhost by default).
25
+
26
+ ## Install
27
+
28
+ Zero dependencies — Python 3.10+ stdlib only.
29
+
30
+ ```bash
31
+ git clone <repo> && cd standupguy
32
+ # no-install alias (run from the repo root):
33
+ alias standupguy="python3 -m standupguy"
34
+ # or: pip install . # provides the `standupguy` console script
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ standupguy # yesterday's digest (deterministic, offline)
41
+ standupguy --week # last 7 days
42
+ standupguy --sprint # sprint-review bullets
43
+ standupguy --since 2026-10-01 # custom range
44
+ standupguy --draft # LLM prose: Yesterday / Today / Blockers
45
+ standupguy --format json # machine-readable digest
46
+ standupguy --author "teammate@co" # filter (default: your git user)
47
+ standupguy --all # everyone
48
+ standupguy --push-slack # POST to AUTOSTANDUP_SLACK_WEBHOOK
49
+ standupguy mcp # MCP server on stdio
50
+ standupguy serve # HTTP bridge for Activepieces / workflow tools
51
+ ```
52
+
53
+ ## Activepieces
54
+
55
+ `standupguy serve` exposes the digest over HTTP so Activepieces' HTTP
56
+ Request piece can pull it on a schedule:
57
+
58
+ ```bash
59
+ standupguy serve --root /path/to/repo # localhost:8765
60
+ ```
61
+
62
+ - `GET /digest?since=yesterday&author=&format=json|markdown`
63
+ - `GET /week?author=&format=json|markdown`
64
+ - `GET /health`
65
+
66
+ [`workflows/activepieces-standup-digest.json`](workflows/activepieces-standup-digest.json)
67
+ is a documented flow definition (it's a human-readable spec, not a native
68
+ export — build it in Activepieces by adding the steps in order; the file
69
+ carries the exact config for each step and setup notes at the top):
70
+ weekday 9am Schedule trigger → HTTP Request → Branch (skip quiet days) →
71
+ Slack. Connect your Slack connection, set the channel, done. The same
72
+ `serve` endpoints work from n8n's HTTP Request node too — nothing in the
73
+ bridge is platform-specific.
74
+
75
+ Example output:
76
+
77
+ ```markdown
78
+ # Standup — myrepo
79
+ _since 2026-10-14_
80
+
81
+ ## Shipped
82
+ ### Features
83
+ - add login flow (1 files) `6785c87`
84
+ ### Fixes
85
+ - null pointer on logout (1 files) `8ce2731`
86
+
87
+ ## In progress (uncommitted)
88
+ - `wip.py`
89
+
90
+ ## Blockers
91
+ _none reported — fill in yours_
92
+ ```
93
+
94
+ ## How it works
95
+
96
+ - **Digest mode (default):** pure Python, fully offline. Commits grouped by
97
+ conventional-commit prefix (feat/fix/docs/refactor/test/chore); merge
98
+ commits surface as shipped PRs; `git status` feeds the In-progress section.
99
+ - **Draft mode (`--draft`):** sends the digest to your local model via an
100
+ OpenAI-compatible endpoint and gets back Yesterday/Today/Blockers prose.
101
+ Configure with env vars (Ollama defaults shown):
102
+
103
+ ```bash
104
+ export AUTOSTANDUP_BASE_URL="http://localhost:11434/v1"
105
+ export AUTOSTANDUP_MODEL="llama3.1"
106
+ # freellmapi works too — it's OpenAI-compatible:
107
+ export AUTOSTANDUP_BASE_URL="https://api.freellmapi.com/v1"
108
+ export AUTOSTANDUP_API_KEY="<your key>"
109
+ ```
110
+
111
+ - **MCP server:** exposes `what_did_i_ship {since?, author?, root?}` so your
112
+ coding agent can answer "what did I ship this week" from local git.
113
+ - **Push:** `--push-slack` POSTs the Markdown to a Slack incoming webhook
114
+ (URL from the flag or `AUTOSTANDUP_SLACK_WEBHOOK`). Commit-derived text
115
+ is sanitized before posting: control characters stripped, lines
116
+ truncated at 500 chars, and `@channel`/`@here`/`@everyone` neutralized
117
+ to `[at]channel`-style text — so a hostile commit message can never ping
118
+ the channel or inject payloads. Jira/Confluence push
119
+ is intentionally not built in — paste the Markdown, keep your tokens
120
+ out of another integration.
121
+
122
+ ## Date handling
123
+
124
+ Bare dates and words are normalized to explicit midnight (`2026-10-14` →
125
+ `2026-10-14 00:00`, `yesterday` → yesterday 00:00) because git's date parser
126
+ treats them as *noon* — silently dropping morning commits.
127
+
128
+ ## Tests
129
+
130
+ ```bash
131
+ python3 -m unittest discover -s tests # 36 tests, no network, no model
132
+ ```
133
+
134
+ ## License
135
+
136
+ TBD
@@ -0,0 +1,15 @@
1
+ standupguy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ standupguy/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ standupguy/cli.py,sha256=COWdznA7602_5i-4MM6hcC7Ycc7NAdYPN-Gbh9hQISU,5145
4
+ standupguy/digest.py,sha256=L3ruE3nIb11NRjDbvkNdemcvPpKSbmdoXsZAMvSov-E,3519
5
+ standupguy/draft.py,sha256=Y8M0aZtuWPHvEcTy-dR4FYBbbyRy56-mpGa1n4gFIuU,3430
6
+ standupguy/gitlog.py,sha256=DEwVhRegCIIrJwei3Q8TXIunP0g5BYhaZWyTkmNG2qQ,3764
7
+ standupguy/mcp.py,sha256=UQ7SnXhl6D3KJukSVWN9WAM6TMLZN8SvKfvFTiFaY60,3218
8
+ standupguy/push.py,sha256=nTQ1VHYznudXIArIhgDHLgbNSTxwJXgTDPGD1-vJNE8,3373
9
+ standupguy/serve.py,sha256=f7QcCJFwdWQBwgMrszYtjlYclze8k05NEO8OIOERMJY,4534
10
+ standupguy-0.1.0.dist-info/licenses/LICENSE,sha256=f-qFLkEfvZidBgKv2YINOOKS7Trk_gj1c6Cy718D8F0,1079
11
+ standupguy-0.1.0.dist-info/METADATA,sha256=dfoUHiu8ra-_y0WgVsSuoL6udZE49JaaZl9GdaowJEY,4844
12
+ standupguy-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ standupguy-0.1.0.dist-info/entry_points.txt,sha256=BDo9dF-bhfVsCeyKsSua8SH2GHI3svG_QuhzJQzzob0,51
14
+ standupguy-0.1.0.dist-info/top_level.txt,sha256=WpKcSamOiLVk4KtEp608GLvAuChV990S9ORpBaoGMhs,11
15
+ standupguy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ standupguy = standupguy.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calas Automations Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ standupguy