自动化
This commit is contained in:
@@ -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: 可填写微信、邮箱或留空
|
||||||
@@ -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 表单即可再次触发。"
|
||||||
|
});
|
||||||
@@ -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)}`);
|
||||||
Reference in New Issue
Block a user