@openhands/extensions 0.1.0 → 0.2.0
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.
- package/.agents/skills/custom-codereview-guide.md +25 -0
- package/.github/pull_request_template.md +38 -0
- package/.github/release.yml +14 -0
- package/.github/workflows/check-extensions.yml +72 -0
- package/.github/workflows/npm-publish.yml +89 -0
- package/.github/workflows/pr.yml +30 -0
- package/.github/workflows/release.yml +24 -0
- package/.github/workflows/tests.yml +25 -0
- package/.github/workflows/vulnerability-scan.yml +87 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +132 -0
- package/README.md +10 -0
- package/analysis_results.md +162 -0
- package/marketplaces/large-codebase.json +66 -0
- package/marketplaces/openhands-extensions.json +682 -0
- package/package.json +4 -10
- package/plugins/README.md +30 -0
- package/plugins/city-weather/.plugin/plugin.json +13 -0
- package/plugins/city-weather/README.md +145 -0
- package/plugins/city-weather/commands/now.md +56 -0
- package/plugins/cobol-modernization/.plugin/plugin.json +19 -0
- package/plugins/cobol-modernization/README.md +201 -0
- package/plugins/cobol-modernization/references/troubleshooting.md +18 -0
- package/plugins/cobol-modernization/skills/build-setup/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/build-setup/scripts/install-gnucobol.sh +32 -0
- package/plugins/cobol-modernization/skills/cobol-modernization-overview/SKILL.md +113 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/SKILL.md +62 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/references/cics-transformation-examples.md +45 -0
- package/plugins/cobol-modernization/skills/mainframe-planning/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/to-java-migration/SKILL.md +59 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/cobol-to-java-example.md +58 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/datatype-mappings.md +19 -0
- package/plugins/issue-duplicate-checker/.plugin/plugin.json +13 -0
- package/plugins/issue-duplicate-checker/README.md +51 -0
- package/plugins/issue-duplicate-checker/action.yml +349 -0
- package/plugins/issue-duplicate-checker/scripts/auto_close_duplicate_issues.py +569 -0
- package/plugins/issue-duplicate-checker/scripts/issue_duplicate_check_openhands.py +681 -0
- package/plugins/issue-duplicate-checker/scripts/post_duplicate_notice.js +220 -0
- package/plugins/issue-duplicate-checker/scripts/remove_duplicate_candidate_label.js +27 -0
- package/plugins/magic-test/.plugin/plugin.json +13 -0
- package/plugins/magic-test/skills/magic-word/SKILL.md +33 -0
- package/plugins/migration-scoring/.plugin/plugin.json +19 -0
- package/plugins/migration-scoring/README.md +244 -0
- package/plugins/migration-scoring/skills/migration-mapping/SKILL.md +72 -0
- package/plugins/migration-scoring/skills/migration-report/SKILL.md +118 -0
- package/plugins/migration-scoring/skills/migration-scoring-overview/SKILL.md +126 -0
- package/plugins/migration-scoring/skills/score-quality/SKILL.md +54 -0
- package/plugins/migration-scoring/skills/score-quality/references/scoring-criteria.md +30 -0
- package/plugins/migration-scoring/skills/score-style/SKILL.md +106 -0
- package/plugins/onboarding/.plugin/plugin.json +20 -0
- package/plugins/onboarding/README.md +30 -0
- package/plugins/onboarding/references/criteria.md +144 -0
- package/plugins/onboarding/skills/agent-readiness-report/README.md +23 -0
- package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +122 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_agent_instructions.sh +88 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_build_env.sh +114 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_feedback_loops.sh +133 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_policy.sh +113 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_workflows.sh +127 -0
- package/plugins/onboarding/skills/improve-agent-readiness/README.md +19 -0
- package/plugins/onboarding/skills/improve-agent-readiness/SKILL.md +167 -0
- package/plugins/onboarding/skills/setup-agents-md/README.md +15 -0
- package/plugins/onboarding/skills/setup-agents-md/SKILL.md +150 -0
- package/plugins/onboarding/skills/setup-openhands/README.md +20 -0
- package/plugins/onboarding/skills/setup-openhands/SKILL.md +56 -0
- package/plugins/onboarding/skills/setup-pr-review/README.md +23 -0
- package/plugins/onboarding/skills/setup-pr-review/SKILL.md +72 -0
- package/plugins/openhands/.plugin/plugin.json +13 -0
- package/plugins/openhands/README.md +52 -0
- package/plugins/openhands/SKILL.md +61 -0
- package/plugins/openhands/commands/create.md +55 -0
- package/plugins/openhands/commands/openhands-cloud.md +8 -0
- package/plugins/openhands/scripts/run.sh +69 -0
- package/plugins/pr-review/.plugin/plugin.json +13 -0
- package/plugins/pr-review/README.md +393 -0
- package/plugins/pr-review/action.yml +298 -0
- package/plugins/pr-review/scripts/agent_script.py +1282 -0
- package/plugins/pr-review/scripts/evaluate_review.py +655 -0
- package/plugins/pr-review/scripts/prompt.py +260 -0
- package/plugins/pr-review/workflows/pr-review-by-openhands.yml +51 -0
- package/plugins/pr-review/workflows/pr-review-evaluation.yml +85 -0
- package/plugins/qa-changes/.plugin/plugin.json +11 -0
- package/plugins/qa-changes/README.md +185 -0
- package/plugins/qa-changes/action.yml +181 -0
- package/plugins/qa-changes/scripts/agent_script.py +406 -0
- package/plugins/qa-changes/scripts/evaluate_qa_changes.py +385 -0
- package/plugins/qa-changes/scripts/prompt.py +174 -0
- package/plugins/qa-changes/workflows/qa-changes-by-openhands.yml +50 -0
- package/plugins/qa-changes/workflows/qa-changes-evaluation.yml +85 -0
- package/plugins/release-notes/.plugin/plugin.json +19 -0
- package/plugins/release-notes/README.md +283 -0
- package/plugins/release-notes/SKILL.md +83 -0
- package/plugins/release-notes/action.yml +117 -0
- package/plugins/release-notes/commands/release-notes.md +8 -0
- package/plugins/release-notes/scripts/agent_script.py +292 -0
- package/plugins/release-notes/scripts/generate_release_notes.py +733 -0
- package/plugins/release-notes/scripts/prompt.py +90 -0
- package/plugins/release-notes/scripts/validate_release_notes.py +328 -0
- package/plugins/release-notes/workflows/release-notes.yml +76 -0
- package/plugins/vulnerability-remediation/.plugin/plugin.json +19 -0
- package/plugins/vulnerability-remediation/README.md +217 -0
- package/plugins/vulnerability-remediation/action.yml +187 -0
- package/plugins/vulnerability-remediation/scripts/scan_and_remediate.py +561 -0
- package/plugins/vulnerability-remediation/workflows/vulnerability-scan.yml +87 -0
- package/pyproject.toml +12 -0
- package/release-please-config.json +16 -0
- package/scripts/sync_extensions.py +494 -0
- package/scripts/sync_openhands_sdk_skill.py +264 -0
- package/skills/README.md +159 -0
- package/skills/add-javadoc/.plugin/plugin.json +18 -0
- package/skills/add-javadoc/README.md +40 -0
- package/skills/add-javadoc/SKILL.md +35 -0
- package/skills/add-javadoc/references/example.md +32 -0
- package/skills/add-skill/.plugin/plugin.json +18 -0
- package/skills/add-skill/README.md +67 -0
- package/skills/add-skill/SKILL.md +47 -0
- package/skills/add-skill/scripts/fetch_skill.py +259 -0
- package/skills/agent-creator/.plugin/plugin.json +20 -0
- package/skills/agent-creator/README.md +104 -0
- package/skills/agent-creator/SKILL.md +190 -0
- package/skills/agent-creator/commands/agent-creator.md +8 -0
- package/skills/agent-creator/references/fallback.md +117 -0
- package/skills/agent-memory/.plugin/plugin.json +18 -0
- package/skills/agent-memory/README.md +35 -0
- package/skills/agent-memory/SKILL.md +30 -0
- package/skills/agent-memory/commands/remember.md +8 -0
- package/skills/agent-sdk-builder/.plugin/plugin.json +18 -0
- package/skills/agent-sdk-builder/README.md +40 -0
- package/skills/agent-sdk-builder/SKILL.md +37 -0
- package/skills/agent-sdk-builder/commands/agent-builder.md +8 -0
- package/skills/azure-devops/.plugin/plugin.json +18 -0
- package/skills/azure-devops/README.md +55 -0
- package/skills/azure-devops/SKILL.md +50 -0
- package/skills/bitbucket/.plugin/plugin.json +17 -0
- package/skills/bitbucket/README.md +50 -0
- package/skills/bitbucket/SKILL.md +45 -0
- package/skills/code-review/.plugin/plugin.json +19 -0
- package/skills/code-review/README.md +18 -0
- package/skills/code-review/SKILL.md +208 -0
- package/skills/code-review/commands/codereview-roasted.md +8 -0
- package/skills/code-review/commands/codereview.md +8 -0
- package/skills/code-review/references/risk-evaluation.md +41 -0
- package/skills/code-review/references/supply-chain-security.md +31 -0
- package/skills/code-simplifier/.plugin/plugin.json +21 -0
- package/skills/code-simplifier/README.md +30 -0
- package/skills/code-simplifier/SKILL.md +91 -0
- package/skills/code-simplifier/commands/simplify.md +8 -0
- package/skills/code-simplifier/references/code-quality-review.md +86 -0
- package/skills/code-simplifier/references/code-reuse-review.md +63 -0
- package/skills/code-simplifier/references/efficiency-review.md +81 -0
- package/skills/datadog/.plugin/plugin.json +19 -0
- package/skills/datadog/README.md +100 -0
- package/skills/datadog/SKILL.md +95 -0
- package/skills/deno/.plugin/plugin.json +18 -0
- package/skills/deno/README.md +5 -0
- package/skills/deno/SKILL.md +99 -0
- package/skills/deno/references/README.md +6 -0
- package/skills/discord/.plugin/plugin.json +18 -0
- package/skills/discord/README.md +31 -0
- package/skills/discord/SKILL.md +109 -0
- package/skills/discord/__init__.py +0 -0
- package/skills/discord/references/REFERENCE.md +78 -0
- package/skills/discord/scripts/__init__.py +0 -0
- package/skills/discord/scripts/_http.py +127 -0
- package/skills/discord/scripts/post_webhook.py +106 -0
- package/skills/discord/scripts/send_message.py +102 -0
- package/skills/docker/.plugin/plugin.json +17 -0
- package/skills/docker/README.md +34 -0
- package/skills/docker/SKILL.md +29 -0
- package/skills/evidence-based-citations/.plugin/plugin.json +20 -0
- package/skills/evidence-based-citations/README.md +31 -0
- package/skills/evidence-based-citations/SKILL.md +59 -0
- package/skills/flarglebargle/.plugin/plugin.json +16 -0
- package/skills/flarglebargle/README.md +14 -0
- package/skills/flarglebargle/SKILL.md +9 -0
- package/skills/frontend-design/.plugin/plugin.json +21 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/README.md +42 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/github/.plugin/plugin.json +19 -0
- package/skills/github/README.md +42 -0
- package/skills/github/SKILL.md +106 -0
- package/skills/github-pr-review/.plugin/plugin.json +18 -0
- package/skills/github-pr-review/README.md +145 -0
- package/skills/github-pr-review/SKILL.md +148 -0
- package/skills/github-pr-review/commands/github-pr-review.md +8 -0
- package/skills/github-pr-reviewer/.plugin/plugin.json +20 -0
- package/skills/github-pr-reviewer/README.md +34 -0
- package/skills/github-pr-reviewer/SKILL.md +89 -0
- package/skills/github-pr-reviewer/commands/pr-reviewer:setup.md +8 -0
- package/skills/github-repo-monitor/.plugin/plugin.json +22 -0
- package/skills/github-repo-monitor/README.md +70 -0
- package/skills/github-repo-monitor/SKILL.md +316 -0
- package/skills/github-repo-monitor/commands/github-monitor:poll.md +8 -0
- package/skills/github-repo-monitor/references/github-api.md +241 -0
- package/skills/github-repo-monitor/references/state-schema.md +160 -0
- package/skills/github-repo-monitor/scripts/main.py +915 -0
- package/skills/github-repo-monitor/tests/test_main.py +400 -0
- package/skills/gitlab/.plugin/plugin.json +17 -0
- package/skills/gitlab/README.md +37 -0
- package/skills/gitlab/SKILL.md +32 -0
- package/skills/incident-retrospective/.plugin/plugin.json +21 -0
- package/skills/incident-retrospective/README.md +34 -0
- package/skills/incident-retrospective/SKILL.md +98 -0
- package/skills/incident-retrospective/commands/incident-retro:setup.md +8 -0
- package/skills/iterate/.plugin/plugin.json +13 -0
- package/skills/iterate/README.md +25 -0
- package/skills/iterate/SKILL.md +399 -0
- package/skills/iterate/commands/babysit.md +8 -0
- package/skills/iterate/commands/iterate.md +8 -0
- package/skills/iterate/commands/verify.md +8 -0
- package/skills/iterate/references/heuristics.md +58 -0
- package/skills/iterate/references/verification.md +96 -0
- package/skills/jupyter/.plugin/plugin.json +18 -0
- package/skills/jupyter/README.md +55 -0
- package/skills/jupyter/SKILL.md +50 -0
- package/skills/kubernetes/.plugin/plugin.json +18 -0
- package/skills/kubernetes/README.md +53 -0
- package/skills/kubernetes/SKILL.md +48 -0
- package/skills/learn-from-code-review/.plugin/plugin.json +19 -0
- package/skills/learn-from-code-review/README.md +64 -0
- package/skills/learn-from-code-review/SKILL.md +186 -0
- package/skills/learn-from-code-review/commands/learn-from-reviews.md +8 -0
- package/skills/linear/.plugin/plugin.json +19 -0
- package/skills/linear/README.md +58 -0
- package/skills/linear/SKILL.md +213 -0
- package/skills/linear-triage/.plugin/plugin.json +21 -0
- package/skills/linear-triage/README.md +34 -0
- package/skills/linear-triage/SKILL.md +91 -0
- package/skills/linear-triage/commands/linear-triage:setup.md +8 -0
- package/skills/notion/.plugin/plugin.json +17 -0
- package/skills/notion/README.md +114 -0
- package/skills/notion/SKILL.md +109 -0
- package/skills/npm/.plugin/plugin.json +17 -0
- package/skills/npm/README.md +14 -0
- package/skills/npm/SKILL.md +9 -0
- package/skills/openhands-api/.plugin/plugin.json +22 -0
- package/skills/openhands-api/README.md +48 -0
- package/skills/openhands-api/SKILL.md +399 -0
- package/skills/openhands-api/references/README.md +33 -0
- package/skills/openhands-api/references/TROUBLESHOOTING.md +81 -0
- package/skills/openhands-api/references/example_prompt.md +12 -0
- package/skills/openhands-api/scripts/openhands_api.py +606 -0
- package/skills/openhands-api/scripts/openhands_api.ts +252 -0
- package/skills/openhands-automation/.plugin/plugin.json +19 -0
- package/skills/openhands-automation/README.md +89 -0
- package/skills/openhands-automation/SKILL.md +875 -0
- package/skills/openhands-automation/commands/automation:create.md +8 -0
- package/skills/openhands-automation/references/ab-testing.md +185 -0
- package/skills/openhands-automation/references/custom-automation.md +644 -0
- package/skills/openhands-sdk/.plugin/plugin.json +20 -0
- package/skills/openhands-sdk/README.md +22 -0
- package/skills/openhands-sdk/SKILL.md +229 -0
- package/skills/openhands-sdk/commands/sdk.md +8 -0
- package/skills/pdflatex/.plugin/plugin.json +18 -0
- package/skills/pdflatex/README.md +39 -0
- package/skills/pdflatex/SKILL.md +34 -0
- package/skills/prd/.plugin/plugin.json +19 -0
- package/skills/prd/README.md +28 -0
- package/skills/prd/SKILL.md +237 -0
- package/skills/prd/commands/prd.md +8 -0
- package/skills/qa-changes/README.md +18 -0
- package/skills/qa-changes/SKILL.md +229 -0
- package/skills/qa-changes/commands/qa-changes.md +8 -0
- package/skills/release-notes/README.md +24 -0
- package/skills/release-notes/SKILL.md +19 -0
- package/skills/release-notes/commands/release-notes.md +8 -0
- package/skills/research-brief/.plugin/plugin.json +20 -0
- package/skills/research-brief/README.md +34 -0
- package/skills/research-brief/SKILL.md +99 -0
- package/skills/research-brief/commands/research-brief:setup.md +8 -0
- package/skills/security/.plugin/plugin.json +18 -0
- package/skills/security/README.md +38 -0
- package/skills/security/SKILL.md +33 -0
- package/skills/skill-creator/.plugin/plugin.json +17 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/README.md +182 -0
- package/skills/skill-creator/SKILL.md +545 -0
- package/skills/skill-creator/references/output-patterns.md +82 -0
- package/skills/skill-creator/references/workflows.md +28 -0
- package/skills/skill-creator/scripts/init_skill.py +303 -0
- package/skills/skill-creator/scripts/quick_validate.py +95 -0
- package/skills/slack-channel-monitor/.plugin/plugin.json +21 -0
- package/skills/slack-channel-monitor/README.md +91 -0
- package/skills/slack-channel-monitor/SKILL.md +276 -0
- package/skills/slack-channel-monitor/commands/slack-monitor:poll.md +8 -0
- package/skills/slack-channel-monitor/references/slack-api.md +207 -0
- package/skills/slack-channel-monitor/references/state-schema.md +180 -0
- package/skills/slack-channel-monitor/scripts/main.py +962 -0
- package/skills/slack-standup-digest/.plugin/plugin.json +21 -0
- package/skills/slack-standup-digest/README.md +34 -0
- package/skills/slack-standup-digest/SKILL.md +92 -0
- package/skills/slack-standup-digest/commands/standup-digest:setup.md +8 -0
- package/skills/spark-version-upgrade/.plugin/plugin.json +20 -0
- package/skills/spark-version-upgrade/README.md +54 -0
- package/skills/spark-version-upgrade/SKILL.md +233 -0
- package/skills/ssh/.plugin/plugin.json +18 -0
- package/skills/ssh/README.md +140 -0
- package/skills/ssh/SKILL.md +135 -0
- package/skills/swift-linux/.plugin/plugin.json +17 -0
- package/skills/swift-linux/README.md +86 -0
- package/skills/swift-linux/SKILL.md +81 -0
- package/skills/theme-factory/.plugin/plugin.json +19 -0
- package/skills/theme-factory/LICENSE.txt +202 -0
- package/skills/theme-factory/README.md +58 -0
- package/skills/theme-factory/SKILL.md +59 -0
- package/skills/theme-factory/theme-showcase.pdf +0 -0
- package/skills/theme-factory/themes/arctic-frost.md +19 -0
- package/skills/theme-factory/themes/botanical-garden.md +19 -0
- package/skills/theme-factory/themes/desert-rose.md +19 -0
- package/skills/theme-factory/themes/forest-canopy.md +19 -0
- package/skills/theme-factory/themes/golden-hour.md +19 -0
- package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
- package/skills/theme-factory/themes/modern-minimalist.md +19 -0
- package/skills/theme-factory/themes/ocean-depths.md +19 -0
- package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
- package/skills/theme-factory/themes/tech-innovation.md +19 -0
- package/skills/uv/.plugin/plugin.json +18 -0
- package/skills/uv/README.md +5 -0
- package/skills/uv/SKILL.md +95 -0
- package/skills/uv/references/README.md +5 -0
- package/skills/vercel/.plugin/plugin.json +18 -0
- package/skills/vercel/README.md +108 -0
- package/skills/vercel/SKILL.md +103 -0
- package/tests/test_add_skill_installs_to_agents_dir.py +42 -0
- package/tests/test_catalogs.py +109 -0
- package/tests/test_code_review_risk_evaluation.py +94 -0
- package/tests/test_issue_duplicate_checker.py +240 -0
- package/tests/test_openhands_api_python.py +152 -0
- package/tests/test_plugin_manifest.py +83 -0
- package/tests/test_pr_review_diff_payload.py +202 -0
- package/tests/test_pr_review_feedback.py +263 -0
- package/tests/test_pr_review_prompt.py +152 -0
- package/tests/test_pr_review_review_context.py +253 -0
- package/tests/test_qa_changes.py +232 -0
- package/tests/test_qa_changes_evaluation.py +259 -0
- package/tests/test_release_notes_generator.py +990 -0
- package/tests/test_sdk_loading.py +150 -0
- package/tests/test_skill_plugin_loading.py +149 -0
- package/tests/test_skills_have_readme.py +66 -0
- package/tests/test_sync_extensions.py +292 -0
- package/tests/test_workflow_sync.py +46 -0
- package/utils/analysis/README.md +7 -0
- package/utils/analysis/laminar_signals/README.md +211 -0
- package/utils/analysis/laminar_signals/analyze.py +780 -0
- package/utils/analysis/laminar_signals/templates/default.j2 +49 -0
- package/utils/analysis/laminar_signals/templates/pr_review.j2 +61 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Release Notes Generator
|
|
4
|
+
|
|
5
|
+
Generates consistent, well-structured release notes from git history.
|
|
6
|
+
Categorizes changes based on conventional commit prefixes and PR labels.
|
|
7
|
+
|
|
8
|
+
Environment Variables:
|
|
9
|
+
GITHUB_TOKEN: GitHub token for API access (required)
|
|
10
|
+
TAG: The release tag to generate notes for (required)
|
|
11
|
+
PREVIOUS_TAG: Override automatic detection of previous release (optional)
|
|
12
|
+
INCLUDE_INTERNAL: Include internal/infrastructure changes (default: false)
|
|
13
|
+
OUTPUT_FORMAT: Output format - 'release' or 'changelog' (default: release)
|
|
14
|
+
REPO_NAME: Repository name in format owner/repo (required)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# Category definitions with emojis and patterns
|
|
29
|
+
# Patterns match both conventional commit style (feat:, fix:) and bracket/paren style ([Feat]:, (Fix):)
|
|
30
|
+
CATEGORIES = {
|
|
31
|
+
"breaking": {
|
|
32
|
+
"emoji": "⚠️",
|
|
33
|
+
"title": "Breaking Changes",
|
|
34
|
+
"commit_patterns": [r"^BREAKING[\s\-:]", r"!:", r"^\[?BREAKING\]?[\s\-:]"],
|
|
35
|
+
"labels": ["breaking-change", "breaking"],
|
|
36
|
+
},
|
|
37
|
+
"features": {
|
|
38
|
+
"emoji": "✨",
|
|
39
|
+
"title": "New Features",
|
|
40
|
+
"commit_patterns": [
|
|
41
|
+
r"^feat(?:ure)?[\s\-:\(]",
|
|
42
|
+
r"^[\[\(]feat(?:ure)?[\]\)][\s\-:]*", # [Feat]: or (Feat):
|
|
43
|
+
],
|
|
44
|
+
"labels": ["enhancement", "feature"],
|
|
45
|
+
},
|
|
46
|
+
"fixes": {
|
|
47
|
+
"emoji": "🐛",
|
|
48
|
+
"title": "Bug Fixes",
|
|
49
|
+
"commit_patterns": [
|
|
50
|
+
r"^fix(?:es)?[\s\-:\(]",
|
|
51
|
+
r"^bugfix[\s\-:\(]",
|
|
52
|
+
r"^[\[\(]fix(?:es)?[\]\)][\s\-:]*", # [Fix]: or (Fix):
|
|
53
|
+
r"^[\[\(]hotfix[\]\)][\s\-:]*", # [Hotfix]: or (Hotfix):
|
|
54
|
+
r"^hotfix[\s\-:\(]",
|
|
55
|
+
],
|
|
56
|
+
"labels": ["bug", "bugfix"],
|
|
57
|
+
},
|
|
58
|
+
"docs": {
|
|
59
|
+
"emoji": "📚",
|
|
60
|
+
"title": "Documentation",
|
|
61
|
+
"commit_patterns": [
|
|
62
|
+
r"^docs?[\s\-:\(]",
|
|
63
|
+
r"^[\[\(]docs?[\]\)][\s\-:]*", # [Docs]: or (Docs):
|
|
64
|
+
],
|
|
65
|
+
"labels": ["documentation", "docs"],
|
|
66
|
+
},
|
|
67
|
+
"internal": {
|
|
68
|
+
"emoji": "🏗️",
|
|
69
|
+
"title": "Internal/Infrastructure",
|
|
70
|
+
"commit_patterns": [
|
|
71
|
+
r"^chore[\s\-:\(]",
|
|
72
|
+
r"^ci[\s\-:\(]",
|
|
73
|
+
r"^refactor[\s\-:\(]",
|
|
74
|
+
r"^test[\s\-:\(]",
|
|
75
|
+
r"^build[\s\-:\(]",
|
|
76
|
+
r"^style[\s\-:\(]",
|
|
77
|
+
r"^perf[\s\-:\(]",
|
|
78
|
+
r"^[\[\(]chore[\]\)][\s\-:]*", # [Chore]: or (Chore):
|
|
79
|
+
r"^[\[\(]ci[\]\)][\s\-:]*",
|
|
80
|
+
r"^[\[\(]refactor[\]\)][\s\-:]*",
|
|
81
|
+
r"^[\[\(]test[\]\)][\s\-:]*",
|
|
82
|
+
],
|
|
83
|
+
"labels": ["internal", "chore", "ci", "dependencies"],
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
KEYWORD_PATTERNS = {
|
|
88
|
+
"docs": [
|
|
89
|
+
r"\bdocs?\b",
|
|
90
|
+
r"\bdocumentation\b",
|
|
91
|
+
r"\breadme\b",
|
|
92
|
+
r"\bchangelog\b",
|
|
93
|
+
r"\bguide\b",
|
|
94
|
+
r"\bopenapi\b",
|
|
95
|
+
],
|
|
96
|
+
"internal": [
|
|
97
|
+
r"\bci\b",
|
|
98
|
+
r"\blint\b",
|
|
99
|
+
r"\btyping\b",
|
|
100
|
+
r"\brefactor\b",
|
|
101
|
+
r"\bdebug\b",
|
|
102
|
+
r"\bpre-commit\b",
|
|
103
|
+
r"\bdockerfile\b",
|
|
104
|
+
r"\bdependencies?\b",
|
|
105
|
+
r"\brelease\b",
|
|
106
|
+
r"\btool descriptions?\b",
|
|
107
|
+
r"\bmicroagents?\b",
|
|
108
|
+
],
|
|
109
|
+
"fixes": [
|
|
110
|
+
r"\bfix(?:es|ed)?\b",
|
|
111
|
+
r"\bbug\b",
|
|
112
|
+
r"\berror\b",
|
|
113
|
+
r"\bfail(?:ed|ing)?\b",
|
|
114
|
+
r"\bissue\b",
|
|
115
|
+
r"\bcrash\b",
|
|
116
|
+
r"\btimeout\b",
|
|
117
|
+
r"\bleak\b",
|
|
118
|
+
r"\bmissing\b",
|
|
119
|
+
r"\berroneous\b",
|
|
120
|
+
r"\breconnect\b",
|
|
121
|
+
r"\breset\b",
|
|
122
|
+
],
|
|
123
|
+
"features": [
|
|
124
|
+
r"^(add|allow|support|enable|implement|introduce|create|provide|improve)\b",
|
|
125
|
+
],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Change:
|
|
131
|
+
"""Represents a single change/commit in the release."""
|
|
132
|
+
|
|
133
|
+
message: str
|
|
134
|
+
sha: str
|
|
135
|
+
author: str
|
|
136
|
+
pr_number: int | None = None
|
|
137
|
+
pr_labels: list[str] = field(default_factory=list)
|
|
138
|
+
body: str = ""
|
|
139
|
+
url: str = ""
|
|
140
|
+
author_type: str = ""
|
|
141
|
+
pr_created_at: str = ""
|
|
142
|
+
pr_merged_at: str = ""
|
|
143
|
+
category: str = "other"
|
|
144
|
+
|
|
145
|
+
def to_markdown(self, repo_name: str) -> str:
|
|
146
|
+
"""Format the change as a markdown list item."""
|
|
147
|
+
# Clean up the message - remove conventional commit prefix
|
|
148
|
+
# Supports multiple formats:
|
|
149
|
+
# - feat: message, fix(scope): message, feat!: breaking
|
|
150
|
+
# - [Feat]: message, (Fix): message, [Chore]: message
|
|
151
|
+
msg = self.message.strip()
|
|
152
|
+
for pattern in [
|
|
153
|
+
# Standard conventional commit: feat:, fix(scope):, feat!:
|
|
154
|
+
r"^(feat|fix|docs?|chore|ci|refactor|test|build|style|perf|BREAKING|hotfix)(\(.+?\))?!?:\s+",
|
|
155
|
+
# Bracket/paren style: [Feat]:, (Fix):, [Hotfix]:
|
|
156
|
+
r"^[\[\(](feat|fix|docs?|chore|ci|refactor|test|build|style|perf|BREAKING|hotfix)[\]\)][\s\-:]+",
|
|
157
|
+
]:
|
|
158
|
+
msg = re.sub(pattern, "", msg, flags=re.IGNORECASE)
|
|
159
|
+
msg = msg.strip()
|
|
160
|
+
|
|
161
|
+
# Capitalize first letter
|
|
162
|
+
if msg:
|
|
163
|
+
msg = msg[0].upper() + msg[1:]
|
|
164
|
+
|
|
165
|
+
# Add PR link if available and not already in the message
|
|
166
|
+
if self.pr_number:
|
|
167
|
+
pr_ref = f"#{self.pr_number}"
|
|
168
|
+
if pr_ref not in msg:
|
|
169
|
+
msg += f" ({pr_ref})"
|
|
170
|
+
|
|
171
|
+
# Add author
|
|
172
|
+
if self.author:
|
|
173
|
+
msg += f" @{self.author}"
|
|
174
|
+
|
|
175
|
+
return f"- {msg}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class Contributor:
|
|
180
|
+
"""Represents a contributor to the release."""
|
|
181
|
+
|
|
182
|
+
username: str
|
|
183
|
+
first_pr: int | None = None
|
|
184
|
+
is_new: bool = False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class ReleaseNotes:
|
|
189
|
+
"""Holds all data needed to generate release notes."""
|
|
190
|
+
|
|
191
|
+
tag: str
|
|
192
|
+
previous_tag: str
|
|
193
|
+
date: str
|
|
194
|
+
repo_name: str
|
|
195
|
+
commit_count: int = 0
|
|
196
|
+
changes: dict[str, list[Change]] = field(default_factory=dict)
|
|
197
|
+
contributors: list[Contributor] = field(default_factory=list)
|
|
198
|
+
new_contributors: list[Contributor] = field(default_factory=list)
|
|
199
|
+
|
|
200
|
+
def to_markdown(self, include_internal: bool = False) -> str:
|
|
201
|
+
"""Generate the full release notes markdown."""
|
|
202
|
+
lines = [f"## [{self.tag}] - {self.date}", ""]
|
|
203
|
+
|
|
204
|
+
# Order of categories to display
|
|
205
|
+
category_order = ["breaking", "features", "fixes", "docs"]
|
|
206
|
+
if include_internal:
|
|
207
|
+
category_order.append("internal")
|
|
208
|
+
|
|
209
|
+
# Add categorized changes
|
|
210
|
+
for category in category_order:
|
|
211
|
+
changes = self.changes.get(category, [])
|
|
212
|
+
if changes:
|
|
213
|
+
cat_info = CATEGORIES[category]
|
|
214
|
+
lines.append(f"### {cat_info['emoji']} {cat_info['title']}")
|
|
215
|
+
for change in changes:
|
|
216
|
+
lines.append(change.to_markdown(self.repo_name))
|
|
217
|
+
lines.append("")
|
|
218
|
+
|
|
219
|
+
# Add new contributors section
|
|
220
|
+
if self.new_contributors:
|
|
221
|
+
lines.append("### 👥 New Contributors")
|
|
222
|
+
for contrib in self.new_contributors:
|
|
223
|
+
pr_text = f" in #{contrib.first_pr}" if contrib.first_pr else ""
|
|
224
|
+
lines.append(f"- @{contrib.username} made their first contribution{pr_text}")
|
|
225
|
+
lines.append("")
|
|
226
|
+
|
|
227
|
+
# Add full changelog link
|
|
228
|
+
lines.append(
|
|
229
|
+
f"**Full Changelog**: https://github.com/{self.repo_name}/compare/"
|
|
230
|
+
f"{self.previous_tag}...{self.tag}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_env(name: str, default: str | None = None, required: bool = False) -> str:
|
|
237
|
+
"""Get an environment variable."""
|
|
238
|
+
value = os.getenv(name, default)
|
|
239
|
+
if required and not value:
|
|
240
|
+
print(f"Error: {name} environment variable is required")
|
|
241
|
+
sys.exit(1)
|
|
242
|
+
return value or ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def github_api_request(
|
|
246
|
+
endpoint: str,
|
|
247
|
+
token: str,
|
|
248
|
+
method: str = "GET",
|
|
249
|
+
data: dict[str, Any] | None = None,
|
|
250
|
+
) -> Any:
|
|
251
|
+
"""Make a request to the GitHub API."""
|
|
252
|
+
url = f"https://api.github.com{endpoint}"
|
|
253
|
+
request = urllib.request.Request(url, method=method)
|
|
254
|
+
request.add_header("Accept", "application/vnd.github+json")
|
|
255
|
+
request.add_header("Authorization", f"Bearer {token}")
|
|
256
|
+
request.add_header("X-GitHub-Api-Version", "2022-11-28")
|
|
257
|
+
|
|
258
|
+
if data:
|
|
259
|
+
request.add_header("Content-Type", "application/json")
|
|
260
|
+
request.data = json.dumps(data).encode("utf-8")
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
264
|
+
return json.loads(response.read().decode("utf-8"))
|
|
265
|
+
except urllib.error.HTTPError as e:
|
|
266
|
+
details = (e.read() or b"").decode("utf-8", errors="replace").strip()
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
f"GitHub API request failed: HTTP {e.code} {e.reason}. {details}"
|
|
269
|
+
) from e
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_tags(repo_name: str, token: str) -> list[dict[str, Any]]:
|
|
273
|
+
"""Get all tags from the repository, sorted by creation date."""
|
|
274
|
+
tags = []
|
|
275
|
+
page = 1
|
|
276
|
+
per_page = 100
|
|
277
|
+
|
|
278
|
+
while True:
|
|
279
|
+
endpoint = f"/repos/{repo_name}/tags?per_page={per_page}&page={page}"
|
|
280
|
+
page_tags = github_api_request(endpoint, token)
|
|
281
|
+
if not page_tags:
|
|
282
|
+
break
|
|
283
|
+
tags.extend(page_tags)
|
|
284
|
+
if len(page_tags) < per_page:
|
|
285
|
+
break
|
|
286
|
+
page += 1
|
|
287
|
+
|
|
288
|
+
return tags
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def find_previous_tag(
|
|
292
|
+
current_tag: str, tags: list[dict[str, Any]]
|
|
293
|
+
) -> str | None:
|
|
294
|
+
"""Find the previous release tag before the current one."""
|
|
295
|
+
# Filter to only semver tags (with optional pre-release/build metadata)
|
|
296
|
+
semver_pattern = re.compile(r"^v?\d+\.\d+\.\d+(?:[.-].*)?$")
|
|
297
|
+
semver_tags = [t for t in tags if semver_pattern.match(t["name"])]
|
|
298
|
+
|
|
299
|
+
# Find current tag index
|
|
300
|
+
current_idx = None
|
|
301
|
+
for i, tag in enumerate(semver_tags):
|
|
302
|
+
if tag["name"] == current_tag:
|
|
303
|
+
current_idx = i
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
if current_idx is None:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
# Return the next tag (which is the previous release since tags are sorted newest first)
|
|
310
|
+
if current_idx + 1 < len(semver_tags):
|
|
311
|
+
return semver_tags[current_idx + 1]["name"]
|
|
312
|
+
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def get_commits_between_tags(
|
|
317
|
+
repo_name: str, base_tag: str, head_tag: str, token: str
|
|
318
|
+
) -> list[dict[str, Any]]:
|
|
319
|
+
"""Get all commits between two tags."""
|
|
320
|
+
endpoint = f"/repos/{repo_name}/compare/{base_tag}...{head_tag}"
|
|
321
|
+
response = github_api_request(endpoint, token)
|
|
322
|
+
commits = response.get("commits", [])
|
|
323
|
+
|
|
324
|
+
total_commits = response.get("total_commits")
|
|
325
|
+
if isinstance(total_commits, int) and total_commits > len(commits):
|
|
326
|
+
print(
|
|
327
|
+
"Warning: GitHub compare API truncated the commit list; "
|
|
328
|
+
"release notes may be incomplete.",
|
|
329
|
+
file=sys.stderr,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return commits
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_pr_for_commit(
|
|
336
|
+
repo_name: str, sha: str, token: str
|
|
337
|
+
) -> dict[str, Any] | None:
|
|
338
|
+
"""Get the PR associated with a commit (if any)."""
|
|
339
|
+
endpoint = f"/repos/{repo_name}/commits/{sha}/pulls"
|
|
340
|
+
try:
|
|
341
|
+
prs = github_api_request(endpoint, token)
|
|
342
|
+
if prs:
|
|
343
|
+
# Return the first merged PR
|
|
344
|
+
for pr in prs:
|
|
345
|
+
if pr.get("merged_at"):
|
|
346
|
+
return pr
|
|
347
|
+
# If no merged PR, return the first one
|
|
348
|
+
return prs[0]
|
|
349
|
+
except Exception as e:
|
|
350
|
+
# Log but don't fail - PR data is optional
|
|
351
|
+
print(f"Warning: Could not fetch PR for commit {sha[:7]}: {e}", file=sys.stderr)
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _matches_any_pattern(text: str, patterns: list[str]) -> bool:
|
|
356
|
+
"""Return True if the text matches any of the provided regex patterns."""
|
|
357
|
+
return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def categorize_change(change: Change) -> str:
|
|
361
|
+
"""Determine the category for a change based on commit message and PR labels."""
|
|
362
|
+
# Exact matches first: conventional commit prefixes are the strongest signal.
|
|
363
|
+
for category, info in CATEGORIES.items():
|
|
364
|
+
if _matches_any_pattern(change.message, info["commit_patterns"]):
|
|
365
|
+
return category
|
|
366
|
+
|
|
367
|
+
# Strong keyword matches help suppress noisy internal-only PRs even when a
|
|
368
|
+
# repository applies broad labels like `bug` or `enhancement`.
|
|
369
|
+
for category in ["docs", "internal"]:
|
|
370
|
+
if _matches_any_pattern(change.message, KEYWORD_PATTERNS[category]):
|
|
371
|
+
return category
|
|
372
|
+
|
|
373
|
+
label_names = [label.lower() for label in change.pr_labels]
|
|
374
|
+
for category, info in CATEGORIES.items():
|
|
375
|
+
if any(label.lower() in label_names for label in info["labels"]):
|
|
376
|
+
return category
|
|
377
|
+
|
|
378
|
+
# Fallback heuristics make PR-title based release notes more useful while
|
|
379
|
+
# still preferring user-facing categories over noisy implementation details.
|
|
380
|
+
for category in ["fixes", "features"]:
|
|
381
|
+
if _matches_any_pattern(change.message, KEYWORD_PATTERNS[category]):
|
|
382
|
+
return category
|
|
383
|
+
|
|
384
|
+
return "other"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _parse_github_timestamp(timestamp: str) -> datetime:
|
|
388
|
+
"""Parse a GitHub timestamp into a timezone-aware datetime."""
|
|
389
|
+
return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _is_bot_author(author: str, author_type: str = "") -> bool:
|
|
393
|
+
"""Return True when the contributor is clearly a bot account."""
|
|
394
|
+
normalized_type = author_type.lower()
|
|
395
|
+
return normalized_type == "bot" or author.endswith("[bot]")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _search_merged_pull_requests_by_author(
|
|
399
|
+
repo_name: str, author: str, token: str
|
|
400
|
+
) -> list[dict[str, Any]]:
|
|
401
|
+
"""Return merged PRs in the repo authored by the given GitHub user."""
|
|
402
|
+
results: list[dict[str, Any]] = []
|
|
403
|
+
page = 1
|
|
404
|
+
query = f'repo:{repo_name} is:pr is:merged author:"{author}"'
|
|
405
|
+
encoded_query = urllib.parse.quote(query)
|
|
406
|
+
|
|
407
|
+
while True:
|
|
408
|
+
endpoint = (
|
|
409
|
+
f"/search/issues?q={encoded_query}&per_page=100&page={page}"
|
|
410
|
+
"&sort=created&order=asc"
|
|
411
|
+
)
|
|
412
|
+
response = github_api_request(endpoint, token)
|
|
413
|
+
items = response.get("items", [])
|
|
414
|
+
results.extend(items)
|
|
415
|
+
if len(items) < 100:
|
|
416
|
+
break
|
|
417
|
+
page += 1
|
|
418
|
+
|
|
419
|
+
return results
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def is_new_contributor(
|
|
423
|
+
author: str,
|
|
424
|
+
repo_name: str,
|
|
425
|
+
before_timestamp: str,
|
|
426
|
+
token: str,
|
|
427
|
+
current_pr_number: int | None = None,
|
|
428
|
+
author_type: str = "",
|
|
429
|
+
) -> bool:
|
|
430
|
+
"""Check whether a human author's earliest release PR is their first merged PR."""
|
|
431
|
+
if not author or _is_bot_author(author, author_type) or not before_timestamp:
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
threshold = _parse_github_timestamp(before_timestamp)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
for pr in _search_merged_pull_requests_by_author(repo_name, author, token):
|
|
438
|
+
if current_pr_number and pr.get("number") == current_pr_number:
|
|
439
|
+
continue
|
|
440
|
+
closed_at = pr.get("closed_at")
|
|
441
|
+
if not closed_at:
|
|
442
|
+
continue
|
|
443
|
+
if _parse_github_timestamp(closed_at) < threshold:
|
|
444
|
+
return False
|
|
445
|
+
return True
|
|
446
|
+
except Exception as e:
|
|
447
|
+
print(f"Warning: Could not check contributor history for {author}: {e}", file=sys.stderr)
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def get_tag_date(repo_name: str, tag: str, token: str) -> str:
|
|
452
|
+
"""Get the date when a tag was created."""
|
|
453
|
+
endpoint = f"/repos/{repo_name}/git/refs/tags/{tag}"
|
|
454
|
+
try:
|
|
455
|
+
ref = github_api_request(endpoint, token)
|
|
456
|
+
# Get the commit or tag object
|
|
457
|
+
obj_sha = ref["object"]["sha"]
|
|
458
|
+
obj_type = ref["object"]["type"]
|
|
459
|
+
|
|
460
|
+
if obj_type == "tag":
|
|
461
|
+
# Annotated tag - get the tag object
|
|
462
|
+
tag_endpoint = f"/repos/{repo_name}/git/tags/{obj_sha}"
|
|
463
|
+
tag_obj = github_api_request(tag_endpoint, token)
|
|
464
|
+
date_str = tag_obj["tagger"]["date"]
|
|
465
|
+
else:
|
|
466
|
+
# Lightweight tag - get the commit
|
|
467
|
+
commit_endpoint = f"/repos/{repo_name}/git/commits/{obj_sha}"
|
|
468
|
+
commit_obj = github_api_request(commit_endpoint, token)
|
|
469
|
+
date_str = commit_obj["committer"]["date"]
|
|
470
|
+
|
|
471
|
+
# Parse and format the date
|
|
472
|
+
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
473
|
+
return dt.strftime("%Y-%m-%d")
|
|
474
|
+
except Exception as e:
|
|
475
|
+
# Log but fall back to current date
|
|
476
|
+
print(f"Warning: Could not get tag date for {tag}: {e}", file=sys.stderr)
|
|
477
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _process_commit(
|
|
481
|
+
commit_data: dict[str, Any], repo_name: str, token: str
|
|
482
|
+
) -> Change | None:
|
|
483
|
+
"""Process a single commit into a Change object."""
|
|
484
|
+
sha = commit_data["sha"]
|
|
485
|
+
message = commit_data["commit"]["message"].split("\n")[0] # First line only
|
|
486
|
+
author = commit_data.get("author", {}).get("login", "")
|
|
487
|
+
|
|
488
|
+
# Skip merge commits
|
|
489
|
+
if message.lower().startswith("merge "):
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
# Get PR info
|
|
493
|
+
pr_number = None
|
|
494
|
+
pr_labels: list[str] = []
|
|
495
|
+
pr = get_pr_for_commit(repo_name, sha, token)
|
|
496
|
+
body = ""
|
|
497
|
+
url = ""
|
|
498
|
+
author_type = ""
|
|
499
|
+
pr_created_at = ""
|
|
500
|
+
pr_merged_at = ""
|
|
501
|
+
if pr:
|
|
502
|
+
pr_number = pr["number"]
|
|
503
|
+
pr_labels = [label["name"] for label in pr.get("labels", [])]
|
|
504
|
+
author = pr.get("user", {}).get("login", "") or author
|
|
505
|
+
author_type = pr.get("user", {}).get("type", "") or ""
|
|
506
|
+
message = pr.get("title") or message
|
|
507
|
+
body = pr.get("body") or ""
|
|
508
|
+
url = pr.get("html_url") or ""
|
|
509
|
+
pr_created_at = pr.get("created_at") or ""
|
|
510
|
+
pr_merged_at = pr.get("merged_at") or ""
|
|
511
|
+
|
|
512
|
+
return Change(
|
|
513
|
+
message=message,
|
|
514
|
+
sha=sha,
|
|
515
|
+
author=author,
|
|
516
|
+
pr_number=pr_number,
|
|
517
|
+
pr_labels=pr_labels,
|
|
518
|
+
body=body,
|
|
519
|
+
url=url,
|
|
520
|
+
author_type=author_type,
|
|
521
|
+
pr_created_at=pr_created_at,
|
|
522
|
+
pr_merged_at=pr_merged_at,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _dedupe_changes(changes_list: list[Change]) -> list[Change]:
|
|
527
|
+
"""Collapse multiple commits from the same PR into one release-note entry."""
|
|
528
|
+
deduped: list[Change] = []
|
|
529
|
+
seen_keys: set[str] = set()
|
|
530
|
+
|
|
531
|
+
for change in changes_list:
|
|
532
|
+
key = f"pr:{change.pr_number}" if change.pr_number else f"sha:{change.sha}"
|
|
533
|
+
if key in seen_keys:
|
|
534
|
+
continue
|
|
535
|
+
seen_keys.add(key)
|
|
536
|
+
deduped.append(change)
|
|
537
|
+
|
|
538
|
+
return deduped
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _categorize_changes(
|
|
542
|
+
changes_list: list[Change],
|
|
543
|
+
) -> dict[str, list[Change]]:
|
|
544
|
+
"""Categorize a list of changes by type."""
|
|
545
|
+
categorized: dict[str, list[Change]] = {cat: [] for cat in CATEGORIES}
|
|
546
|
+
categorized["other"] = []
|
|
547
|
+
|
|
548
|
+
for change in changes_list:
|
|
549
|
+
change.category = categorize_change(change)
|
|
550
|
+
if change.category in categorized:
|
|
551
|
+
categorized[change.category].append(change)
|
|
552
|
+
else:
|
|
553
|
+
categorized["other"].append(change)
|
|
554
|
+
|
|
555
|
+
return categorized
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _process_contributors(
|
|
559
|
+
changes_list: list[Change],
|
|
560
|
+
repo_name: str,
|
|
561
|
+
token: str,
|
|
562
|
+
) -> tuple[list[Contributor], list[Contributor]]:
|
|
563
|
+
"""Extract contributors and identify first-time human PR contributors."""
|
|
564
|
+
contributors: dict[str, Contributor] = {}
|
|
565
|
+
earliest_pr_by_author: dict[str, Change] = {}
|
|
566
|
+
new_contributors: list[Contributor] = []
|
|
567
|
+
|
|
568
|
+
for change in changes_list:
|
|
569
|
+
author = change.author
|
|
570
|
+
if not author:
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
contrib = contributors.get(author)
|
|
574
|
+
if contrib is None:
|
|
575
|
+
contrib = Contributor(username=author, first_pr=change.pr_number)
|
|
576
|
+
contributors[author] = contrib
|
|
577
|
+
elif contrib.first_pr is None and change.pr_number:
|
|
578
|
+
contrib.first_pr = change.pr_number
|
|
579
|
+
|
|
580
|
+
if not change.pr_number:
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
candidate_timestamp = change.pr_merged_at or change.pr_created_at
|
|
584
|
+
current = earliest_pr_by_author.get(author)
|
|
585
|
+
current_timestamp = (current.pr_merged_at or current.pr_created_at) if current else ""
|
|
586
|
+
if current is None or (
|
|
587
|
+
candidate_timestamp
|
|
588
|
+
and (not current_timestamp or candidate_timestamp < current_timestamp)
|
|
589
|
+
):
|
|
590
|
+
earliest_pr_by_author[author] = change
|
|
591
|
+
contrib.first_pr = change.pr_number
|
|
592
|
+
|
|
593
|
+
for author, contrib in contributors.items():
|
|
594
|
+
first_pr_change = earliest_pr_by_author.get(author)
|
|
595
|
+
if not first_pr_change:
|
|
596
|
+
continue
|
|
597
|
+
|
|
598
|
+
first_pr_timestamp = first_pr_change.pr_merged_at or first_pr_change.pr_created_at
|
|
599
|
+
if is_new_contributor(
|
|
600
|
+
author,
|
|
601
|
+
repo_name,
|
|
602
|
+
first_pr_timestamp,
|
|
603
|
+
token,
|
|
604
|
+
current_pr_number=first_pr_change.pr_number,
|
|
605
|
+
author_type=first_pr_change.author_type,
|
|
606
|
+
):
|
|
607
|
+
contrib.is_new = True
|
|
608
|
+
new_contributors.append(contrib)
|
|
609
|
+
|
|
610
|
+
return list(contributors.values()), new_contributors
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def generate_release_notes(
|
|
614
|
+
tag: str,
|
|
615
|
+
previous_tag: str | None,
|
|
616
|
+
repo_name: str,
|
|
617
|
+
token: str,
|
|
618
|
+
include_internal: bool = False,
|
|
619
|
+
) -> ReleaseNotes:
|
|
620
|
+
"""Generate release notes for the given tag."""
|
|
621
|
+
# Get all tags
|
|
622
|
+
tags = get_tags(repo_name, token)
|
|
623
|
+
|
|
624
|
+
# Find previous tag if not provided
|
|
625
|
+
if not previous_tag:
|
|
626
|
+
previous_tag = find_previous_tag(tag, tags)
|
|
627
|
+
|
|
628
|
+
if not previous_tag:
|
|
629
|
+
print(f"Warning: Could not find previous tag for {tag}")
|
|
630
|
+
previous_tag = tags[-1]["name"] if tags else "HEAD~100"
|
|
631
|
+
|
|
632
|
+
print(f"Generating release notes: {previous_tag} -> {tag}")
|
|
633
|
+
|
|
634
|
+
# Get tag date
|
|
635
|
+
tag_date = get_tag_date(repo_name, tag, token)
|
|
636
|
+
|
|
637
|
+
# Get commits between tags
|
|
638
|
+
commits = get_commits_between_tags(repo_name, previous_tag, tag, token)
|
|
639
|
+
print(f"Found {len(commits)} commits")
|
|
640
|
+
|
|
641
|
+
# Phase 1: Process commits into Change objects
|
|
642
|
+
raw_changes = [
|
|
643
|
+
change
|
|
644
|
+
for c in commits
|
|
645
|
+
if (change := _process_commit(c, repo_name, token)) is not None
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
# Phase 2: Collapse multiple commits from the same PR into a single entry.
|
|
649
|
+
changes = _dedupe_changes(raw_changes)
|
|
650
|
+
|
|
651
|
+
# Phase 3: Categorize changes
|
|
652
|
+
categorized = _categorize_changes(changes)
|
|
653
|
+
|
|
654
|
+
# Phase 4: Extract and identify contributors
|
|
655
|
+
contributors, new_contributors = _process_contributors(changes, repo_name, token)
|
|
656
|
+
|
|
657
|
+
return ReleaseNotes(
|
|
658
|
+
tag=tag,
|
|
659
|
+
previous_tag=previous_tag,
|
|
660
|
+
date=tag_date,
|
|
661
|
+
repo_name=repo_name,
|
|
662
|
+
commit_count=len(commits),
|
|
663
|
+
changes=categorized,
|
|
664
|
+
contributors=contributors,
|
|
665
|
+
new_contributors=new_contributors,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def set_github_output(name: str, value: str) -> None:
|
|
670
|
+
"""Set a GitHub Actions output variable."""
|
|
671
|
+
output_file = os.getenv("GITHUB_OUTPUT")
|
|
672
|
+
if output_file:
|
|
673
|
+
with open(output_file, "a") as f:
|
|
674
|
+
# Handle multiline values
|
|
675
|
+
if "\n" in value:
|
|
676
|
+
delimiter = f"EOF_{os.urandom(4).hex()}"
|
|
677
|
+
f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
|
|
678
|
+
else:
|
|
679
|
+
f.write(f"{name}={value}\n")
|
|
680
|
+
else:
|
|
681
|
+
print(f"::set-output name={name}::{value}")
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def main():
|
|
685
|
+
"""Main entry point."""
|
|
686
|
+
# Get configuration from environment
|
|
687
|
+
token = get_env("GITHUB_TOKEN", required=True)
|
|
688
|
+
tag = get_env("TAG", required=True)
|
|
689
|
+
previous_tag = get_env("PREVIOUS_TAG") or None
|
|
690
|
+
include_internal = get_env("INCLUDE_INTERNAL", "false").lower() == "true"
|
|
691
|
+
output_format = get_env("OUTPUT_FORMAT", "release")
|
|
692
|
+
repo_name = get_env("REPO_NAME", required=True)
|
|
693
|
+
|
|
694
|
+
print(f"Generating release notes for {repo_name}")
|
|
695
|
+
print(f"Tag: {tag}")
|
|
696
|
+
print(f"Previous tag: {previous_tag or 'auto-detect'}")
|
|
697
|
+
print(f"Include internal: {include_internal}")
|
|
698
|
+
print(f"Output format: {output_format}")
|
|
699
|
+
|
|
700
|
+
# Generate release notes
|
|
701
|
+
notes = generate_release_notes(
|
|
702
|
+
tag=tag,
|
|
703
|
+
previous_tag=previous_tag,
|
|
704
|
+
repo_name=repo_name,
|
|
705
|
+
token=token,
|
|
706
|
+
include_internal=include_internal,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Generate markdown
|
|
710
|
+
markdown = notes.to_markdown(include_internal=include_internal)
|
|
711
|
+
|
|
712
|
+
# Write to file
|
|
713
|
+
with open("release_notes.md", "w") as f:
|
|
714
|
+
f.write(markdown)
|
|
715
|
+
|
|
716
|
+
print("\n" + "=" * 60)
|
|
717
|
+
print("Generated Release Notes:")
|
|
718
|
+
print("=" * 60)
|
|
719
|
+
print(markdown)
|
|
720
|
+
print("=" * 60)
|
|
721
|
+
|
|
722
|
+
# Set GitHub Actions outputs
|
|
723
|
+
set_github_output("release_notes", markdown)
|
|
724
|
+
set_github_output("previous_tag", notes.previous_tag)
|
|
725
|
+
set_github_output("commit_count", str(notes.commit_count))
|
|
726
|
+
set_github_output("contributor_count", str(len(notes.contributors)))
|
|
727
|
+
set_github_output("new_contributor_count", str(len(notes.new_contributors)))
|
|
728
|
+
|
|
729
|
+
print("\nRelease notes generated successfully!")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
if __name__ == "__main__":
|
|
733
|
+
main()
|