Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/weekly_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
REPORT_OWNER: ${{ github.repository_owner }}
REPORT_REPO: ${{ github.event.repository.name }}
LOOKBACK_DAYS: ${{ github.event.inputs.lookback_days }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: python scripts/watch_dog.py

- name: Commit updated state
Expand Down
7 changes: 6 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@
},
"lookback_days": 7,
"important_labels": ["bug", "enhancement", "breaking change", "priority/high"],
"ai_summary": true
"ai_summary": true,
"llm": {
"model": "gpt-4o-mini",
"tech_stack": "general software development",
"focus_areas": "breaking changes, new features, performance improvements, security vulnerabilities"
}
}
126 changes: 113 additions & 13 deletions scripts/watch_dog.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@

MAX_RELEASE_BODY_LENGTH = 1000
LABEL_COLOR = "0075ca" # GitHub's default blue used for informational labels
OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"
DEFAULT_LLM_MODEL = "gpt-4o-mini"
DEFAULT_LLM_TECH_STACK = "general software development"
DEFAULT_LLM_FOCUS_AREAS = "breaking changes, new features, performance improvements, security vulnerabilities"

# ---------------------------------------------------------------------------
# Paths
Expand Down Expand Up @@ -204,6 +208,75 @@ def _parse_dt(dt_str: str) -> datetime:
return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))


# ---------------------------------------------------------------------------
# LLM summarization (direct OpenAI API – configurable via config.json + OPENAI_API_KEY)
# ---------------------------------------------------------------------------

_LLM_PROMPT_TEMPLATE = """\
# Role
你是一位資深的軟體技術分析師,擅長將複雜的 GitHub 技術文件簡化為易於理解的商業與技術決策摘要。

# Task
請分析以下來自 GitHub 的 Release Note 與 Issue 討論,並針對我的需求進行篩選與彙總。

# My Context (我的背景與關注點)
- 我主要關注的技術棧:{tech_stack}
- 我在意的事:{focus_areas}
- 閱讀偏好:請避開艱澀的程式碼細節,用直白的話解釋這些變更對我的專案或開發流程有什麼實質影響。

# Output Requirements
請按以下結構輸出:
1. 🔥 重大變更 (必須注意):列出會導致程式出錯或需要大幅改動的部分。
2. ✨ 重點新功能:挑選 2-3 個最具代表性的功能,並說明用途。
3. 🛠️ 效能與修復:簡述是否有顯著的優化。
4. 💡 專家建議:根據這些變更,我現在應該「立刻更新」、「再等等」還是「手動調整某個設定」?

# Input Data
{raw_content}\
"""


def _build_llm_prompt(raw_content: str, tech_stack: str, focus_areas: str) -> str:
"""Return the filled-in LLM prompt for a single repository's raw data."""
return _LLM_PROMPT_TEMPLATE.format(
tech_stack=tech_stack,
focus_areas=focus_areas,
raw_content=raw_content,
)


def summarize_with_llm(raw_content: str, llm_config: dict, api_key: str) -> str:
"""Call the OpenAI Chat Completions API and return the LLM-generated summary.

Falls back to *raw_content* unchanged if the API call fails, so that a
transient error does not prevent the whole report from being published.
"""
model = llm_config.get("model", DEFAULT_LLM_MODEL)
tech_stack = llm_config.get("tech_stack", DEFAULT_LLM_TECH_STACK)
focus_areas = llm_config.get("focus_areas", DEFAULT_LLM_FOCUS_AREAS)
prompt = _build_llm_prompt(raw_content, tech_stack, focus_areas)
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
}
try:
resp = requests.post(
OPENAI_API_URL,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except (requests.RequestException, KeyError, IndexError) as exc:
print(f"⚠️ LLM summarization failed ({exc}); falling back to raw report.", file=sys.stderr)
return raw_content


# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------
Expand All @@ -215,8 +288,16 @@ def build_report(
since: datetime,
important_labels: list[str] | None = None,
ai_summary: bool = False,
llm_config: dict | None = None,
llm_api_key: str = "",
) -> str:
"""Fetch data for every watched repo and return a markdown report."""
"""Fetch data for every watched repo and return a markdown report.

When *llm_api_key* is provided and *llm_config* is not None, the releases
and issues section for each repository is summarised by an LLM instead of
being rendered as raw markdown.
"""
use_llm = bool(llm_api_key and llm_config is not None)
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
since_str = since.strftime("%Y-%m-%d %H:%M UTC")

Expand Down Expand Up @@ -320,30 +401,33 @@ def build_report(
sections.append(f"_No merged PRs this week._")
sections.append(f"")

# --- Releases + Issues (optionally LLM-summarized) ---
raw_lines: list[str] = []

# --- Releases ---
sections.append(f"### 🚀 New Releases ({len(releases)})")
sections.append(f"")
raw_lines.append(f"### 🚀 New Releases ({len(releases)})")
raw_lines.append(f"")
if releases:
for r in releases:
tag = r.get("tag_name", "")
name = r.get("name") or tag
html_url = r.get("html_url", "")
pub = r.get("published_at", "")[:10]
body = (r.get("body") or "").strip()
sections.append(f"#### [{name}]({html_url}) `{tag}` – {pub}")
raw_lines.append(f"#### [{name}]({html_url}) `{tag}` – {pub}")
if body:
# Indent body as a blockquote (trim to 1000 chars to keep issue readable)
trimmed = body[:MAX_RELEASE_BODY_LENGTH] + ("…" if len(body) > MAX_RELEASE_BODY_LENGTH else "")
for line in trimmed.splitlines():
sections.append(f"> {line}")
sections.append(f"")
raw_lines.append(f"> {line}")
raw_lines.append(f"")
else:
sections.append(f"_No new releases this week._")
sections.append(f"")
raw_lines.append(f"_No new releases this week._")
raw_lines.append(f"")

# --- Issues ---
sections.append(f"### 🐛 重要 Issues ({len(issues)})")
sections.append(f"")
raw_lines.append(f"### 🐛 重要 Issues ({len(issues)})")
raw_lines.append(f"")
if issues:
for i in issues:
num = i.get("number")
Expand All @@ -356,13 +440,20 @@ def build_report(
)
label_str = f" [{labels}]" if labels else ""
state_emoji = "🟢" if state == "open" else "🔴"
sections.append(
raw_lines.append(
f"- {state_emoji} [#{num} {title}]({html_url}){label_str} – {created}"
)
sections.append(f"")
raw_lines.append(f"")
else:
sections.append(f"_No new issues this week._")
raw_lines.append(f"_No new issues this week._")
raw_lines.append(f"")

if use_llm:
print(f"🤖 Summarising {full_name} with LLM ...")
sections.append(summarize_with_llm("\n".join(raw_lines), llm_config, llm_api_key)) # type: ignore[arg-type]
sections.append(f"")
else:
sections.extend(raw_lines)

# --- Commit stats ---
total = commits["total"]
Expand Down Expand Up @@ -468,6 +559,7 @@ def main() -> None:
"important_labels", ["bug", "enhancement", "breaking change", "priority/high"]
)
ai_summary_enabled: bool = bool(config.get("ai_summary", False))
llm_config: dict | None = config.get("llm") or None

# Allow env var to override lookback_days (used by workflow_dispatch)
if os.environ.get("LOOKBACK_DAYS"):
Expand All @@ -490,10 +582,18 @@ def main() -> None:

print(f"Fetching activity since {since.isoformat()} ...")

llm_api_key = os.environ.get("OPENAI_API_KEY", "")
if llm_api_key and llm_config is not None:
print(f"🤖 LLM summarization enabled (model: {llm_config.get('model', DEFAULT_LLM_MODEL)})")
else:
print("ℹ️ LLM summarization disabled – set OPENAI_API_KEY and configure 'llm' in config.json to enable.")

report_body = build_report(
watch_repos, token, since,
important_labels=important_labels or None,
ai_summary=ai_summary_enabled,
llm_config=llm_config,
llm_api_key=llm_api_key,
)

now = datetime.now(timezone.utc)
Expand Down
101 changes: 101 additions & 0 deletions tests/test_watch_dog.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

from watch_dog import (
_build_llm_prompt,
_parse_dt,
build_report,
fetch_commit_stats,
Expand All @@ -22,6 +23,7 @@
generate_ai_summary,
load_state,
save_state,
summarize_with_llm,
)


Expand Down Expand Up @@ -550,3 +552,102 @@ def fake_build(watch_repos, token, since, **kwargs):
# 'since' should be clamped to ~14 days ago (not 30)
diff = datetime.now(timezone.utc) - captured["since"]
assert 13 <= diff.days <= 15


# ---------------------------------------------------------------------------
# LLM summarization
# ---------------------------------------------------------------------------


def test_build_llm_prompt_contains_sections():
prompt = _build_llm_prompt("some raw content", "React, Node.js", "breaking changes")
assert "🔥" in prompt
assert "✨" in prompt
assert "🛠️" in prompt
assert "💡" in prompt
assert "React, Node.js" in prompt
assert "breaking changes" in prompt
assert "some raw content" in prompt


def _make_llm_response(content: str):
mock = MagicMock()
mock.json.return_value = {"choices": [{"message": {"content": content}}]}
mock.raise_for_status = MagicMock()
return mock


@patch("watch_dog.requests.post")
def test_summarize_with_llm_success(mock_post):
mock_post.return_value = _make_llm_response("🔥 重大變更\n✨ 重點新功能")

llm_config = {"model": "gpt-4o-mini", "tech_stack": "Python", "focus_areas": "security"}
result = summarize_with_llm("raw data", llm_config, "fake-key")

assert "🔥 重大變更" in result
assert "✨ 重點新功能" in result
mock_post.assert_called_once()
call_kwargs = mock_post.call_args
assert "Bearer fake-key" in call_kwargs[1]["headers"]["Authorization"]


@patch("watch_dog.requests.post")
def test_summarize_with_llm_fallback_on_error(mock_post):
import requests as req
mock_post.side_effect = req.exceptions.ConnectionError("network error")

result = summarize_with_llm("raw data", {}, "fake-key")
assert result == "raw data"


@patch("watch_dog.requests.get")
@patch("watch_dog.requests.post")
def test_build_report_with_llm(mock_post, mock_get):
since = datetime(2024, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
watch_repos = [{"owner": "example", "repo": "repo", "description": "Test repo"}]

mock_get.side_effect = [
_make_response([MOCK_PR]), # merged PRs page 1
_make_response([]), # merged PRs page 2
_make_response([MOCK_RELEASE]), # releases page 1
_make_response([]), # releases page 2
_make_response([MOCK_ISSUE]), # issues page 1
_make_response([]), # issues page 2
_make_response([MOCK_COMMIT]), # commits page 1
_make_response([]), # commits page 2
]

llm_summary = "🔥 重大變更: 無\n✨ 重點新功能: 新增功能\n🛠️ 效能與修復: 修復 bug\n💡 專家建議: 立刻更新"
mock_post.return_value = _make_llm_response(llm_summary)

llm_config = {"model": "gpt-4o-mini", "tech_stack": "Python", "focus_areas": "breaking changes"}
report = build_report(watch_repos, token="fake", since=since, llm_config=llm_config, llm_api_key="fake-key")

assert "RepoWatchDog Weekly Summary" in report
assert "example/repo" in report
assert "🔥 重大變更" in report
assert "💡 專家建議" in report
mock_post.assert_called_once()


@patch("watch_dog.requests.get")
def test_build_report_without_llm_key_uses_raw(mock_get):
"""When no LLM key is provided, the raw markdown report is used."""
since = datetime(2024, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
watch_repos = [{"owner": "example", "repo": "repo", "description": "Test repo"}]

mock_get.side_effect = [
_make_response([]), # merged PRs – empty
_make_response([MOCK_RELEASE]), # releases page 1
_make_response([]), # releases page 2
_make_response([MOCK_ISSUE]), # issues page 1
_make_response([]), # issues page 2
_make_response([]), # commits – empty
]

llm_config = {"model": "gpt-4o-mini"}
report = build_report(watch_repos, token="fake", since=since, llm_config=llm_config, llm_api_key="")

# Raw release and issue info should appear
assert "v1.2.3" in report
assert "Something is broken" in report