自动化

This commit is contained in:
2026-05-03 07:28:13 +08:00
Unverified
parent 2929bff081
commit a12be5bfd4
3 changed files with 332 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
name: 更新「如今的我们」
description: 新增或修改一位同学在「如今的我们」中的资料
title: "people: "
labels:
- people-update
body:
- type: markdown
attributes:
value: |
提交后,自动化会用 `slug` 作为唯一标识更新 `src/data/people.ts`。
如果这个 `slug` 不存在,会新增一条同学资料;如果已经存在,会修改对应资料。
可选字段留空时,修改已有资料会保留原值,新增资料会写为空字符串。
- type: input
id: slug
attributes:
label: slug
description: URL 中使用的唯一标识,只建议使用小写字母、数字和短横线。
placeholder: student-a
validations:
required: true
- type: input
id: initial
attributes:
label: initial
description: 卡片照片占位时显示的字母或简称。
placeholder: A
validations:
required: true
- type: input
id: name
attributes:
label: name
placeholder: 同学 A
validations:
required: true
- type: input
id: photo
attributes:
label: photo
description: 近照路径,例如 /photos/student-a.jpg;可以先留空。
placeholder: /photos/student-a.jpg
- type: input
id: location
attributes:
label: location
placeholder: 北京
validations:
required: true
- type: input
id: school
attributes:
label: school
placeholder: 示例大学
validations:
required: true
- type: input
id: direction
attributes:
label: direction
placeholder: 计算机科学
validations:
required: true
- type: input
id: keywords
attributes:
label: keywords
description: 用中文逗号、英文逗号或顿号分隔。
placeholder: 开始、独立、想念
validations:
required: true
- type: textarea
id: text
attributes:
label: text
description: 卡片上的一句近况。
placeholder: 这一年的关键词:开始、独立、想念。
validations:
required: true
- type: textarea
id: currentStatus
attributes:
label: currentStatus
description: 个人页「近况」内容。
validations:
required: true
- type: textarea
id: highlight
attributes:
label: highlight
description: 个人页「这一年的小事」内容。
validations:
required: true
- type: textarea
id: toPastSelf
attributes:
label: toPastSelf
description: 个人页「想对高中时的自己说」内容。
validations:
required: true
- type: textarea
id: messageToClass
attributes:
label: messageToClass
description: 个人页「想对大家说」内容。
validations:
required: true
- type: textarea
id: favoriteMemory
attributes:
label: favoriteMemory
description: 个人页「最想带走的高中瞬间」内容。
validations:
required: true
- type: input
id: contact
attributes:
label: contact
description: 微信、邮箱或其他联系方式;可以留空。
placeholder: 可填写微信、邮箱或留空
+49
View File
@@ -0,0 +1,49 @@
name: Update People Data
on:
issues:
types:
- opened
- edited
- labeled
permissions:
contents: write
issues: write
jobs:
update-people:
if: contains(github.event.issue.labels.*.name, 'people-update') || startsWith(github.event.issue.title, 'people:')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Update src/data/people.ts
env:
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
run: node scripts/update-people-from-issue.mjs
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update people data from issue #${{ github.event.issue.number }}"
file_pattern: src/data/people.ts
- name: Comment on issue
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: "已根据这个 issue 更新 `src/data/people.ts`。如果内容需要再调整,直接编辑 issue 表单即可再次触发。"
});
+164
View File
@@ -0,0 +1,164 @@
import { readFileSync, writeFileSync } from "node:fs";
import { relative, resolve } from "node:path";
const peoplePath = resolve(process.env.PEOPLE_PATH ?? "src/data/people.ts");
const issueBody = process.env.ISSUE_BODY ?? "";
const fields = [
"slug",
"initial",
"name",
"photo",
"location",
"school",
"direction",
"keywords",
"text",
"currentStatus",
"highlight",
"toPastSelf",
"messageToClass",
"favoriteMemory",
"contact"
];
const requiredFields = [
"slug",
"initial",
"name",
"location",
"school",
"direction",
"keywords",
"text",
"currentStatus",
"highlight",
"toPastSelf",
"messageToClass",
"favoriteMemory"
];
const sectionAliases = new Map(fields.map((field) => [field.toLowerCase(), field]));
function normalizeValue(value) {
const trimmed = value.trim();
return trimmed === "_No response_" ? "" : trimmed;
}
function parseIssueForm(body) {
const result = {};
const sectionPattern = /^###\s+(.+?)\s*$/gm;
const matches = [...body.matchAll(sectionPattern)];
for (let index = 0; index < matches.length; index += 1) {
const rawLabel = matches[index][1].trim().toLowerCase();
const field = sectionAliases.get(rawLabel);
if (!field) continue;
const start = matches[index].index + matches[index][0].length;
const end = index + 1 < matches.length ? matches[index + 1].index : body.length;
result[field] = normalizeValue(body.slice(start, end));
}
return result;
}
function assertValidInput(input) {
for (const field of requiredFields) {
if (!input[field]) {
throw new Error(`Issue form is missing required field: ${field}`);
}
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug)) {
throw new Error("slug must use lowercase letters, numbers, and single hyphens only.");
}
}
function readPeopleModule(filePath) {
const source = readFileSync(filePath, "utf8");
const executable = source
.replace("export const peopleIntro =", "const peopleIntro =")
.replace("export const people =", "const people =");
return Function(`${executable}; return { peopleIntro, people };`)();
}
function splitKeywords(value) {
return value
.split(/[、,]/)
.map((keyword) => keyword.trim())
.filter(Boolean);
}
function buildPerson(input, existing = {}) {
const person = { ...existing };
for (const field of fields) {
if (field === "keywords") continue;
if (input[field] || !(field in person)) {
person[field] = input[field] ?? "";
}
}
if (input.keywords) {
person.keywords = splitKeywords(input.keywords);
} else if (!Array.isArray(person.keywords)) {
person.keywords = [];
}
return person;
}
function quote(value) {
return JSON.stringify(value);
}
function formatPerson(person) {
return ` {
slug: ${quote(person.slug)},
initial: ${quote(person.initial)},
name: ${quote(person.name)},
photo: ${quote(person.photo ?? "")},
location: ${quote(person.location)},
school: ${quote(person.school)},
direction: ${quote(person.direction)},
keywords: [${person.keywords.map(quote).join(", ")}],
text: ${quote(person.text)},
currentStatus: ${quote(person.currentStatus)},
highlight: ${quote(person.highlight)},
toPastSelf: ${quote(person.toPastSelf)},
messageToClass: ${quote(person.messageToClass)},
favoriteMemory: ${quote(person.favoriteMemory)},
contact: ${quote(person.contact ?? "")}
}`;
}
function formatPeopleModule(peopleIntro, people) {
return `export const peopleIntro = {
title: ${quote(peopleIntro.title)},
text: ${quote(peopleIntro.text)}
};
export const people = [
${people.map(formatPerson).join(",\n")}
];
`;
}
const input = parseIssueForm(issueBody);
assertValidInput(input);
const { peopleIntro, people } = readPeopleModule(peoplePath);
const existingIndex = people.findIndex((person) => person.slug === input.slug);
if (existingIndex >= 0) {
people[existingIndex] = buildPerson(input, people[existingIndex]);
console.log(`Updated existing person: ${input.slug}`);
} else {
people.push(buildPerson(input));
console.log(`Added new person: ${input.slug}`);
}
writeFileSync(peoplePath, formatPeopleModule(peopleIntro, people), "utf8");
console.log(`Wrote ${relative(process.cwd(), peoplePath)}`);