mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +00:00
Merge remote-tracking branch 'origin/main' into staging
This commit is contained in:
commit
0d3a174f13
@ -241,6 +241,65 @@ Format details:
|
|||||||
- When an entry already exists in an earlier version section, it must be removed
|
- When an entry already exists in an earlier version section, it must be removed
|
||||||
from the current version to avoid duplicates
|
from the current version to avoid duplicates
|
||||||
|
|
||||||
|
### 6a. Pre-flight checks — fix rule violations in the changelog
|
||||||
|
|
||||||
|
**The LLM must apply these checks during the workflow and fix any
|
||||||
|
violations directly in `CHANGES.md`. They are not anomalies — they are
|
||||||
|
process errors that should be corrected before writing the new section.**
|
||||||
|
|
||||||
|
The changelog is a *snapshot* of the milestone at a point in time, but
|
||||||
|
milestones and changelog entries can drift. The LLM must reconcile the
|
||||||
|
existing changelog against the current state of the milestone and the
|
||||||
|
existing changelog entries.
|
||||||
|
|
||||||
|
For each entry that already exists in `CHANGES.md` (in any version
|
||||||
|
section) or in the candidate set for the current milestone, check:
|
||||||
|
|
||||||
|
1. **Duplicate across versions.** Is the same issue already documented
|
||||||
|
in another (older) version section? If yes, this is a *backport*:
|
||||||
|
- The user-facing fix was already released. Remove the duplicate
|
||||||
|
from the current section. The earlier version is the canonical
|
||||||
|
reference.
|
||||||
|
|
||||||
|
2. **Stale milestone assignment.** Has the issue been moved out of the
|
||||||
|
current milestone since the changelog was last updated (e.g., a fix
|
||||||
|
arrived late and the issue was reassigned to a future milestone)?
|
||||||
|
- Verify the issue is still in the current milestone via
|
||||||
|
`python3 tools/gh.py issues <MILESTONE> --state all`. If it's no
|
||||||
|
longer there, remove the entry from the current section. (If the
|
||||||
|
target section doesn't exist yet, the entry is simply dropped.)
|
||||||
|
|
||||||
|
3. **Exclusion labels newly applied.** Did the issue acquire a
|
||||||
|
`no changelog` or `release blocker` label since the changelog was
|
||||||
|
last updated? If yes, remove the entry from the current section.
|
||||||
|
|
||||||
|
4. **Issue state changed.** Is the issue still closed? Has it been
|
||||||
|
reopened, deleted, or moved to a `Rejected` project status? If yes,
|
||||||
|
remove the entry.
|
||||||
|
|
||||||
|
5. **Unmerged or removed PR references.** For every PR referenced in
|
||||||
|
the entry, is the PR still merged? Was the PR closed without
|
||||||
|
merging (superseded)? Was the PR moved to a different milestone?
|
||||||
|
If the only referenced PR is no longer merged, fix the reference
|
||||||
|
(find the actual merged fix PR) or remove the entry. A PR that is
|
||||||
|
merged in a *different* milestone is reported as an anomaly in
|
||||||
|
step 11 — do not silently remove it.
|
||||||
|
|
||||||
|
6. **Issue type changed.** Did the issue type change (e.g., from Bug to
|
||||||
|
Task)? If the new type is `Task`, the issue is internal and should
|
||||||
|
be removed.
|
||||||
|
|
||||||
|
7. **Cross-section completeness.** For every closed, non-excluded
|
||||||
|
milestone issue that is *not* referenced in any version section of
|
||||||
|
the changelog, add it to the current section (per the categorization
|
||||||
|
rules in step 5).
|
||||||
|
|
||||||
|
After these checks, the changelog should be internally consistent with
|
||||||
|
the milestone. **Do not defer these fixes to step 11 — they are
|
||||||
|
workflow errors, not anomalies.** Step 11 only reports milestone
|
||||||
|
mismatches that require human judgment about the team's release
|
||||||
|
intent.
|
||||||
|
|
||||||
### 7. Build the description text
|
### 7. Build the description text
|
||||||
|
|
||||||
Derive the description from the issue title, not the PR title. Strip leading
|
Derive the description from the issue title, not the PR title. Strip leading
|
||||||
@ -350,42 +409,98 @@ if closed:
|
|||||||
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
|
||||||
|
|
||||||
After all edits and cross-referencing are complete, generate a structured
|
After all edits and cross-referencing are complete, generate a structured
|
||||||
anomaly report and save it to `CHANGES-ISSUES.md` (overwriting if exists).
|
report and save it to `CHANGES-ISSUES.md` (overwriting if exists).
|
||||||
This provides a persistent record of any discrepancies between the milestone
|
This provides a persistent record of any discrepancies between the milestone
|
||||||
and the changelog.
|
and the changelog.
|
||||||
|
|
||||||
|
**Every issue and PR number in the report must be rendered as a full GitHub
|
||||||
|
Markdown link** using the same URL format as `CHANGES.md`:
|
||||||
|
- Issue N → `[#N](https://github.com/penpot/penpot/issues/N)`
|
||||||
|
- PR N → `[#N](https://github.com/penpot/penpot/pull/N)`
|
||||||
|
|
||||||
|
The titles and notes should also link to the corresponding issue/PR page
|
||||||
|
where applicable, so the report is self-contained and clickable from any
|
||||||
|
Markdown viewer.
|
||||||
|
|
||||||
|
## What is an anomaly
|
||||||
|
|
||||||
|
**An anomaly is a milestone-mismatch between an issue and its referenced
|
||||||
|
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
|
||||||
|
all in milestone M" is inconsistent with the actual milestone assignments.
|
||||||
|
There are exactly two types:
|
||||||
|
|
||||||
|
1. **Issue is in the milestone, but its referenced PR is in a different
|
||||||
|
milestone (or has no milestone).** The changelog claims a fix in this
|
||||||
|
release, but the PR is being released elsewhere — the fix may not
|
||||||
|
actually ship here.
|
||||||
|
2. **PR is in the milestone, but the issue it closes is in a different
|
||||||
|
milestone (or has no milestone).** The PR is being released here, but
|
||||||
|
the issue it fixes is being released in a different version (or never
|
||||||
|
tracked in a milestone) — the changelog pairing is misleading.
|
||||||
|
|
||||||
|
**Anything else is not an anomaly.** Other discrepancies (exclusion
|
||||||
|
labels on in-changelog issues, missing valid issues, unmerged PR
|
||||||
|
references, duplicates across versions, stale milestone assignments)
|
||||||
|
are **rule violations** that the LLM must fix directly in `CHANGES.md`
|
||||||
|
during step 6a (pre-flight checks). They should not appear in this
|
||||||
|
report — if they do, the LLM has skipped the pre-flight step and
|
||||||
|
needs to re-run the workflow.
|
||||||
|
|
||||||
|
The changelog's primary unit is the **issue**, not the PR, so a missing or
|
||||||
|
mismatched PR only matters when its issue is part of this milestone.
|
||||||
|
|
||||||
Run this self-contained script:
|
Run this self-contained script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 << 'PYEOF'
|
python3 << 'PYEOF'
|
||||||
import json, re, subprocess, sys
|
import json, re, subprocess, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
MILESTONE = "<MILESTONE>"
|
MILESTONE = "<MILESTONE>"
|
||||||
CHANGES_MD = "CHANGES.md"
|
CHANGES_MD = "CHANGES.md"
|
||||||
OUTPUT = "CHANGES-ISSUES.md"
|
OUTPUT = "CHANGES-ISSUES.md"
|
||||||
|
REPO = "penpot/penpot"
|
||||||
|
|
||||||
# Fetch milestone issues (all states)
|
# --- URL helpers (match CHANGES.md format exactly) ---
|
||||||
|
def issue_url(n): return f"https://github.com/{REPO}/issues/{n}"
|
||||||
|
def pr_url(n): return f"https://github.com/{REPO}/pull/{n}"
|
||||||
|
def issue_link(n): return f"[#{n}]({issue_url(n)})"
|
||||||
|
def pr_link(n): return f"[#{n}]({pr_url(n)})"
|
||||||
|
def issue_link_title(n, title):
|
||||||
|
url = issue_url(n)
|
||||||
|
if title:
|
||||||
|
return f"[#{n}]({url}) — [{title}]({url})"
|
||||||
|
return f"[#{n}]({url})"
|
||||||
|
def pr_link_title(n, title):
|
||||||
|
url = pr_url(n)
|
||||||
|
if title:
|
||||||
|
return f"[#{n}]({url}) — [{title}]({url})"
|
||||||
|
return f"[#{n}]({url})"
|
||||||
|
def fmt_pr_list(nums):
|
||||||
|
return ", ".join(pr_link(n) for n in nums)
|
||||||
|
def fmt_issue_list(nums):
|
||||||
|
return ", ".join(issue_link(n) for n in nums)
|
||||||
|
|
||||||
|
# --- Fetch milestone data ---
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
|
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
all_issues = json.loads(result.stdout)
|
all_issues = json.loads(result.stdout)
|
||||||
issue_by_num = {i['number']: i for i in all_issues}
|
issue_by_num = {i['number']: i for i in all_issues}
|
||||||
|
|
||||||
# Fetch milestone PRs (all states)
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
all_prs = json.loads(result.stdout)
|
all_prs = json.loads(result.stdout)
|
||||||
pr_by_num = {p['number']: p for p in all_prs}
|
pr_by_num = {p['number']: p for p in all_prs}
|
||||||
|
|
||||||
# Read changelog
|
# --- Read changelog section ---
|
||||||
with open(CHANGES_MD) as f:
|
with open(CHANGES_MD) as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
m = re.search(rf'## {MILESTONE} \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
|
m = re.search(rf'## {re.escape(MILESTONE)}(?:\s*\([^)]*\))?\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
|
||||||
section = m.group(1) if m else ""
|
section = m.group(1) if m else ""
|
||||||
|
|
||||||
# Collect issue and PR references from the changelog section
|
|
||||||
changelog_issues = set()
|
changelog_issues = set()
|
||||||
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)', section):
|
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)', section):
|
||||||
changelog_issues.add(int(num))
|
changelog_issues.add(int(num))
|
||||||
@ -398,166 +513,159 @@ for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)
|
|||||||
for num in re.findall(r'PR:\[(\d+)\]', section):
|
for num in re.findall(r'PR:\[(\d+)\]', section):
|
||||||
changelog_prs.add(int(num))
|
changelog_prs.add(int(num))
|
||||||
|
|
||||||
# Determine valid (non-excluded) milestone issues
|
# --- Milestone lookup caches ---
|
||||||
|
# PRs and issues returned by milestone queries are KNOWN to be in MILESTONE.
|
||||||
|
# For everything else, fall back to `gh` per-item lookups.
|
||||||
|
pr_milestone_cache = {p['number']: MILESTONE for p in all_prs}
|
||||||
|
issue_milestone_cache = {i['number']: MILESTONE for i in all_issues}
|
||||||
|
|
||||||
|
def get_pr_milestone(pr_num):
|
||||||
|
"""Return the milestone title for a PR, or None if unassigned / unknown."""
|
||||||
|
if pr_num in pr_milestone_cache:
|
||||||
|
return pr_milestone_cache[pr_num]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["gh", "pr", "view", str(pr_num), "--json", "milestone"],
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
ms = data.get('milestone')
|
||||||
|
pr_milestone_cache[pr_num] = (ms or {}).get('title')
|
||||||
|
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
||||||
|
pr_milestone_cache[pr_num] = None
|
||||||
|
return pr_milestone_cache[pr_num]
|
||||||
|
|
||||||
|
def get_issue_milestone(issue_num):
|
||||||
|
"""Return the milestone title for an issue, or None if unassigned / unknown."""
|
||||||
|
if issue_num in issue_milestone_cache:
|
||||||
|
return issue_milestone_cache[issue_num]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["gh", "issue", "view", str(issue_num), "--json", "milestone"],
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
ms = data.get('milestone')
|
||||||
|
issue_milestone_cache[issue_num] = (ms or {}).get('title')
|
||||||
|
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
||||||
|
issue_milestone_cache[issue_num] = None
|
||||||
|
return issue_milestone_cache[issue_num]
|
||||||
|
|
||||||
|
# --- Exclusion rules (shared) ---
|
||||||
EXCLUDED_LABELS = {'release blocker', 'no changelog'}
|
EXCLUDED_LABELS = {'release blocker', 'no changelog'}
|
||||||
EXCLUDED_ISSUE_TYPES = {'Task'}
|
EXCLUDED_ISSUE_TYPES = {'Task'}
|
||||||
EXCLUDED_PROJECT_STATUS = {'Rejected'}
|
EXCLUDED_PROJECT_STATUS = {'Rejected'}
|
||||||
|
|
||||||
valid_issues = []
|
def issue_excluded(issue):
|
||||||
for issue in all_issues:
|
if not issue: return True
|
||||||
labels = set(issue.get('labels', []))
|
if issue.get('state') != 'CLOSED': return True
|
||||||
if issue.get('state') != 'CLOSED': continue
|
if issue.get('issue_type') in EXCLUDED_ISSUE_TYPES: return True
|
||||||
if issue.get('issue_type') in EXCLUDED_ISSUE_TYPES: continue
|
if issue.get('project_status') in EXCLUDED_PROJECT_STATUS: return True
|
||||||
if issue.get('project_status') in EXCLUDED_PROJECT_STATUS: continue
|
if EXCLUDED_LABELS & set(issue.get('labels', [])): return True
|
||||||
if EXCLUDED_LABELS & labels: continue
|
return False
|
||||||
valid_issues.append(issue)
|
|
||||||
valid_nums = {i['number'] for i in valid_issues}
|
|
||||||
|
|
||||||
# --- Gather anomalies ---
|
# --- ANOMALIES: milestone mismatches between issues and their referenced PRs ---
|
||||||
anomalies = []
|
# These are the ONLY items that should appear in the report. All other
|
||||||
|
# discrepancies (exclusion labels, missing valid issues, unmerged PRs,
|
||||||
|
# duplicates, stale milestone assignments) are workflow errors that the
|
||||||
|
# LLM must fix in step 6a (pre-flight checks) — they are not anomalies.
|
||||||
|
|
||||||
# Type 1: Entries in changelog that should be excluded
|
# Type A: issue in MILESTONE, referenced PR in different milestone or no milestone
|
||||||
for num in sorted(changelog_issues):
|
anomalies_a = [] # list of dicts: {issue, issue_title, pr, pr_milestone}
|
||||||
issue = issue_by_num.get(num)
|
for issue_num in sorted(changelog_issues):
|
||||||
if issue is None:
|
issue = issue_by_num.get(issue_num)
|
||||||
anomalies.append({
|
if not issue: continue
|
||||||
'type': 'should_remove',
|
if get_issue_milestone(issue_num) != MILESTONE: continue
|
||||||
'severity': 'HIGH',
|
|
||||||
'number': num,
|
|
||||||
'title': '',
|
|
||||||
'reason': 'Issue not found in milestone (deleted or moved)'
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
labels = set(issue.get('labels', []))
|
|
||||||
reasons = []
|
|
||||||
if issue.get('state') != 'CLOSED':
|
|
||||||
reasons.append(f'state is "{issue["state"]}" (should be CLOSED)')
|
|
||||||
if 'release blocker' in labels:
|
|
||||||
reasons.append('has "release blocker" label')
|
|
||||||
if 'no changelog' in labels:
|
|
||||||
reasons.append('has "no changelog" label')
|
|
||||||
if issue.get('issue_type') == 'Task':
|
|
||||||
reasons.append(f'issue_type is Task (internal chore)')
|
|
||||||
if issue.get('project_status') == 'Rejected':
|
|
||||||
reasons.append('project_status is Rejected')
|
|
||||||
if reasons:
|
|
||||||
anomalies.append({
|
|
||||||
'type': 'should_remove',
|
|
||||||
'severity': 'MEDIUM' if issue.get('issue_type') == 'Task' else 'HIGH',
|
|
||||||
'number': num,
|
|
||||||
'title': issue.get('title', '')[:80],
|
|
||||||
'reason': '; '.join(reasons)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Type 2: Valid issues not in changelog
|
|
||||||
for num in sorted(valid_nums - changelog_issues):
|
|
||||||
issue = issue_by_num[num]
|
|
||||||
info = {
|
|
||||||
'type': 'missing',
|
|
||||||
'severity': 'MEDIUM',
|
|
||||||
'number': num,
|
|
||||||
'title': issue['title'][:80],
|
|
||||||
'issue_type': issue['issue_type'],
|
|
||||||
'closing_prs': issue.get('closing_prs', []),
|
|
||||||
'note': ''
|
|
||||||
}
|
|
||||||
# Check for duplicate (same PR as existing entry)
|
|
||||||
existing = []
|
|
||||||
for pr_num in issue.get('closing_prs', []):
|
for pr_num in issue.get('closing_prs', []):
|
||||||
for cl_num in changelog_issues:
|
pr_ms = get_pr_milestone(pr_num)
|
||||||
cl_issue = issue_by_num.get(cl_num)
|
if pr_ms != MILESTONE:
|
||||||
if cl_issue and pr_num in cl_issue.get('closing_prs', []):
|
anomalies_a.append({
|
||||||
existing.append(f'#{cl_num}')
|
'issue': issue_num,
|
||||||
if existing:
|
'issue_title': issue.get('title', ''),
|
||||||
info['note'] = f'DUPLICATE: same PR as existing entry(ies): {", ".join(existing)}'
|
'pr': pr_num,
|
||||||
# Check closing PRs not merged
|
'pr_milestone': pr_ms, # may be None
|
||||||
unmerged = []
|
})
|
||||||
for pr_num in issue.get('closing_prs', []):
|
|
||||||
pr = pr_by_num.get(pr_num)
|
|
||||||
if pr is None:
|
|
||||||
unmerged.append(f'#{pr_num} (unknown)')
|
|
||||||
elif pr.get('state') != 'MERGED':
|
|
||||||
unmerged.append(f'#{pr_num} (state={pr["state"]})')
|
|
||||||
if unmerged:
|
|
||||||
info['note'] = (info['note'] + '; ' if info['note'] else '') + f'Closing PRs not merged: {", ".join(unmerged)}'
|
|
||||||
anomalies.append(info)
|
|
||||||
|
|
||||||
# Type 3: PRs in changelog that are not merged
|
# Type B: PR in MILESTONE, the issue it closes is in different milestone or no milestone
|
||||||
|
anomalies_b = [] # list of dicts: {pr, pr_title, issue, issue_milestone}
|
||||||
for pr_num in sorted(changelog_prs):
|
for pr_num in sorted(changelog_prs):
|
||||||
pr = pr_by_num.get(pr_num)
|
pr = pr_by_num.get(pr_num)
|
||||||
if pr is None:
|
if not pr: continue
|
||||||
anomalies.append({
|
if get_pr_milestone(pr_num) != MILESTONE: continue
|
||||||
'type': 'unmerged_pr',
|
for issue_num in pr.get('closing_issues', []):
|
||||||
'severity': 'HIGH',
|
issue_ms = get_issue_milestone(issue_num)
|
||||||
'number': pr_num,
|
if issue_ms != MILESTONE:
|
||||||
'title': '',
|
anomalies_b.append({
|
||||||
'reason': 'PR not found in milestone PR list'
|
'pr': pr_num,
|
||||||
})
|
'pr_title': pr.get('title', ''),
|
||||||
elif pr.get('state') != 'MERGED':
|
'issue': issue_num,
|
||||||
anomalies.append({
|
'issue_milestone': issue_ms, # may be None
|
||||||
'type': 'unmerged_pr',
|
})
|
||||||
'severity': 'HIGH',
|
|
||||||
'number': pr_num,
|
# --- Write report ---
|
||||||
'title': pr.get('title', '')[:80],
|
def fmt_ms(ms):
|
||||||
'reason': f'state={pr["state"]} (should be MERGED)'
|
return ms if ms else "_none_"
|
||||||
})
|
|
||||||
|
|
||||||
# --- Write report to CHANGES-ISSUES.md ---
|
|
||||||
with open(OUTPUT, 'w') as f:
|
with open(OUTPUT, 'w') as f:
|
||||||
f.write(f'# Changelog Anomaly Report — {MILESTONE}\n\n')
|
f.write(f'# Changelog Anomaly Report — {MILESTONE}\n\n')
|
||||||
f.write(f'Generated: {__import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M UTC")}\n\n')
|
f.write(f'Generated: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}\n\n')
|
||||||
f.write('---\n\n')
|
f.write('---\n\n')
|
||||||
|
|
||||||
# Summary
|
n_a = len(anomalies_a)
|
||||||
n_remove = sum(1 for a in anomalies if a['type'] == 'should_remove')
|
n_b = len(anomalies_b)
|
||||||
n_missing = sum(1 for a in anomalies if a['type'] == 'missing')
|
|
||||||
n_pr = sum(1 for a in anomalies if a['type'] == 'unmerged_pr')
|
|
||||||
f.write(f'## Summary\n\n')
|
|
||||||
f.write(f'- **Issues to remove from changelog:** {n_remove}\n')
|
|
||||||
f.write(f'- **Valid issues missing from changelog:** {n_missing}\n')
|
|
||||||
f.write(f'- **Unmerged PRs referenced:** {n_pr}\n')
|
|
||||||
f.write(f'- **Total anomalies:** {len(anomalies)}\n\n')
|
|
||||||
|
|
||||||
if not anomalies:
|
f.write('## Summary\n\n')
|
||||||
f.write('✅ No anomalies found. The changelog is fully consistent with the milestone.\n\n')
|
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
|
||||||
else:
|
f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n')
|
||||||
# Type 1
|
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
|
||||||
if n_remove:
|
|
||||||
f.write(f'## Issues to Remove\n\n')
|
|
||||||
f.write('These entries are in the changelog but should be excluded based on current issue metadata.\n\n')
|
|
||||||
for a in anomalies:
|
|
||||||
if a['type'] != 'should_remove': continue
|
|
||||||
badge = '🔴' if a['severity'] == 'HIGH' else '🟡'
|
|
||||||
f.write(f'{badge} **#{a["number"]}**')
|
|
||||||
if a.get('title'): f.write(f' — {a["title"]}')
|
|
||||||
f.write(f'\n - Reason: {a["reason"]}\n\n')
|
|
||||||
|
|
||||||
# Type 2
|
# --- Anomalies section ---
|
||||||
if n_missing:
|
if n_a or n_b:
|
||||||
f.write(f'## Valid Issues Not in Changelog\n\n')
|
f.write('## Anomalies\n\n')
|
||||||
f.write('These issues are closed, non-excluded milestone items that lack a changelog entry.\n\n')
|
f.write('These are milestone mismatches between an issue in the changelog '
|
||||||
for a in anomalies:
|
'and its referenced PR (or vice-versa). The changelog claim '
|
||||||
if a['type'] != 'missing': continue
|
'"this issue is fixed by this PR, all in this milestone" is '
|
||||||
f.write(f'❓ **#{a["number"]}** — {a["title"]}\n')
|
'inconsistent with the actual milestone assignments. '
|
||||||
f.write(f' - Type: {a["issue_type"]}, Closing PRs: {a["closing_prs"]}\n')
|
'Resolve by either updating the milestone on the issue/PR or '
|
||||||
if a.get('note'): f.write(f' - Note: {a["note"]}\n')
|
'removing the misleading entry from the changelog.\n\n')
|
||||||
|
|
||||||
|
if n_a:
|
||||||
|
f.write(f'### Issue in {MILESTONE}, PR in different milestone or no milestone\n\n')
|
||||||
|
by_issue = {}
|
||||||
|
for a in anomalies_a:
|
||||||
|
by_issue.setdefault(a['issue'], []).append(a)
|
||||||
|
for issue_num in sorted(by_issue):
|
||||||
|
entries = by_issue[issue_num]
|
||||||
|
title = entries[0]['issue_title']
|
||||||
|
f.write(f'- {issue_link_title(issue_num, title[:80])}\n')
|
||||||
|
for e in entries:
|
||||||
|
ms_label = fmt_ms(e['pr_milestone'])
|
||||||
|
badge = '🔴' if e['pr_milestone'] is None else '⚠️'
|
||||||
|
f.write(f' - {badge} Referenced {pr_link(e["pr"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
|
||||||
f.write('\n')
|
f.write('\n')
|
||||||
|
|
||||||
# Type 3
|
if n_b:
|
||||||
if n_pr:
|
f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n')
|
||||||
f.write(f'## Unmerged PRs Referenced in Changelog\n\n')
|
by_pr = {}
|
||||||
f.write('These PR numbers appear in the changelog but are not merged.\n\n')
|
for b in anomalies_b:
|
||||||
for a in anomalies:
|
by_pr.setdefault(b['pr'], []).append(b)
|
||||||
if a['type'] != 'unmerged_pr': continue
|
for pr_num in sorted(by_pr):
|
||||||
f.write(f'🔴 **#{a["number"]}**')
|
entries = by_pr[pr_num]
|
||||||
if a.get('title'): f.write(f' — {a["title"]}')
|
title = entries[0]['pr_title']
|
||||||
f.write(f'\n - {a["reason"]}\n\n')
|
f.write(f'- {pr_link_title(pr_num, title[:80])}\n')
|
||||||
|
for e in entries:
|
||||||
|
ms_label = fmt_ms(e['issue_milestone'])
|
||||||
|
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
|
||||||
|
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
|
||||||
|
f.write('\n')
|
||||||
|
else:
|
||||||
|
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
|
||||||
|
|
||||||
# Appendix: counts
|
# --- Context ---
|
||||||
f.write('---\n\n')
|
f.write('---\n\n')
|
||||||
f.write(f'## Context\n\n')
|
f.write('## Context\n\n')
|
||||||
|
f.write(f'- Milestone: **{MILESTONE}**\n')
|
||||||
f.write(f'- Milestone total issues (all states): {len(all_issues)}\n')
|
f.write(f'- Milestone total issues (all states): {len(all_issues)}\n')
|
||||||
f.write(f'- Valid issues after exclusions: {len(valid_issues)}\n')
|
f.write(f'- Closed issues in milestone: {sum(1 for i in all_issues if i.get("state") == "CLOSED")}\n')
|
||||||
|
f.write(f'- Valid issues after exclusions (after step 5/6a): {len([i for i in all_issues if not issue_excluded(i)])}\n')
|
||||||
f.write(f'- Issues referenced in changelog: {len(changelog_issues)}\n')
|
f.write(f'- Issues referenced in changelog: {len(changelog_issues)}\n')
|
||||||
f.write(f'- PRs referenced in changelog: {len(changelog_prs)}\n')
|
f.write(f'- PRs referenced in changelog: {len(changelog_prs)}\n')
|
||||||
|
|
||||||
@ -565,15 +673,24 @@ print(f"Anomaly report written to {OUTPUT}")
|
|||||||
PYEOF
|
PYEOF
|
||||||
```
|
```
|
||||||
|
|
||||||
This generates `CHANGES-ISSUES.md` with three sections:
|
This generates `CHANGES-ISSUES.md` containing **only the anomalies** —
|
||||||
1. **Issues to Remove** — Entries in the changelog that should be excluded based
|
milestone mismatches between issues and their referenced PRs:
|
||||||
on current issue metadata (labels, type, project status, or deletion).
|
|
||||||
2. **Valid Issues Not in Changelog** — Closed, non-excluded milestone issues
|
1. **Issue in milestone, referenced PR in different milestone or no milestone** —
|
||||||
that lack a changelog entry (with notes on duplicates and unmerged closing PRs).
|
the changelog claims a fix here, but the PR is released elsewhere.
|
||||||
3. **Unmerged PRs Referenced** — PRs in the changelog that are not merged.
|
2. **PR in milestone, closing issue in different milestone or no milestone** —
|
||||||
|
the PR is released here, but the issue it fixes belongs to another version.
|
||||||
|
|
||||||
|
**Rule violations are not in the report** — they are workflow errors the
|
||||||
|
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
|
||||||
|
If the report contains a rule violation, the LLM has skipped the pre-flight
|
||||||
|
step and needs to re-run the workflow before re-generating the report.
|
||||||
|
|
||||||
The report is overwritten each time it's generated, reflecting the current
|
The report is overwritten each time it's generated, reflecting the current
|
||||||
state of the milestone and changelog.
|
state of the milestone and changelog. Every number is rendered as a full
|
||||||
|
`[#N](https://github.com/penpot/penpot/issues/N)` or
|
||||||
|
`[#N](https://github.com/penpot/penpot/pull/N)` link so the report is
|
||||||
|
self-contained and clickable in any Markdown viewer.
|
||||||
|
|
||||||
## Key Principles
|
## Key Principles
|
||||||
|
|
||||||
@ -628,3 +745,14 @@ state of the milestone and changelog.
|
|||||||
issue from a different project or context. If the PR title and issue title
|
issue from a different project or context. If the PR title and issue title
|
||||||
are clearly unrelated, or the PR predates the issue by years, treat it as a
|
are clearly unrelated, or the PR predates the issue by years, treat it as a
|
||||||
data glitch and skip it.
|
data glitch and skip it.
|
||||||
|
- **Anomaly = milestone mismatch only.** The report contains only milestone
|
||||||
|
mismatches: (1) the issue is in this milestone but the referenced PR is
|
||||||
|
in a different milestone (or unassigned), and (2) the PR is in this
|
||||||
|
milestone but the issue it closes is in a different milestone (or
|
||||||
|
unassigned). These are anomalies because the changelog pairing is
|
||||||
|
*misleading* — the human needs to decide whether the milestone or the
|
||||||
|
changelog is wrong. All other discrepancies (exclusion labels, missing
|
||||||
|
valid issues, unmerged PR references, duplicates, stale milestone
|
||||||
|
assignments) are **rule violations** that the LLM must fix directly in
|
||||||
|
`CHANGES.md` during step 6a (pre-flight checks). They never appear in
|
||||||
|
the report — if they do, the pre-flight step was skipped.
|
||||||
|
|||||||
@ -135,15 +135,16 @@
|
|||||||
- Add WebSocket proxy configuration for MCP in Nginx example (by @lancatlin) [#10153](https://github.com/penpot/penpot/issues/10153) (PR: [#10152](https://github.com/penpot/penpot/pull/10152))
|
- Add WebSocket proxy configuration for MCP in Nginx example (by @lancatlin) [#10153](https://github.com/penpot/penpot/issues/10153) (PR: [#10152](https://github.com/penpot/penpot/pull/10152))
|
||||||
- Add tenant prefix to MCP Redis channel names for multi-environment isolation [#10277](https://github.com/penpot/penpot/issues/10277) (PR: [#10276](https://github.com/penpot/penpot/pull/10276))
|
- Add tenant prefix to MCP Redis channel names for multi-environment isolation [#10277](https://github.com/penpot/penpot/issues/10277) (PR: [#10276](https://github.com/penpot/penpot/pull/10276))
|
||||||
- Show MCP key on Integrations page and remove non-recoverable warning from modal [#10290](https://github.com/penpot/penpot/issues/10290) (PR: [#10298](https://github.com/penpot/penpot/pull/10298))
|
- Show MCP key on Integrations page and remove non-recoverable warning from modal [#10290](https://github.com/penpot/penpot/issues/10290) (PR: [#10298](https://github.com/penpot/penpot/pull/10298))
|
||||||
|
- Add MCP status button with single-tab connection control [#9923](https://github.com/penpot/penpot/issues/9923) (PR: [#9930](https://github.com/penpot/penpot/pull/9930))
|
||||||
|
|
||||||
### :bug: Bugs fixed
|
### :bug: Bugs fixed
|
||||||
|
|
||||||
- Improve performance of multiple nested flex layouts with text content [#10095](https://github.com/penpot/penpot/issues/10095)
|
|
||||||
- Fix race condition between MCP initialization and plugin runtime [#10138](https://github.com/penpot/penpot/issues/10138) (PR: [#10137](https://github.com/penpot/penpot/pull/10137))
|
- Fix race condition between MCP initialization and plugin runtime [#10138](https://github.com/penpot/penpot/issues/10138) (PR: [#10137](https://github.com/penpot/penpot/pull/10137))
|
||||||
- Filter ignorable React removeChild errors from browser extensions in error boundary [#10146](https://github.com/penpot/penpot/issues/10146) (PR: [#10145](https://github.com/penpot/penpot/pull/10145))
|
- Filter ignorable React removeChild errors from browser extensions in error boundary [#10146](https://github.com/penpot/penpot/issues/10146) (PR: [#10145](https://github.com/penpot/penpot/pull/10145))
|
||||||
- Show resolved values in font family token combobox when pasting comma-separated values [#10212](https://github.com/penpot/penpot/issues/10212) (PR: [#10215](https://github.com/penpot/penpot/pull/10215))
|
- Show resolved values in font family token combobox when pasting comma-separated values [#10212](https://github.com/penpot/penpot/issues/10212) (PR: [#10215](https://github.com/penpot/penpot/pull/10215))
|
||||||
- Fix MCP server status toggle persistence and missing workspace connection options [#10292](https://github.com/penpot/penpot/issues/10292) (PR: [#10226](https://github.com/penpot/penpot/pull/10226))
|
- Fix MCP server status toggle persistence and missing workspace connection options [#10292](https://github.com/penpot/penpot/issues/10292) (PR: [#10226](https://github.com/penpot/penpot/pull/10226))
|
||||||
- Allow pasting comma-separated emails in the invite members modal [#10173](https://github.com/penpot/penpot/issues/10173) (PR: [#10186](https://github.com/penpot/penpot/pull/10186))
|
- Allow pasting comma-separated emails in the invite members modal [#10173](https://github.com/penpot/penpot/issues/10173) (PR: [#10186](https://github.com/penpot/penpot/pull/10186))
|
||||||
|
- Fix text element edit detaching applied color tokens [#9255](https://github.com/penpot/penpot/issues/9255) (PR: [#9525](https://github.com/penpot/penpot/pull/9525), [#9814](https://github.com/penpot/penpot/pull/9814), [#10340](https://github.com/penpot/penpot/pull/10340))
|
||||||
|
|
||||||
## 2.16.0
|
## 2.16.0
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user