mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Automations for config.json, API, audit log event, and Go release notes (#36075)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / Check go fix (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres (shard 0) (push) Blocked by required conditions
Server CI / Postgres (shard 1) (push) Blocked by required conditions
Server CI / Postgres (shard 2) (push) Blocked by required conditions
Server CI / Postgres (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres Test Results (push) Blocked by required conditions
Server CI / Elasticsearch v8 Compatibility (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 0) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 1) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 2) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres FIPS Test Results (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Tools CI / check-style (mattermost-govet) (push) Waiting to run
Tools CI / Test (mattermost-govet) (push) Waiting to run
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
YAML Lint / yamllint (push) Waiting to run
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / Check go fix (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres (shard 0) (push) Blocked by required conditions
Server CI / Postgres (shard 1) (push) Blocked by required conditions
Server CI / Postgres (shard 2) (push) Blocked by required conditions
Server CI / Postgres (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres Test Results (push) Blocked by required conditions
Server CI / Elasticsearch v8 Compatibility (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 0) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 1) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 2) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres FIPS Test Results (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Tools CI / check-style (mattermost-govet) (push) Waiting to run
Tools CI / Test (mattermost-govet) (push) Waiting to run
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
YAML Lint / yamllint (push) Waiting to run
* Create config-change-checker.yml
* Create check_config_changes_ci.py
* Update config-change-checker.yml
* Update check_config_changes_ci.py
* Update check_config_changes_ci.py
* Update check_config_changes_ci.py
* Update check_config_changes_ci.py
* Update config-change-checker.yml
* Update check_config_changes_ci.py
* Update config-change-checker.yml
* Update config.go
* Fix check_api to detect multi-line and multi-method endpoints
The previous implementation matched the .Handle(...).Methods(...) regex
line-by-line against diff lines. This silently missed two real and
common patterns in api4/:
1. Multi-line .Handle(...) declarations — e.g. group.go has 18 of
them, where the path lives on one line and the wrapper/handler on
the next. The regex never matched, so PRs adding such endpoints
produced empty release-note entries.
2. Multi-method declarations like
.Methods(http.MethodGet, http.MethodHead) (4 instances in file.go)
— the old regex required a closing paren immediately after the
first method.
The fix:
- Add a file_at(ref, path) helper that snapshots a file at a git ref
via 'git show', so checkers can compare full file states instead of
pattern-matching diff text.
- Add _scan_endpoints() that whitespace-collapses the file before
matching, letting the regex span what were originally multiple
lines.
- Loosen _HANDLE_RE to capture the methods list as a substring and
extract individual HTTP verbs with a known-method allowlist, so
multi-method declarations produce one entry per verb.
- Switch check_api to set-diff (after - before) / (before - after)
on the parsed endpoint sets. This also cleanly handles routes
that move within a file (no fragile add/remove dedup needed).
- Anchor the new/deleted file detection to '^new file mode \d+' to
avoid false positives from stray text in source files.
Made-with: Cursor
* Track enclosing struct in check_config to avoid dedup collisions
The previous check_config keyed its add/remove dedup on the bare field
name. The dedup intent was to ignore fields that were merely reordered
within config.go (which appear in the diff as both '-Foo' and '+Foo').
But because the key was just the field name, an unrelated rename in one
struct could silently cancel out a real new field with the same name in
a different struct. For example, in a single PR:
- EnableFoo *bool // removed from ServiceSettings
+ EnableFooV2 *bool
- EnableBar *bool // removed from EmailSettings
+ EnableFoo *bool // newly added — but wrongly cancelled below
The dedup would see 'EnableFoo' in both lists and drop both entries,
hiding the brand-new EmailSettings.EnableFoo from the release-note
output.
The fix tracks each field's enclosing struct using a brace-depth stack
that walks the file at BASE_SHA and HEAD_SHA. Fields are keyed as
(struct_name, field_name) tuples, so identically-named fields in
different structs are distinct, and the dedup only collapses true
reorderings. As a side benefit the rendered output is now
'StructName.FieldName' which is much more useful to reviewers.
Switching to file-at-revision scanning + set diff also removes the
custom dedup logic entirely — set arithmetic handles "moved within
file" naturally.
Made-with: Cursor
* Switch remaining checkers to file-at-revision style; drop lines_by_sign
check_audit_events and check_go_version still parsed +/- diff lines
directly, with the same brittle dedup-and-cancel logic that was used in
the previous check_config. After the previous two commits the rest of
the file uses the file_at(ref, path) helper to compare full file
states between BASE_SHA and HEAD_SHA, which:
- removes the entire moved-within-file dedup dance (set arithmetic
handles it for free),
- aligns all four checkers on a single, easy-to-reason-about pattern,
- is robust to whitespace-only or reordering edits in the watched
files.
For Dockerfile.buildenv the helper also avoids a subtle case where the
old code only inspected +/- lines: an edit to an unrelated RUN line
that didn't touch the FROM line could in theory leave both old_ver and
new_ver as None even though the version was effectively unchanged.
Reading the file at each revision compares the actual current and
previous FROM line directly.
The lines_by_sign helper now has no callers, so remove it.
Made-with: Cursor
* Update config.go
* Update config.go
* Update check_config_changes_ci.py
* Update check_config_changes_ci.py
* Update check_config_changes_ci.py
* Update check_config_changes_ci.py
* Tighten check_config_changes_ci.py: regex coverage + idempotency
- Restore tolerant `_HANDLE_RE` so 2-arg wrappers (e.g. `api.APISessionRequired(handler, handlerParamFileAPI)`)
are not silently dropped from the api4 endpoint scan; broaden the `.Methods(...)`
capture so string-literal variants (`Methods("GET")`) work too. Filtering moves
back to the `_HTTP_METHODS` allowlist in `_parse_methods` to keep stray
identifiers from being treated as HTTP verbs.
- Make `strip_old_note` also remove auto-generated lines that landed outside
the ```release-note fence (the inject_note fallback paths) so reruns no
longer accumulate duplicates when a PR has no fence.
- Skip the GitHub PATCH when the PR description is already up to date, so
every commit no longer triggers an unconditional write.
- Wire up `check_go_version`'s `additions` path in `_format_lines` and
`_AUTO_LINE_RE` so a freshly-added Dockerfile.buildenv emits a note.
- Remove the now-dead `CheckResult.to_markdown` method (replaced by
`_format_lines`).
Made-with: Cursor
* Restore ExperimentalSettings.EnableWatermark
The field was removed in f71527f0b1 but `server/config/client.go`,
`server/config/client_test.go`, and `server/public/model/config_test.go`
still reference it (added on master in #36025). Restoring the field
makes the branch compile again so CI can go green.
Made-with: Cursor
* Replace placeholder release-note content (NONE / N/A) on injection
The script previously appended its auto-detected lines INSIDE the
```release-note fence but never displaced template placeholders, so PRs
that only had `NONE` ended up with output like:
NONE
Added `Foo.Bar` configuration setting.
Go runtime updated from 1.25.8 to 1.25.9.
When the existing fence content is empty or consists only of placeholder
tokens (NONE, N/A, NA, dashes — case-insensitive), replace it entirely
with the auto-detected entries. User-written human content is still
preserved by appending instead.
Idempotent: stripping followed by re-injection keeps the placeholder
visible when there's nothing to inject, and replaces it again when there
is.
Made-with: Cursor
* Update config-change-checker.yml
* Update check_config_changes_ci.py
---------
Co-authored-by: Your Name <eva.sarafianou@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
9d318dc4cd
commit
0675d0ea0b
2 changed files with 656 additions and 0 deletions
590
.github/scripts/check_config_changes_ci.py
vendored
Normal file
590
.github/scripts/check_config_changes_ci.py
vendored
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
.github/scripts/check_config_changes_ci.py
|
||||
|
||||
CI script that detects notable changes across several Mattermost source files
|
||||
and appends structured release-note entries to the PR description.
|
||||
|
||||
Checkers
|
||||
────────
|
||||
1. config.go — exported struct field additions/removals
|
||||
2. api4/ — API endpoint additions/removals (Handle() calls)
|
||||
3. audit_events.go — AuditEvent* constant additions/removals
|
||||
4. Dockerfile.buildenv — Go (base-image) version changes
|
||||
|
||||
All inputs come from environment variables set by the GitHub Actions workflow:
|
||||
GITHUB_TOKEN — built-in Actions token (pull-requests: write scope)
|
||||
PR_NUMBER — pull request number
|
||||
BASE_SHA — base commit SHA
|
||||
HEAD_SHA — head commit SHA
|
||||
REPO — owner/repo (e.g. mattermost/mattermost)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import requests
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
# ── Environment ────────────────────────────────────────────────────────────────
|
||||
|
||||
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
|
||||
PR_NUMBER = int(os.environ["PR_NUMBER"])
|
||||
BASE_SHA = os.environ["BASE_SHA"]
|
||||
HEAD_SHA = os.environ["HEAD_SHA"]
|
||||
REPO = os.environ.get("REPO", "mattermost/mattermost")
|
||||
|
||||
BASE_URL = "https://api.github.com"
|
||||
HEADERS = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
|
||||
# Timeout for all GitHub API requests: (connect seconds, read seconds).
|
||||
# Prevents the workflow from hanging indefinitely on a slow/unresponsive API.
|
||||
_TIMEOUT = (5, 30)
|
||||
|
||||
# Paths watched by this script (must align with `paths:` in the workflow YAML)
|
||||
WATCHED_PATHS = [
|
||||
"server/public/model/config.go",
|
||||
"server/channels/api4/",
|
||||
"server/public/model/audit_events.go",
|
||||
"server/build/Dockerfile.buildenv",
|
||||
]
|
||||
|
||||
|
||||
# ── Data types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""Holds the findings from one checker."""
|
||||
label: str # Section heading, e.g. "`config.json` Changes"
|
||||
additions: list = field(default_factory=list)
|
||||
removals: list = field(default_factory=list)
|
||||
changes: list = field(default_factory=list) # for free-form entries (version bumps)
|
||||
|
||||
def has_findings(self) -> bool:
|
||||
return bool(self.additions or self.removals or self.changes)
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
lines = [f"### {self.label}"]
|
||||
if self.additions:
|
||||
lines.append("**Added:** " + ", ".join(self.additions))
|
||||
if self.removals:
|
||||
lines.append("**Removed:** " + ", ".join(self.removals))
|
||||
for change in self.changes:
|
||||
lines.append(change)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Diff helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def get_full_patch() -> str:
|
||||
"""Return unified diff for all watched paths between base and head."""
|
||||
result = subprocess.run(
|
||||
["git", "diff", f"{BASE_SHA}...{HEAD_SHA}", "--"] + WATCHED_PATHS,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def split_patch_by_file(full_patch: str) -> dict[str, str]:
|
||||
"""
|
||||
Split a multi-file unified diff into {filename: patch} mapping.
|
||||
Filenames are the b-side (new) path, stripped of the 'b/' prefix.
|
||||
"""
|
||||
patches: dict[str, str] = {}
|
||||
current_file: Optional[str] = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in full_patch.splitlines(keepends=True):
|
||||
if line.startswith("diff --git "):
|
||||
if current_file:
|
||||
patches[current_file] = "".join(current_lines)
|
||||
current_lines = [line]
|
||||
# Extract filename from "diff --git a/foo b/foo"
|
||||
m = re.search(r" b/(.+)$", line.rstrip())
|
||||
current_file = m.group(1) if m else None
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_file:
|
||||
patches[current_file] = "".join(current_lines)
|
||||
|
||||
return patches
|
||||
|
||||
|
||||
def file_at(ref: str, path: str) -> str:
|
||||
"""Return the full contents of `path` at git ref `ref`, or '' if absent."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "show", f"{ref}:{path}"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
# ── Checker 1 — config.go ──────────────────────────────────────────────────────
|
||||
|
||||
_CONFIG_PATH = "server/public/model/config.go"
|
||||
_STRUCT_DECL_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{")
|
||||
_FIELD_LINE_RE = re.compile(r"^\t([A-Z][A-Za-z0-9_]*)\s+\S")
|
||||
|
||||
|
||||
def _scan_struct_fields(src: str) -> set[tuple[str, str]]:
|
||||
"""
|
||||
Walk Go source and return {(StructName, FieldName)} for every exported
|
||||
field in every struct.
|
||||
|
||||
Uses a brace-depth stack so nested anonymous structs, interface bodies,
|
||||
and function literals don't corrupt the enclosing struct context.
|
||||
Named type declarations cannot be nested in Go, so the struct_stack
|
||||
never grows beyond one entry for named structs.
|
||||
"""
|
||||
fields: set[tuple[str, str]] = set()
|
||||
# Each entry: (struct_name, brace_depth_when_opened)
|
||||
struct_stack: list[tuple[str, int]] = []
|
||||
depth = 0
|
||||
|
||||
for line in src.splitlines():
|
||||
sm = _STRUCT_DECL_RE.match(line)
|
||||
if sm:
|
||||
# Record depth *before* counting this line's braces
|
||||
struct_stack.append((sm.group(1), depth))
|
||||
|
||||
depth += line.count("{") - line.count("}")
|
||||
|
||||
# Pop any structs whose closing brace has been passed
|
||||
while struct_stack and depth <= struct_stack[-1][1]:
|
||||
struct_stack.pop()
|
||||
|
||||
# Record fields only when we're directly inside exactly one named struct
|
||||
if len(struct_stack) == 1:
|
||||
fm = _FIELD_LINE_RE.match(line)
|
||||
if fm:
|
||||
fields.add((struct_stack[0][0], fm.group(1)))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def check_config(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect exported Go struct field additions/removals in config.go.
|
||||
|
||||
Compares full-file snapshots at BASE_SHA and HEAD_SHA so that fields
|
||||
are always attributed to the correct struct regardless of which diff
|
||||
hunks are present.
|
||||
"""
|
||||
result = CheckResult(label="`config.json` Field Changes")
|
||||
if _CONFIG_PATH not in patches:
|
||||
return result
|
||||
|
||||
base_fields = _scan_struct_fields(file_at(BASE_SHA, _CONFIG_PATH))
|
||||
head_fields = _scan_struct_fields(file_at(HEAD_SHA, _CONFIG_PATH))
|
||||
|
||||
added = head_fields - base_fields
|
||||
removed = base_fields - head_fields
|
||||
|
||||
result.additions = sorted(f"`{s}.{f}`" for s, f in added)
|
||||
result.removals = sorted(f"`{s}.{f}`" for s, f in removed)
|
||||
return result
|
||||
|
||||
|
||||
# ── Checker 2 — api4/ ─────────────────────────────────────────────────────────
|
||||
|
||||
# Matches Handle() route registrations after whitespace-collapsing the source.
|
||||
# Whitespace collapse makes multi-line declarations single-searchable.
|
||||
# Group 1: path Group 2: handler func Group 3: raw Methods(...) content
|
||||
#
|
||||
# The wrapper pattern uses [^)]* so it tolerates any middleware arguments
|
||||
# (e.g. r.APIHandler(...), r.ApiSessionRequired(..., isLocal=true), etc.)
|
||||
# without having to enumerate every possible wrapper signature.
|
||||
_HANDLE_RE = re.compile(
|
||||
r'\.Handle\("([^"]*)"' # path
|
||||
r',\s*[^)]*\((\w+)\)\)' # wrapper(...handlerFunc))
|
||||
r'\.Methods\(([^)]+)\)', # .Methods(one or more methods)
|
||||
)
|
||||
|
||||
_METHOD_RE = re.compile(r'(?:http\.Method)?(\w+)')
|
||||
_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
|
||||
|
||||
def _parse_methods(raw: str) -> list[str]:
|
||||
"""Split raw Methods(...) content into individual uppercase HTTP verbs.
|
||||
|
||||
Filters against the set of known HTTP methods so that incidental
|
||||
identifiers (handler names, constants, etc.) that happen to appear
|
||||
inside Methods(...) don't produce spurious results.
|
||||
"""
|
||||
return [
|
||||
verb
|
||||
for token in raw.split(",")
|
||||
if (m := _METHOD_RE.search(token.strip()))
|
||||
and (verb := m.group(1).upper()) in _HTTP_METHODS
|
||||
]
|
||||
|
||||
|
||||
def _format_endpoint(path: str, handler: str, method: str) -> str:
|
||||
return f"`{method.upper()} {path or '/'}` (`{handler}`)"
|
||||
|
||||
|
||||
def _parse_endpoints(src: str) -> set[tuple[str, str, str]]:
|
||||
"""
|
||||
Parse Handle() registrations from a Go source file.
|
||||
|
||||
Whitespace-collapses the entire file first so multi-line declarations
|
||||
(e.g. the 18 in group.go) are matched as a single token sequence.
|
||||
Returns {(path, handler, method)} tuples.
|
||||
"""
|
||||
blob = " ".join(src.split())
|
||||
endpoints: set[tuple[str, str, str]] = set()
|
||||
for m in _HANDLE_RE.finditer(blob):
|
||||
path, handler, methods_raw = m.group(1), m.group(2), m.group(3)
|
||||
for method in _parse_methods(methods_raw):
|
||||
endpoints.add((path or "/", handler, method))
|
||||
return endpoints
|
||||
|
||||
|
||||
def check_api(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect API endpoint additions/removals in the api4/ directory.
|
||||
|
||||
Compares full-file snapshots at BASE_SHA and HEAD_SHA via set arithmetic,
|
||||
so multi-line and multi-method registrations are handled correctly.
|
||||
"""
|
||||
result = CheckResult(label="API Changes (`api4`)")
|
||||
|
||||
api4_patches = {
|
||||
fname: patch
|
||||
for fname, patch in patches.items()
|
||||
if fname.startswith("server/channels/api4/") and fname.endswith(".go")
|
||||
}
|
||||
if not api4_patches:
|
||||
return result
|
||||
|
||||
added_eps: set[tuple[str, str, str]] = set()
|
||||
removed_eps: set[tuple[str, str, str]] = set()
|
||||
|
||||
for fname, patch in api4_patches.items():
|
||||
base_eps = _parse_endpoints(file_at(BASE_SHA, fname))
|
||||
head_eps = _parse_endpoints(file_at(HEAD_SHA, fname))
|
||||
added_eps |= head_eps - base_eps
|
||||
removed_eps |= base_eps - head_eps
|
||||
|
||||
# Anchor the check to avoid false positives from unrelated source text
|
||||
if re.search(r"^new file mode \d+", patch, re.MULTILINE):
|
||||
result.changes.append(f"🆕 New API file: `{fname.split('/')[-1]}`")
|
||||
if re.search(r"^deleted file mode \d+", patch, re.MULTILINE):
|
||||
result.changes.append(f"🗑️ Removed API file: `{fname.split('/')[-1]}`")
|
||||
|
||||
result.additions = sorted(_format_endpoint(p, h, m) for p, h, m in added_eps)
|
||||
result.removals = sorted(_format_endpoint(p, h, m) for p, h, m in removed_eps)
|
||||
return result
|
||||
|
||||
|
||||
# ── Checker 3 — audit_events.go ───────────────────────────────────────────────
|
||||
|
||||
_AUDIT_EVENT_PATH = "server/public/model/audit_events.go"
|
||||
_AUDIT_CONST_RE = re.compile(r"^\t(AuditEvent\w+)\s*=")
|
||||
|
||||
|
||||
def _parse_audit_events(src: str) -> set[str]:
|
||||
return {m.group(1) for line in src.splitlines() if (m := _AUDIT_CONST_RE.match(line))}
|
||||
|
||||
|
||||
def check_audit_events(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect AuditEvent* constant additions/removals.
|
||||
|
||||
Uses full-file snapshots at BASE_SHA/HEAD_SHA so reorderings and
|
||||
cross-constant name collisions don't produce false results.
|
||||
"""
|
||||
result = CheckResult(label="Audit Log Event Changes")
|
||||
if _AUDIT_EVENT_PATH not in patches:
|
||||
return result
|
||||
|
||||
base_events = _parse_audit_events(file_at(BASE_SHA, _AUDIT_EVENT_PATH))
|
||||
head_events = _parse_audit_events(file_at(HEAD_SHA, _AUDIT_EVENT_PATH))
|
||||
|
||||
result.additions = sorted(f"`{e}`" for e in head_events - base_events)
|
||||
result.removals = sorted(f"`{e}`" for e in base_events - head_events)
|
||||
return result
|
||||
|
||||
|
||||
# ── Checker 4 — Dockerfile.buildenv (Go version) ──────────────────────────────
|
||||
|
||||
# The Go version lives in the base image tag, e.g.:
|
||||
# FROM mattermost/golang-bullseye:1.25.8@sha256:...
|
||||
_DOCKERFILE_PATH = "server/build/Dockerfile.buildenv"
|
||||
_IMAGE_VER_RE = re.compile(r"^FROM \S+:([0-9]+\.[0-9]+(?:\.[0-9]+)?)")
|
||||
|
||||
|
||||
def _parse_go_version(src: str) -> Optional[str]:
|
||||
for line in src.splitlines():
|
||||
m = _IMAGE_VER_RE.match(line.strip())
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def check_go_version(patches: dict[str, str]) -> CheckResult:
|
||||
"""
|
||||
Detect Go runtime version changes via the base image tag.
|
||||
|
||||
Uses full-file snapshots so the version is read from the actual file
|
||||
state at each ref rather than reconstructed from patch lines.
|
||||
"""
|
||||
result = CheckResult(label="Go Runtime Version")
|
||||
if _DOCKERFILE_PATH not in patches:
|
||||
return result
|
||||
|
||||
old_ver = _parse_go_version(file_at(BASE_SHA, _DOCKERFILE_PATH))
|
||||
new_ver = _parse_go_version(file_at(HEAD_SHA, _DOCKERFILE_PATH))
|
||||
|
||||
if old_ver and new_ver and old_ver != new_ver:
|
||||
result.changes.append(f"Go updated: `{old_ver}` → `{new_ver}`")
|
||||
elif new_ver and not old_ver:
|
||||
result.additions.append(f"`{new_ver}`")
|
||||
return result
|
||||
|
||||
|
||||
# ── PR description helpers ─────────────────────────────────────────────────────
|
||||
|
||||
# Matches lines that were auto-generated by this script so they can be stripped
|
||||
# before re-injecting a fresh set on subsequent commits.
|
||||
_AUTO_LINE_RE = re.compile(
|
||||
r"^(Added|Removed) `[^`]+`.*(configuration setting|API endpoint|audit log event)\."
|
||||
r"|^Go runtime updated from \S+ to \S+\."
|
||||
r"|^Go runtime set to `[^`]+`\."
|
||||
r"|^🆕 New API file:"
|
||||
r"|^🗑️ Removed API file:"
|
||||
)
|
||||
|
||||
# Matches placeholder content inside a release-note fence that means "nothing
|
||||
# to report yet" (e.g. NONE, N/A, ---). When detected, we replace the
|
||||
# placeholder rather than appending alongside it.
|
||||
_PLACEHOLDER_RE = re.compile(r"^\s*(?:NONE|N/?A|-+)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _format_lines(result: CheckResult) -> list[str]:
|
||||
"""Produce natural-language lines for one checker result."""
|
||||
lines = []
|
||||
|
||||
if "`config.json`" in result.label:
|
||||
for item in result.additions:
|
||||
lines.append(f"Added {item} configuration setting.")
|
||||
for item in result.removals:
|
||||
lines.append(f"Removed {item} configuration setting.")
|
||||
|
||||
elif "API Changes" in result.label:
|
||||
for item in result.additions:
|
||||
lines.append(f"Added {item} API endpoint.")
|
||||
for item in result.removals:
|
||||
lines.append(f"Removed {item} API endpoint.")
|
||||
lines.extend(result.changes) # new/deleted file entries
|
||||
|
||||
elif "Audit" in result.label:
|
||||
for item in result.additions:
|
||||
lines.append(f"Added {item} audit log event.")
|
||||
for item in result.removals:
|
||||
lines.append(f"Removed {item} audit log event.")
|
||||
|
||||
elif "Go Runtime" in result.label:
|
||||
for item in result.additions:
|
||||
# item is e.g. "`1.22`" — strip backticks for the prose form
|
||||
lines.append(f"Go runtime set to {item}.")
|
||||
for c in result.changes:
|
||||
# c arrives as "Go updated: `1.21` → `1.22`" — rewrite it
|
||||
m = re.search(r"`([^`]+)`\s*→\s*`([^`]+)`", c)
|
||||
if m:
|
||||
lines.append(f"Go runtime updated from {m.group(1)} to {m.group(2)}.")
|
||||
else:
|
||||
lines.append(c)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def build_pr_note(results: list[CheckResult]) -> str:
|
||||
"""Assemble all findings into a clean plain-text block."""
|
||||
lines = []
|
||||
for r in results:
|
||||
if r.has_findings():
|
||||
lines.extend(_format_lines(r))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def strip_old_note(body: str) -> str:
|
||||
"""
|
||||
Remove previously auto-generated lines from the PR description.
|
||||
|
||||
Primary path — lines inside the ```release-note ... ``` fence.
|
||||
Fallback path — auto-generated lines that were appended outside any fence
|
||||
(e.g. via the ## Release Notes section on earlier runs).
|
||||
|
||||
Lines are identified by pattern rather than visible markers, so the PR
|
||||
description stays clean for human readers.
|
||||
"""
|
||||
def _clean_fence(m: re.Match) -> str:
|
||||
open_tag, content, close_tag = m.group(1), m.group(2), m.group(3)
|
||||
cleaned_lines = [
|
||||
line for line in content.split("\n")
|
||||
if not _AUTO_LINE_RE.match(line.strip())
|
||||
]
|
||||
return open_tag + "\n".join(cleaned_lines) + close_tag
|
||||
|
||||
cleaned = re.sub(
|
||||
r"(```release-note)(.*?)(```)",
|
||||
_clean_fence,
|
||||
body or "",
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Fallback: strip any auto-generated lines that appear outside a fence
|
||||
# (written by an older version of this script or via the header-inject path).
|
||||
cleaned_lines = [
|
||||
line for line in cleaned.splitlines()
|
||||
if not _AUTO_LINE_RE.match(line.strip())
|
||||
]
|
||||
return "\n".join(cleaned_lines).rstrip()
|
||||
|
||||
|
||||
def inject_note(body: str, note: str) -> str:
|
||||
"""
|
||||
Insert `note` using this priority order:
|
||||
|
||||
1. INSIDE the ```release-note block, before its closing ```
|
||||
(Mattermost convention — keeps everything in one place for reviewers)
|
||||
2. After a recognised release-notes section header (## Release Notes, etc.)
|
||||
3. Fallback: append a new ## Release Notes section at the end
|
||||
"""
|
||||
body = strip_old_note(body)
|
||||
if not note:
|
||||
return body
|
||||
|
||||
# 1. Mattermost-style ```release-note ... ``` block — inject INSIDE the fence.
|
||||
# If the fence currently contains only a placeholder (NONE / N/A / ---),
|
||||
# replace the placeholder rather than appending alongside it.
|
||||
release_note_block = re.search(
|
||||
r"(```release-note)(.*?)(```)",
|
||||
body,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if release_note_block:
|
||||
open_tag = release_note_block.group(1)
|
||||
content = release_note_block.group(2) # everything between the fences
|
||||
close_tag = release_note_block.group(3)
|
||||
block_start = release_note_block.start()
|
||||
block_end = release_note_block.end()
|
||||
|
||||
# Strip leading/trailing newlines inside the fence for comparison
|
||||
inner = content.strip()
|
||||
if _PLACEHOLDER_RE.match(inner):
|
||||
# Replace the entire fence with a fresh one
|
||||
new_block = f"{open_tag}\n{note}\n{close_tag}"
|
||||
else:
|
||||
# Append before the closing fence
|
||||
new_block = open_tag + content + note + "\n" + close_tag
|
||||
|
||||
return body[:block_start] + new_block + body[block_end:]
|
||||
|
||||
# 2. Markdown section headers
|
||||
for header in ["## Release Notes", "## Changelog", "## What Changed", "## What's Changed"]:
|
||||
if header.lower() in body.lower():
|
||||
idx = body.lower().index(header.lower()) + len(header)
|
||||
return body[:idx] + "\n\n" + note + body[idx:]
|
||||
|
||||
# 3. Fallback — append
|
||||
return body + "\n\n## Release Notes\n\n" + note
|
||||
|
||||
|
||||
# ── GitHub API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_pr_body() -> str:
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
|
||||
headers=HEADERS,
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json().get("body") or ""
|
||||
|
||||
|
||||
def update_pr_body(new_body: str) -> None:
|
||||
r = requests.patch(
|
||||
f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
|
||||
headers=HEADERS,
|
||||
json={"body": new_body},
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print(f"📋 PR #{PR_NUMBER} | base {BASE_SHA[:8]} → head {HEAD_SHA[:8]}")
|
||||
print("🔍 Collecting diffs …")
|
||||
|
||||
full_patch = get_full_patch()
|
||||
if not full_patch.strip():
|
||||
print("ℹ️ No changes in watched paths. Nothing to do.")
|
||||
return
|
||||
|
||||
patches = split_patch_by_file(full_patch)
|
||||
print(f" {len(patches)} file(s) changed in watched paths.\n")
|
||||
|
||||
# Run all checkers
|
||||
checkers = [
|
||||
check_config,
|
||||
check_api,
|
||||
check_audit_events,
|
||||
check_go_version,
|
||||
]
|
||||
results: list[CheckResult] = [fn(patches) for fn in checkers]
|
||||
|
||||
for r in results:
|
||||
if r.has_findings():
|
||||
print(f" ✅ {r.label}")
|
||||
if r.additions:
|
||||
print(f" Added: {', '.join(r.additions)}")
|
||||
if r.removals:
|
||||
print(f" Removed: {', '.join(r.removals)}")
|
||||
for c in r.changes:
|
||||
print(f" {c}")
|
||||
else:
|
||||
print(f" – {r.label}: no changes")
|
||||
|
||||
note = build_pr_note(results)
|
||||
if not note:
|
||||
print("\nℹ️ No notable changes found across all checkers.")
|
||||
return
|
||||
|
||||
print("\n🔄 Fetching PR description …")
|
||||
body = get_pr_body()
|
||||
new_body = inject_note(body, note)
|
||||
|
||||
if new_body == body:
|
||||
print("ℹ️ PR description already up to date — no changes needed.")
|
||||
return
|
||||
|
||||
update_pr_body(new_body)
|
||||
print(f"✅ PR #{PR_NUMBER} description updated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ git diff failed:\n{e.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except requests.HTTPError as e:
|
||||
# Avoid dumping the full response body (can be large / noisy).
|
||||
# status + reason gives enough context for debugging (e.g. "403 Forbidden").
|
||||
reason = e.response.reason or "unknown"
|
||||
print(f"❌ GitHub API error: {e.response.status_code} {reason}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
66
.github/workflows/config-change-checker.yml
vendored
Normal file
66
.github/workflows/config-change-checker.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# .github/workflows/config-change-checker.yml
|
||||
#
|
||||
# Automatically detects notable additions/removals across four source files
|
||||
# and appends structured release-note entries to the PR description under
|
||||
# the "## Release Notes" section.
|
||||
#
|
||||
# Tracked files / directories:
|
||||
# • server/public/model/config.go — config struct field changes
|
||||
# • server/channels/api4/ — API endpoint additions/removals
|
||||
# • server/public/model/audit_events.go — audit log event constant changes
|
||||
# • server/build/Dockerfile.buildenv — Go runtime version changes
|
||||
#
|
||||
# No secrets needed — uses the built-in GITHUB_TOKEN.
|
||||
|
||||
name: Config Change Checker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'server/public/model/config.go'
|
||||
- 'server/channels/api4/**'
|
||||
- 'server/public/model/audit_events.go'
|
||||
- 'server/build/Dockerfile.buildenv'
|
||||
|
||||
# Cancel any in-progress run for the same PR when a new commit is pushed.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-release-notes:
|
||||
name: Detect release-note-worthy changes
|
||||
runs-on: ubuntu-latest
|
||||
# Skip bot-authored PRs (Dependabot, mattermost-bot, etc.) — they will
|
||||
# not touch these paths intentionally and cannot receive description updates
|
||||
# via GITHUB_TOKEN anyway (fork-like restrictions apply to most bots).
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
|
||||
permissions:
|
||||
pull-requests: write # needed to update the PR description
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Fetch enough history to diff against the base branch
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install requests==2.32.3 --quiet
|
||||
|
||||
- name: Detect changes and update PR description
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: python3 .github/scripts/check_config_changes_ci.py
|
||||
Loading…
Reference in a new issue