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 +0 -0
- standupguy/__main__.py +4 -0
- standupguy/cli.py +134 -0
- standupguy/digest.py +95 -0
- standupguy/draft.py +100 -0
- standupguy/gitlog.py +122 -0
- standupguy/mcp.py +87 -0
- standupguy/push.py +79 -0
- standupguy/serve.py +125 -0
- standupguy-0.1.0.dist-info/METADATA +136 -0
- standupguy-0.1.0.dist-info/RECORD +15 -0
- standupguy-0.1.0.dist-info/WHEEL +5 -0
- standupguy-0.1.0.dist-info/entry_points.txt +2 -0
- standupguy-0.1.0.dist-info/licenses/LICENSE +21 -0
- standupguy-0.1.0.dist-info/top_level.txt +1 -0
standupguy/__init__.py
ADDED
|
File without changes
|
standupguy/__main__.py
ADDED
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,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
|