standupguy 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.
- standupguy-0.1.0/LICENSE +21 -0
- standupguy-0.1.0/PKG-INFO +136 -0
- standupguy-0.1.0/README.md +123 -0
- standupguy-0.1.0/pyproject.toml +21 -0
- standupguy-0.1.0/setup.cfg +4 -0
- standupguy-0.1.0/standupguy/__init__.py +0 -0
- standupguy-0.1.0/standupguy/__main__.py +4 -0
- standupguy-0.1.0/standupguy/cli.py +134 -0
- standupguy-0.1.0/standupguy/digest.py +95 -0
- standupguy-0.1.0/standupguy/draft.py +100 -0
- standupguy-0.1.0/standupguy/gitlog.py +122 -0
- standupguy-0.1.0/standupguy/mcp.py +87 -0
- standupguy-0.1.0/standupguy/push.py +79 -0
- standupguy-0.1.0/standupguy/serve.py +125 -0
- standupguy-0.1.0/standupguy.egg-info/PKG-INFO +136 -0
- standupguy-0.1.0/standupguy.egg-info/SOURCES.txt +19 -0
- standupguy-0.1.0/standupguy.egg-info/dependency_links.txt +1 -0
- standupguy-0.1.0/standupguy.egg-info/entry_points.txt +2 -0
- standupguy-0.1.0/standupguy.egg-info/top_level.txt +1 -0
- standupguy-0.1.0/tests/test_serve.py +146 -0
- standupguy-0.1.0/tests/test_standupguy.py +389 -0
standupguy-0.1.0/LICENSE
ADDED
|
@@ -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,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,123 @@
|
|
|
1
|
+
# StandupGuy (`standupguy`)
|
|
2
|
+
|
|
3
|
+
Kill the tracker trap without leaking data.
|
|
4
|
+
|
|
5
|
+
Engineers lose up to 2/5 of the week to reporting busywork — duplicating
|
|
6
|
+
updates across Jira, Slack, Confluence, and slide decks. AI note-takers are
|
|
7
|
+
cloud SaaS with data-leak concerns. `standupguy` is the local-first
|
|
8
|
+
alternative: it reads your **git history** (ground truth) and drafts your
|
|
9
|
+
standup notes, weekly summary, and sprint-review bullets as Markdown.
|
|
10
|
+
**Your history never leaves your laptop** — except to the model endpoint
|
|
11
|
+
*you* configure (Ollama on localhost by default).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Zero dependencies — Python 3.10+ stdlib only.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
git clone <repo> && cd standupguy
|
|
19
|
+
# no-install alias (run from the repo root):
|
|
20
|
+
alias standupguy="python3 -m standupguy"
|
|
21
|
+
# or: pip install . # provides the `standupguy` console script
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
standupguy # yesterday's digest (deterministic, offline)
|
|
28
|
+
standupguy --week # last 7 days
|
|
29
|
+
standupguy --sprint # sprint-review bullets
|
|
30
|
+
standupguy --since 2026-10-01 # custom range
|
|
31
|
+
standupguy --draft # LLM prose: Yesterday / Today / Blockers
|
|
32
|
+
standupguy --format json # machine-readable digest
|
|
33
|
+
standupguy --author "teammate@co" # filter (default: your git user)
|
|
34
|
+
standupguy --all # everyone
|
|
35
|
+
standupguy --push-slack # POST to AUTOSTANDUP_SLACK_WEBHOOK
|
|
36
|
+
standupguy mcp # MCP server on stdio
|
|
37
|
+
standupguy serve # HTTP bridge for Activepieces / workflow tools
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Activepieces
|
|
41
|
+
|
|
42
|
+
`standupguy serve` exposes the digest over HTTP so Activepieces' HTTP
|
|
43
|
+
Request piece can pull it on a schedule:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
standupguy serve --root /path/to/repo # localhost:8765
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `GET /digest?since=yesterday&author=&format=json|markdown`
|
|
50
|
+
- `GET /week?author=&format=json|markdown`
|
|
51
|
+
- `GET /health`
|
|
52
|
+
|
|
53
|
+
[`workflows/activepieces-standup-digest.json`](workflows/activepieces-standup-digest.json)
|
|
54
|
+
is a documented flow definition (it's a human-readable spec, not a native
|
|
55
|
+
export — build it in Activepieces by adding the steps in order; the file
|
|
56
|
+
carries the exact config for each step and setup notes at the top):
|
|
57
|
+
weekday 9am Schedule trigger → HTTP Request → Branch (skip quiet days) →
|
|
58
|
+
Slack. Connect your Slack connection, set the channel, done. The same
|
|
59
|
+
`serve` endpoints work from n8n's HTTP Request node too — nothing in the
|
|
60
|
+
bridge is platform-specific.
|
|
61
|
+
|
|
62
|
+
Example output:
|
|
63
|
+
|
|
64
|
+
```markdown
|
|
65
|
+
# Standup — myrepo
|
|
66
|
+
_since 2026-10-14_
|
|
67
|
+
|
|
68
|
+
## Shipped
|
|
69
|
+
### Features
|
|
70
|
+
- add login flow (1 files) `6785c87`
|
|
71
|
+
### Fixes
|
|
72
|
+
- null pointer on logout (1 files) `8ce2731`
|
|
73
|
+
|
|
74
|
+
## In progress (uncommitted)
|
|
75
|
+
- `wip.py`
|
|
76
|
+
|
|
77
|
+
## Blockers
|
|
78
|
+
_none reported — fill in yours_
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
- **Digest mode (default):** pure Python, fully offline. Commits grouped by
|
|
84
|
+
conventional-commit prefix (feat/fix/docs/refactor/test/chore); merge
|
|
85
|
+
commits surface as shipped PRs; `git status` feeds the In-progress section.
|
|
86
|
+
- **Draft mode (`--draft`):** sends the digest to your local model via an
|
|
87
|
+
OpenAI-compatible endpoint and gets back Yesterday/Today/Blockers prose.
|
|
88
|
+
Configure with env vars (Ollama defaults shown):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
export AUTOSTANDUP_BASE_URL="http://localhost:11434/v1"
|
|
92
|
+
export AUTOSTANDUP_MODEL="llama3.1"
|
|
93
|
+
# freellmapi works too — it's OpenAI-compatible:
|
|
94
|
+
export AUTOSTANDUP_BASE_URL="https://api.freellmapi.com/v1"
|
|
95
|
+
export AUTOSTANDUP_API_KEY="<your key>"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **MCP server:** exposes `what_did_i_ship {since?, author?, root?}` so your
|
|
99
|
+
coding agent can answer "what did I ship this week" from local git.
|
|
100
|
+
- **Push:** `--push-slack` POSTs the Markdown to a Slack incoming webhook
|
|
101
|
+
(URL from the flag or `AUTOSTANDUP_SLACK_WEBHOOK`). Commit-derived text
|
|
102
|
+
is sanitized before posting: control characters stripped, lines
|
|
103
|
+
truncated at 500 chars, and `@channel`/`@here`/`@everyone` neutralized
|
|
104
|
+
to `[at]channel`-style text — so a hostile commit message can never ping
|
|
105
|
+
the channel or inject payloads. Jira/Confluence push
|
|
106
|
+
is intentionally not built in — paste the Markdown, keep your tokens
|
|
107
|
+
out of another integration.
|
|
108
|
+
|
|
109
|
+
## Date handling
|
|
110
|
+
|
|
111
|
+
Bare dates and words are normalized to explicit midnight (`2026-10-14` →
|
|
112
|
+
`2026-10-14 00:00`, `yesterday` → yesterday 00:00) because git's date parser
|
|
113
|
+
treats them as *noon* — silently dropping morning commits.
|
|
114
|
+
|
|
115
|
+
## Tests
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python3 -m unittest discover -s tests # 36 tests, no network, no model
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
TBD
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
[project]
|
|
5
|
+
name = "standupguy"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "Local-first standup notes from git ground truth."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
authors = [{name = "Calas Automations Inc."}]
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/calasdan-sketch/standupguy"
|
|
15
|
+
Issues = "https://github.com/calasdan-sketch/standupguy/issues"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
standupguy = "standupguy.cli:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["standupguy"]
|
|
File without changes
|
|
@@ -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())
|
|
@@ -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"
|
|
@@ -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}")
|