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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/.markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"default": true,
"MD013": false,
"MD033": false,
"MD041": false
}
63 changes: 63 additions & 0 deletions .github/scripts/update_markdown_dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import os
import re
import subprocess
from datetime import datetime, timezone
from pathlib import Path

DATE_PATTERN = re.compile(r"^Last updated: \d{4}-\d{2}-\d{2}$", re.MULTILINE)
CURRENT_DATE = datetime.now(timezone.utc).strftime("%Y-%m-%d")
REPO_ROOT = Path(__file__).resolve().parents[2]


def run_git(*args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()


def changed_markdown_files() -> list[Path]:
base_ref = os.environ.get("PR_BASE_REF", "main")
diff_output = run_git("diff", "--name-only", f"origin/{base_ref}...HEAD", "--", "*.md")
files = []
for relative_path in diff_output.splitlines():
path = REPO_ROOT / relative_path
if path.is_file():
files.append(path)
return files


def update_file(path: Path) -> bool:
content = path.read_text(encoding="utf-8")
updated_content, count = DATE_PATTERN.subn(f"Last updated: {CURRENT_DATE}", content, count=1)
if count == 0 or updated_content == content:
return False
path.write_text(updated_content, encoding="utf-8")
return True


def main() -> int:
files = changed_markdown_files()
if not files:
print("No changed Markdown files detected.")
return 0

updated_any = False
for path in files:
if update_file(path):
print(f"Updated {path.relative_to(REPO_ROOT)}")
updated_any = True

if not updated_any:
print("No Last updated lines needed changes.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
132 changes: 132 additions & 0 deletions .github/scripts/update_visitor_counter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const fs = require('fs');
const path = require('path');
const https = require('https');

const repoRoot = path.resolve(__dirname, '..', '..');
const metricsPath = path.join(repoRoot, 'metrics.json');
const refreshDate = new Date().toISOString().slice(0, 10);
const repo = process.env.REPO;
const token = process.env.TRAFFIC_TOKEN;
const markerPattern = /<!-- START BADGE -->[\s\S]*?<!-- END BADGE -->/g;

function readMetrics() {
if (!fs.existsSync(metricsPath)) {
return { totalViews: 0, refreshDate };
}

try {
return JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
} catch {
return { totalViews: 0, refreshDate };
}
}

function writeMetrics(metrics) {
fs.writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8');
}

function fetchTrafficViews() {
if (!repo || !token) {
return Promise.resolve(null);
}

return new Promise((resolve) => {
const request = https.request(
{
hostname: 'api.github.com',
path: `/repos/${repo}/traffic/views`,
method: 'GET',
headers: {
'User-Agent': 'Cloud2BR-visitor-counter',
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
},
},
(response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
if (response.statusCode !== 200) {
console.warn(`GitHub traffic API returned ${response.statusCode}.`);
resolve(null);
return;
}

try {
const parsed = JSON.parse(data);
resolve(typeof parsed.count === 'number' ? parsed.count : null);
} catch {
resolve(null);
}
});
}
);

request.on('error', () => resolve(null));
request.end();
});
}

function findMarkdownFiles(startDir) {
const results = [];
for (const entry of fs.readdirSync(startDir, { withFileTypes: true })) {
if (entry.name === '.git' || entry.name === 'node_modules') {
continue;
}

const fullPath = path.join(startDir, entry.name);
if (entry.isDirectory()) {
results.push(...findMarkdownFiles(fullPath));
continue;
}

if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(fullPath);
}
}
return results;
}

function buildBadgeBlock(totalViews) {
return [
'<!-- START BADGE -->',
'<div align="center">',
` <img src="https://img.shields.io/badge/Total%20views-${totalViews}-limegreen" alt="Total views">`,
` <p>Refresh Date: ${refreshDate}</p>`,
'</div>',
'<!-- END BADGE -->',
].join('\n');
}

function updateMarkdownBadges(totalViews) {
const replacement = buildBadgeBlock(totalViews);
for (const filePath of findMarkdownFiles(repoRoot)) {
const content = fs.readFileSync(filePath, 'utf8');
if (!markerPattern.test(content)) {
markerPattern.lastIndex = 0;
continue;
}

markerPattern.lastIndex = 0;
const updated = content.replace(markerPattern, replacement);
fs.writeFileSync(filePath, updated, 'utf8');
}
}

async function main() {
const currentMetrics = readMetrics();
const fetchedViews = await fetchTrafficViews();
const totalViews = fetchedViews ?? currentMetrics.totalViews ?? 0;
const nextMetrics = { totalViews, refreshDate };

writeMetrics(nextMetrics);
updateMarkdownBadges(totalViews);
console.log(`Visitor badge refreshed with ${totalViews} total views.`);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
89 changes: 89 additions & 0 deletions .github/scripts/validate_markdown_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from __future__ import annotations

import re
import subprocess
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
GITHUB_BADGE = "[![GitHub](https://img.shields.io/badge/--181717?logo=github&logoColor=ffffff)](https://github.com/)"
ORG_LINK = "[Cloud2BR OSS - Learning Hub](https://github.com/Cloud2BR-MSFTLearningHub)"
DATE_RE = re.compile(r"Last updated: \d{4}-\d{2}-\d{2}")
SEPARATOR_RE = re.compile(r"-{10,}")


def tracked_markdown_files() -> list[Path]:
result = subprocess.run(
["git", "ls-files", "*.md"],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
)
return [REPO_ROOT / line for line in result.stdout.splitlines() if line]


def validate_file(path: Path) -> list[str]:
lines = path.read_text(encoding="utf-8").splitlines()
errors: list[str] = []

if not lines or not lines[0].startswith("# "):
return ["first line must be a markdown title starting with '# '"]

if len(lines) < 8:
return ["file is too short to contain the required header block"]

if lines[1] != "":
errors.append("line 2 must be blank")

location_index = 2
while location_index < len(lines) and lines[location_index].strip():
location_index += 1

if location_index == 2:
errors.append("location line is missing")
return errors

if location_index >= len(lines) or lines[location_index] != "":
errors.append("blank line required after location block")
return errors

header_start = location_index + 1
expected = [GITHUB_BADGE, ORG_LINK, "", None, "", None]
for offset, expected_line in enumerate(expected):
line_index = header_start + offset
if line_index >= len(lines):
errors.append("header block is incomplete")
return errors
actual = lines[line_index]
if offset == 3:
if not DATE_RE.fullmatch(actual):
errors.append("Last updated line must use YYYY-MM-DD format")
elif offset == 5:
if not SEPARATOR_RE.fullmatch(actual):
errors.append("separator line must contain at least 10 hyphens")
elif actual != expected_line:
errors.append(f"expected '{expected_line}' at line {line_index + 1}")

return errors


def main() -> int:
failures = []
for path in tracked_markdown_files():
errors = validate_file(path)
if errors:
failures.append((path.relative_to(REPO_ROOT), errors))

if failures:
for path, errors in failures:
print(f"{path}:")
for error in errors:
print(f" - {error}")
return 1

print("All tracked Markdown files passed header validation.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
46 changes: 35 additions & 11 deletions .github/workflows/update-md-date.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,61 @@ on:
branches:
- main

concurrency:
group: pr-branch-maintenance-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: false

permissions:
contents: write
pull-requests: write

jobs:
update-date:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
- name: Checkout PR branch
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.ref }}

- name: Fetch PR base branch
run: git fetch --no-tags origin ${{ github.event.pull_request.base.ref }}

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.x'

- name: Install dependencies
run: pip install python-dateutil
python-version: '3.12'

- name: Configure Git
- name: Configure Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"

- name: Update last modified date in Markdown files
run: python .github/workflows/update_date.py
env:
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: python .github/scripts/update_markdown_dates.py

- name: Commit changes
- name: Pull, commit, and push if needed
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
git add -A
git commit -m "Update last modified date in Markdown files" || echo "No changes to commit"
git push origin HEAD:${{ github.event.pull_request.head.ref }}
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update last modified date in Markdown files"
git remote set-url origin https://x-access-token:${TOKEN}@github.com/${{ github.repository }}
for attempt in 1 2 3; do
git fetch origin "$BRANCH"
git rebase "origin/$BRANCH"
if git push origin HEAD:"$BRANCH"; then
exit 0
fi
done
echo "Failed to push branch updates after 3 attempts."
exit 1
Loading