Skip to content

Create Community Contribution Issue #15804

Create Community Contribution Issue

Create Community Contribution Issue #15804

name: Create Community Contribution Issue
on:
schedule:
- cron: '*/17 * * * *'
workflow_dispatch:
inputs:
force_type:
description: 'Force issue type: theme | fact | proverb | haiku | trivia | grammar | animeQuote | videoGameQuote | idiom | regionalDialect | falseFriend | culturalEtiquette | exampleSentence | commonMistake | wallpaperUrl | communityNote'
required: false
type: string
dry_run:
description: 'Do not create an issue; only log selection'
required: false
type: boolean
permissions:
actions: read
contents: write
issues: write
pull-requests: write
# Prevent multiple runs from overlapping and causing race conditions
concurrency:
group: community-issue-creation
cancel-in-progress: false
jobs:
backup-guard:
runs-on: ubuntu-24.04
outputs:
should_run: ${{ steps.guard.outputs.should_run }}
steps:
- name: Decide whether backup run is needed
id: guard
uses: actions/github-script@v7
with:
github-token: ${{ secrets.AUTOMATION_PR_TOKEN || secrets.GITHUB_TOKEN }}
script: |
if (context.eventName !== 'schedule') {
core.setOutput('should_run', 'true');
return;
}
const maxAgeMs = 25 * 60 * 1000;
const workflowId = 'hourly-community-issue.yml';
const now = Date.now();
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
event: 'workflow_dispatch',
branch: 'main',
per_page: 100,
});
const recentSuccess = runs.find(function(run) {
if (run.status !== 'completed' || run.conclusion !== 'success') {
return false;
}
return (now - Date.parse(run.created_at)) <= maxAgeMs;
});
const shouldRun = recentSuccess ? 'false' : 'true';
console.log(
recentSuccess
? `Recent workflow_dispatch success found at ${recentSuccess.created_at}; skipping scheduled backup run.`
: 'No recent workflow_dispatch success found; running scheduled backup.',
);
core.setOutput('should_run', shouldRun);
create-issue:
needs: backup-guard
runs-on: ubuntu-24.04
timeout-minutes: 2
if: github.repository == 'lingdojo/kana-dojo' && needs.backup-guard.outputs.should_run == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.AUTOMATION_PR_TOKEN }}
ref: main
fetch-depth: 0
- name: Create community issue
id: create-issue
uses: actions/github-script@v7
with:
github-token: ${{ secrets.AUTOMATION_PR_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const templates = require('./.github/templates/messages.cjs');
const emojiBank = require('./.github/templates/emojis.cjs');
const t = templates.issueCreation;
const forceType = (context.payload.inputs && context.payload.inputs.force_type) ? String(context.payload.inputs.force_type).trim() : '';
const dryRun = !!(context.payload.inputs && (context.payload.inputs.dry_run === true || context.payload.inputs.dry_run === 'true'));
function pickEmoji() {
return emojiBank[Math.floor(Math.random() * emojiBank.length)];
}
function findRandomEligible(items, predicate) {
if (!Array.isArray(items) || items.length === 0) {
return null;
}
const eligible = items.filter(predicate);
if (eligible.length === 0) {
return null;
}
const randomIndex = Math.floor(Math.random() * eligible.length);
return eligible[randomIndex];
}
async function resolveMilestoneNumber() {
const configuredMilestoneTitle = t.common.milestoneTitle;
const fallbackMilestoneNumber = t.common.milestoneNumber;
if (!configuredMilestoneTitle) {
return fallbackMilestoneNumber;
}
const targetTitle = String(configuredMilestoneTitle).trim().toLowerCase();
try {
const milestones = await github.paginate(github.rest.issues.listMilestones, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const matchedMilestone = milestones.find(function(milestone) {
return String(milestone.title || '').trim().toLowerCase() === targetTitle;
});
if (matchedMilestone) {
return matchedMilestone.number;
}
console.log(`Milestone titled "${configuredMilestoneTitle}" was not found among open milestones; falling back to milestone number ${fallbackMilestoneNumber}.`);
} catch (e) {
console.log(`Could not resolve milestone title "${configuredMilestoneTitle}": ${e.message}. Falling back to milestone number ${fallbackMilestoneNumber}.`);
}
return fallbackMilestoneNumber;
}
const statePath = 'community/backlog/automation-state.json';
const themeBacklogPath = 'community/backlog/theme-backlog.json';
const factsBacklogPath = 'community/backlog/facts-backlog.json';
const proverbsBacklogPath = 'community/backlog/proverbs-backlog.json';
const haikuBacklogPath = 'community/backlog/haiku-backlog.json';
const triviaBacklogPath = 'community/backlog/trivia-backlog.json';
const grammarBacklogPath = 'community/backlog/grammar-backlog.json';
const animeQuotesBacklogPath = 'community/backlog/anime-quotes-backlog.json';
const idiomsBacklogPath = 'community/backlog/idioms-backlog.json';
const regionalDialectsBacklogPath = 'community/backlog/regional-dialects-backlog.json';
const falseFriendsBacklogPath = 'community/backlog/false-friends-backlog.json';
const culturalEtiquetteBacklogPath = 'community/backlog/cultural-etiquette-backlog.json';
const exampleSentencesBacklogPath = 'community/backlog/example-sentences-backlog.json';
const commonMistakesBacklogPath = 'community/backlog/common-mistakes-backlog.json';
const videoGameQuotesBacklogPath = 'community/backlog/video-game-quotes-backlog.json';
const wallpaperUrlsBacklogPath = 'community/backlog/wallpaper-urls-backlog.json';
const communityNotesBacklogPath = 'community/backlog/community-notes-backlog.json';
const themesSourcePath = 'community/content/community-themes.json';
const factsSourcePath = 'community/content/japan-facts.json';
const proverbsSourcePath = 'community/content/japanese-proverbs.json';
const haikuSourcePath = 'community/content/japanese-haiku.json';
const triviaSourcePaths = {
easy: 'community/content/japan-trivia-easy.json',
medium: 'community/content/japan-trivia-medium.json',
hard: 'community/content/japan-trivia-hard.json'
};
const triviaLegacyPath = 'community/content/japan-trivia.json';
const grammarSourcePath = 'community/content/japanese-grammar.json';
const animeQuotesSourcePath = 'community/content/anime-quotes.json';
const idiomsSourcePath = 'community/content/japanese-idioms.json';
const regionalDialectsSourcePath = 'community/content/japanese-regional-dialects.json';
const falseFriendsSourcePath = 'community/content/japanese-false-friends.json';
const culturalEtiquetteSourcePath = 'community/content/japanese-cultural-etiquette.json';
const exampleSentencesSourcePath = 'community/content/japanese-example-sentences.json';
const commonMistakesSourcePath = 'community/content/japanese-common-mistakes.json';
const videoGameQuotesSourcePath = 'community/content/japanese-videogame-quotes.json';
const wallpaperUrlsSourcePath = 'community/content/community-wallpaper-urls.json';
let state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const themes = JSON.parse(fs.readFileSync(themeBacklogPath, 'utf8'));
const facts = JSON.parse(fs.readFileSync(factsBacklogPath, 'utf8'));
const proverbs = JSON.parse(fs.readFileSync(proverbsBacklogPath, 'utf8'));
const haiku = JSON.parse(fs.readFileSync(haikuBacklogPath, 'utf8'));
const trivia = JSON.parse(fs.readFileSync(triviaBacklogPath, 'utf8'));
const grammar = JSON.parse(fs.readFileSync(grammarBacklogPath, 'utf8'));
const animeQuotes = JSON.parse(fs.readFileSync(animeQuotesBacklogPath, 'utf8'));
const idioms = JSON.parse(fs.readFileSync(idiomsBacklogPath, 'utf8'));
const regionalDialects = JSON.parse(fs.readFileSync(regionalDialectsBacklogPath, 'utf8'));
const falseFriends = JSON.parse(fs.readFileSync(falseFriendsBacklogPath, 'utf8'));
const culturalEtiquette = JSON.parse(fs.readFileSync(culturalEtiquetteBacklogPath, 'utf8'));
const exampleSentences = JSON.parse(fs.readFileSync(exampleSentencesBacklogPath, 'utf8'));
const commonMistakes = JSON.parse(fs.readFileSync(commonMistakesBacklogPath, 'utf8'));
const videoGameQuotes = JSON.parse(fs.readFileSync(videoGameQuotesBacklogPath, 'utf8'));
const wallpaperUrls = JSON.parse(fs.readFileSync(wallpaperUrlsBacklogPath, 'utf8'));
const communityNotes = JSON.parse(fs.readFileSync(communityNotesBacklogPath, 'utf8'));
if (typeof state.consecutiveNoIssueRuns !== 'number') {
state.consecutiveNoIssueRuns = 0;
}
let backlogChanged = false;
let existingFacts = [];
if (fs.existsSync(factsSourcePath)) {
try {
existingFacts = JSON.parse(fs.readFileSync(factsSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${factsSourcePath}: ${e.message}`);
}
}
let existingProverbs = [];
if (fs.existsSync(proverbsSourcePath)) {
try {
existingProverbs = JSON.parse(fs.readFileSync(proverbsSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${proverbsSourcePath}: ${e.message}`);
}
}
let existingHaiku = [];
if (fs.existsSync(haikuSourcePath)) {
try {
existingHaiku = JSON.parse(fs.readFileSync(haikuSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${haikuSourcePath}: ${e.message}`);
}
}
let existingTrivia = [];
const triviaSourceFiles = [
...Object.values(triviaSourcePaths),
triviaLegacyPath
];
triviaSourceFiles.forEach(function(path) {
if (fs.existsSync(path)) {
try {
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
if (Array.isArray(data)) {
existingTrivia = existingTrivia.concat(data);
}
} catch (e) {
console.log(`Could not parse ${path}: ${e.message}`);
}
}
});
let existingGrammar = [];
if (fs.existsSync(grammarSourcePath)) {
try {
existingGrammar = JSON.parse(fs.readFileSync(grammarSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${grammarSourcePath}: ${e.message}`);
}
}
let existingAnimeQuotes = [];
if (fs.existsSync(animeQuotesSourcePath)) {
try {
existingAnimeQuotes = JSON.parse(fs.readFileSync(animeQuotesSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${animeQuotesSourcePath}: ${e.message}`);
}
}
let existingIdioms = [];
if (fs.existsSync(idiomsSourcePath)) {
try {
existingIdioms = JSON.parse(fs.readFileSync(idiomsSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${idiomsSourcePath}: ${e.message}`);
}
}
let existingRegionalDialects = [];
if (fs.existsSync(regionalDialectsSourcePath)) {
try {
existingRegionalDialects = JSON.parse(fs.readFileSync(regionalDialectsSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${regionalDialectsSourcePath}: ${e.message}`);
}
}
let existingFalseFriends = [];
if (fs.existsSync(falseFriendsSourcePath)) {
try {
existingFalseFriends = JSON.parse(fs.readFileSync(falseFriendsSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${falseFriendsSourcePath}: ${e.message}`);
}
}
let existingCulturalEtiquette = [];
if (fs.existsSync(culturalEtiquetteSourcePath)) {
try {
existingCulturalEtiquette = JSON.parse(fs.readFileSync(culturalEtiquetteSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${culturalEtiquetteSourcePath}: ${e.message}`);
}
}
let existingExampleSentences = [];
if (fs.existsSync(exampleSentencesSourcePath)) {
try {
existingExampleSentences = JSON.parse(fs.readFileSync(exampleSentencesSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${exampleSentencesSourcePath}: ${e.message}`);
}
}
let existingCommonMistakes = [];
if (fs.existsSync(commonMistakesSourcePath)) {
try {
existingCommonMistakes = JSON.parse(fs.readFileSync(commonMistakesSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${commonMistakesSourcePath}: ${e.message}`);
}
}
let existingVideoGameQuotes = [];
if (fs.existsSync(videoGameQuotesSourcePath)) {
try {
existingVideoGameQuotes = JSON.parse(fs.readFileSync(videoGameQuotesSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${videoGameQuotesSourcePath}: ${e.message}`);
}
}
let existingWallpaperUrls = [];
if (fs.existsSync(wallpaperUrlsSourcePath)) {
try {
existingWallpaperUrls = JSON.parse(fs.readFileSync(wallpaperUrlsSourcePath, 'utf8'));
} catch (e) {
console.log(`Could not parse ${wallpaperUrlsSourcePath}: ${e.message}`);
}
}
let themesSourceContent = '';
let existingThemes = [];
if (fs.existsSync(themesSourcePath)) {
try {
themesSourceContent = fs.readFileSync(themesSourcePath, 'utf8');
const parsedThemes = JSON.parse(themesSourceContent);
if (Array.isArray(parsedThemes)) {
existingThemes = parsedThemes;
}
} catch (e) {
console.log(`Could not read ${themesSourcePath}: ${e.message}`);
}
}
async function hasOpenIssueForQuery(query) {
const q = `${query} repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:${templates.labels.community}`;
try {
const res = await github.rest.search.issuesAndPullRequests({
q: q,
per_page: 1
});
return res.data && res.data.total_count > 0;
} catch (e) {
console.log(`Search query failed: ${q} :: ${e.message}`);
return false;
}
}
const defaultOrder = ['theme', 'fact', 'proverb', 'haiku', 'trivia', 'grammar', 'animeQuote', 'videoGameQuote', 'idiom', 'regionalDialect', 'falseFriend', 'culturalEtiquette', 'exampleSentence', 'commonMistake', 'wallpaperUrl', 'communityNote'];
const typeOrder = forceType && defaultOrder.includes(forceType)
? [forceType]
: defaultOrder;
const currentIndex = typeOrder.indexOf(state.lastType);
let item, issueTitle, issueBody;
let selectedType = null;
for (let offset = 1; offset <= typeOrder.length; offset += 1) {
const nextType = typeOrder[((currentIndex === -1 ? -1 : currentIndex) + offset) % typeOrder.length];
item = null;
issueTitle = null;
issueBody = null;
if (nextType === 'theme') {
// Skip items that are already issued OR completed OR already exist in main
item = findRandomEligible(themes, function(th) {
if (th.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more themes available in backlog.');
if (backlogChanged) {
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
}
continue;
}
// Check for duplicate theme issue
const duplicateTheme = await hasOpenIssueForQuery(`"Color Theme" "${item.name}"`);
if (duplicateTheme) {
console.log(`Issue already exists for theme "${item.name}": Marking as issued and skipping.`);
const themeIdx = themes.findIndex(function(th) { return th.id === item.id; });
themes[themeIdx].issued = true;
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.theme;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{name}', item.name));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle, { step2: tmpl.step2, step3: tmpl.step3 }).map((instr, i) => `${i + 1}. ${instr.replace(/{name}/g, item.name)}`);
const codeBlock = `\`\`\`typescript\n{\n id: '${item.id}',\n backgroundColor: '${item.backgroundColor}',\n mainColor: '${item.mainColor}',\n secondaryColor: '${item.secondaryColor}'\n},\n\`\`\``;
issueBody = `${tmpl.header.replace('{name}', item.name)}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### 🎯 Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.detailsHeader}\n\n| Property | Value |\n|----------|-------|\n| **ID** | \`${item.id}\` |\n| **Background** | \`${item.backgroundColor}\` |\n| **Main Color** | \`${item.mainColor}\` |\n| **Secondary** | \`${item.secondaryColor}\` |\n\n> ${tmpl.vibeLabel} ${item.description}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const themeIndex = themes.findIndex(function(th) { return th.id === item.id; });
themes[themeIndex].issued = true;
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
backlogChanged = true;
} else if (nextType === 'fact') {
item = findRandomEligible(facts, function(f) {
if (f.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more facts available in backlog.');
if (backlogChanged) {
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
}
continue;
}
// Check for duplicate fact issue
const duplicateFact = await hasOpenIssueForQuery(`"Fact about Japan" "${item.id}"`);
if (duplicateFact) {
console.log(`Issue already exists for fact #${item.id}: Marking as issued and skipping.`);
const factIdx = facts.findIndex(function(f) { return f.id === item.id; });
facts[factIdx].issued = true;
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.fact;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n"${item.fact}"\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### 🎯 Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.factHeader}\n\n> ${item.fact}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const factIndex = facts.findIndex(function(f) { return f.id === item.id; });
facts[factIndex].issued = true;
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
backlogChanged = true;
} else if (nextType === 'proverb') {
// Proverb
item = findRandomEligible(proverbs, function(p) {
if (p.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more proverbs available in backlog.');
if (backlogChanged) {
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
}
continue;
}
// Check for duplicate proverb issue
const duplicateProverb = await hasOpenIssueForQuery(`"Japanese Proverb" "${item.id}"`);
if (duplicateProverb) {
console.log(`Issue already exists for proverb #${item.id}: Marking as issued and skipping.`);
const proverbIdx = proverbs.findIndex(function(p) { return p.id === item.id; });
proverbs[proverbIdx].issued = true;
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.proverb;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "japanese": "${item.japanese}",\n "romaji": "${item.romaji}",\n "english": "${item.english}",\n "meaning": "${item.meaning}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### 🎯 Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.proverbHeader}\n\n| Japanese | Reading | English |\n|----------|---------|---------|\n| **${item.japanese}** | ${item.romaji} | ${item.english} |\n\n> 💡 **Meaning:** ${item.meaning}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const proverbIndex = proverbs.findIndex(function(p) { return p.id === item.id; });
proverbs[proverbIndex].issued = true;
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
backlogChanged = true;
} else if (nextType === 'haiku') {
item = findRandomEligible(haiku, function(h) {
if (h.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more haiku available in backlog.');
if (backlogChanged) {
fs.writeFileSync(haikuBacklogPath, JSON.stringify(haiku, null, 2));
}
continue;
}
const duplicateHaiku = await hasOpenIssueForQuery(`"Classic Japanese Haiku" "${item.id}"`);
if (duplicateHaiku) {
console.log(`Issue already exists for haiku #${item.id}: Marking as issued and skipping.`);
const haikuIdx = haiku.findIndex(function(h) { return h.id === item.id; });
haiku[haikuIdx].issued = true;
fs.writeFileSync(haikuBacklogPath, JSON.stringify(haiku, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.haiku;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "japanese": "${item.japanese}",\n "romaji": "${item.romaji}",\n "english": "${item.english}",\n "poet": "${item.poet}",\n "season": "${item.season}",\n "kigo": "${item.kigo}",\n "notes": "${item.notes}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.haikuHeader}\n\n> ${item.japanese.replace(/\n/g, '\n> ')}\n\n**Romaji:** ${item.romaji} \n**English:** ${item.english} \n**Poet:** ${item.poet} \n**Season:** ${item.season} \n**Kigo:** ${item.kigo} \n**Notes:** ${item.notes}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const haikuIndex = haiku.findIndex(function(h) { return h.id === item.id; });
haiku[haikuIndex].issued = true;
fs.writeFileSync(haikuBacklogPath, JSON.stringify(haiku, null, 2));
backlogChanged = true;
} else if (nextType === 'trivia') {
item = findRandomEligible(trivia, function(q) {
if (q.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more trivia questions available in backlog.');
if (backlogChanged) {
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
fs.writeFileSync(triviaBacklogPath, JSON.stringify(trivia, null, 2));
fs.writeFileSync(grammarBacklogPath, JSON.stringify(grammar, null, 2));
}
continue;
}
const duplicateTrivia = await hasOpenIssueForQuery(`"Trivia Question" "${item.id}"`);
if (duplicateTrivia) {
console.log(`Issue already exists for trivia #${item.id}: Marking as issued and skipping.`);
const triviaIdx = trivia.findIndex(function(q) { return q.id === item.id; });
trivia[triviaIdx].issued = true;
fs.writeFileSync(triviaBacklogPath, JSON.stringify(trivia, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.trivia;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const difficultyFile = triviaSourcePaths[item.difficulty] || triviaSourcePaths.easy;
const instructions = t.buildInstructions(tmpl.file.replace('{difficultyFile}', difficultyFile), tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const answers = Array.isArray(item.answers) ? item.answers : [];
const codeBlock = `\`\`\`json\n{\n "question": "${item.question}",\n "difficulty": "${item.difficulty}",\n "answers": [\n "${answers.join('\",\n \"')}"\n ],\n "correctIndex": ${item.correctIndex}\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### 🎯 Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.triviaHeader}\n\n**Question:** ${item.question}\n\n**Answers:**\n${answers.map(function(a, idx) { return `${idx + 1}. ${a}`; }).join('\n')}\n\n**Correct Answer Index:** ${item.correctIndex}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const triviaIndex = trivia.findIndex(function(q) { return q.id === item.id; });
trivia[triviaIndex].issued = true;
fs.writeFileSync(triviaBacklogPath, JSON.stringify(trivia, null, 2));
backlogChanged = true;
} else if (nextType === 'grammar') {
// Grammar
item = findRandomEligible(grammar, function(g) {
if (g.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more grammar points available in backlog.');
if (backlogChanged) {
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
fs.writeFileSync(triviaBacklogPath, JSON.stringify(trivia, null, 2));
fs.writeFileSync(grammarBacklogPath, JSON.stringify(grammar, null, 2));
}
continue;
}
const duplicateGrammar = await hasOpenIssueForQuery(`"Grammar Point" "${item.id}"`);
if (duplicateGrammar) {
console.log(`Issue already exists for grammar #${item.id}: Marking as issued and skipping.`);
const grammarIdx = grammar.findIndex(function(g) { return g.id === item.id; });
grammar[grammarIdx].issued = true;
fs.writeFileSync(grammarBacklogPath, JSON.stringify(grammar, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.grammar;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n"${item.text}"\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### 🎯 Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.grammarHeader}\n\n> ${item.text}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const grammarIndex = grammar.findIndex(function(g) { return g.id === item.id; });
grammar[grammarIndex].issued = true;
fs.writeFileSync(grammarBacklogPath, JSON.stringify(grammar, null, 2));
backlogChanged = true;
} else if (nextType === 'animeQuote') {
// Anime Quote
item = findRandomEligible(animeQuotes, function(q) {
if (q.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more anime quotes available in backlog.');
if (backlogChanged) {
fs.writeFileSync(themeBacklogPath, JSON.stringify(themes, null, 2));
fs.writeFileSync(factsBacklogPath, JSON.stringify(facts, null, 2));
fs.writeFileSync(proverbsBacklogPath, JSON.stringify(proverbs, null, 2));
fs.writeFileSync(triviaBacklogPath, JSON.stringify(trivia, null, 2));
fs.writeFileSync(grammarBacklogPath, JSON.stringify(grammar, null, 2));
fs.writeFileSync(animeQuotesBacklogPath, JSON.stringify(animeQuotes, null, 2));
}
continue;
}
const duplicateQuote = await hasOpenIssueForQuery(`"Anime Quote" "${item.id}"`);
if (duplicateQuote) {
console.log(`Issue already exists for anime quote #${item.id}: Marking as issued and skipping.`);
const quoteIdx = animeQuotes.findIndex(function(q) { return q.id === item.id; });
animeQuotes[quoteIdx].issued = true;
fs.writeFileSync(animeQuotesBacklogPath, JSON.stringify(animeQuotes, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.animeQuote;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n \"japanese\": \"${item.japanese}\",\n \"romaji\": \"${item.romaji}\",\n \"english\": \"${item.english}\",\n \"anime\": \"${item.anime}\",\n \"character\": \"${item.character}\"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### 🎯 Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.quoteHeader}\n\n| Japanese | Romaji | English |\n|----------|--------|---------|\n| **${item.japanese}** | ${item.romaji} | ${item.english} |\n\n**Anime:** ${item.anime} \n**Character:** ${item.character}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const quoteIndex = animeQuotes.findIndex(function(q) { return q.id === item.id; });
animeQuotes[quoteIndex].issued = true;
fs.writeFileSync(animeQuotesBacklogPath, JSON.stringify(animeQuotes, null, 2));
backlogChanged = true;
} else if (nextType === 'videoGameQuote') {
item = findRandomEligible(videoGameQuotes, function(q) {
if (q.issued) {
return false;
}
return true;
});
if (!item) {
console.log('No more video game quotes available in backlog.');
if (backlogChanged) {
fs.writeFileSync(videoGameQuotesBacklogPath, JSON.stringify(videoGameQuotes, null, 2));
}
continue;
}
const duplicateQuote = await hasOpenIssueForQuery(`"Video Game Quote" "${item.id}"`);
if (duplicateQuote) {
const quoteIdx = videoGameQuotes.findIndex(function(q) { return q.id === item.id; });
videoGameQuotes[quoteIdx].issued = true;
fs.writeFileSync(videoGameQuotesBacklogPath, JSON.stringify(videoGameQuotes, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.videoGameQuote;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "japanese": "${item.japanese}",\n "romaji": "${item.romaji}",\n "english": "${item.english}",\n "game": "${item.game}",\n "character": "${item.character}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.gameQuoteHeader}\n\n| Japanese | Romaji | English |\n|----------|--------|---------|\n| **${item.japanese}** | ${item.romaji} | ${item.english} |\n\n**Game:** ${item.game} \n**Character:** ${item.character}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const quoteIndex = videoGameQuotes.findIndex(function(q) { return q.id === item.id; });
videoGameQuotes[quoteIndex].issued = true;
fs.writeFileSync(videoGameQuotesBacklogPath, JSON.stringify(videoGameQuotes, null, 2));
backlogChanged = true;
} else if (nextType === 'idiom') {
item = findRandomEligible(idioms, function(i) {
if (i.issued || i.completed) {
return false;
}
if (Array.isArray(existingIdioms)) {
const exists = existingIdioms.some(function(ei) {
if (!ei || typeof ei !== 'object') {
return false;
}
return ei.japanese === i.japanese && ei.romaji === i.romaji && ei.english === i.english;
});
if (exists) {
const idiomIdx = idioms.findIndex(function(ii) { return ii.id === i.id; });
idioms[idiomIdx].completed = true;
backlogChanged = true;
return false;
}
}
return true;
});
if (!item) {
console.log('No more idioms available in backlog.');
if (backlogChanged) {
fs.writeFileSync(idiomsBacklogPath, JSON.stringify(idioms, null, 2));
}
continue;
}
const duplicateIdiom = await hasOpenIssueForQuery(`"Japanese Idiom" "${item.id}"`);
if (duplicateIdiom) {
console.log(`Issue already exists for idiom #${item.id}: Marking as issued and skipping.`);
const idiomIdx = idioms.findIndex(function(i) { return i.id === item.id; });
idioms[idiomIdx].issued = true;
fs.writeFileSync(idiomsBacklogPath, JSON.stringify(idioms, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.idiom;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "japanese": "${item.japanese}",\n "romaji": "${item.romaji}",\n "english": "${item.english}",\n "meaning": "${item.meaning}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.idiomHeader}\n\n| Japanese | Romaji | English |\n|----------|--------|---------|\n| **${item.japanese}** | ${item.romaji} | ${item.english} |\n\n> **Meaning:** ${item.meaning}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const idiomIndex = idioms.findIndex(function(i) { return i.id === item.id; });
idioms[idiomIndex].issued = true;
fs.writeFileSync(idiomsBacklogPath, JSON.stringify(idioms, null, 2));
backlogChanged = true;
} else if (nextType === 'regionalDialect') {
item = findRandomEligible(regionalDialects, function(d) {
if (d.issued || d.completed) {
return false;
}
if (Array.isArray(existingRegionalDialects)) {
const exists = existingRegionalDialects.some(function(ed) {
if (!ed || typeof ed !== 'object') {
return false;
}
return ed.dialect === d.dialect && ed.standardJapanese === d.standardJapanese && ed.region === d.region;
});
if (exists) {
const dialectIdx = regionalDialects.findIndex(function(di) { return di.id === d.id; });
regionalDialects[dialectIdx].completed = true;
backlogChanged = true;
return false;
}
}
return true;
});
if (!item) {
console.log('No more regional dialect entries available in backlog.');
if (backlogChanged) {
fs.writeFileSync(regionalDialectsBacklogPath, JSON.stringify(regionalDialects, null, 2));
}
continue;
}
const duplicateDialect = await hasOpenIssueForQuery(`"Regional Dialect Entry" "${item.id}"`);
if (duplicateDialect) {
console.log(`Issue already exists for regional dialect #${item.id}: Marking as issued and skipping.`);
const dialectIdx = regionalDialects.findIndex(function(d) { return d.id === item.id; });
regionalDialects[dialectIdx].issued = true;
fs.writeFileSync(regionalDialectsBacklogPath, JSON.stringify(regionalDialects, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.regionalDialect;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "dialect": "${item.dialect}",\n "standardJapanese": "${item.standardJapanese}",\n "english": "${item.english}",\n "region": "${item.region}",\n "note": "${item.note}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.dialectHeader}\n\n| Dialect | Standard Japanese | English | Region |\n|---------|-------------------|---------|--------|\n| **${item.dialect}** | ${item.standardJapanese} | ${item.english} | ${item.region} |\n\n> **Note:** ${item.note}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const dialectIndex = regionalDialects.findIndex(function(d) { return d.id === item.id; });
regionalDialects[dialectIndex].issued = true;
fs.writeFileSync(regionalDialectsBacklogPath, JSON.stringify(regionalDialects, null, 2));
backlogChanged = true;
} else if (nextType === 'falseFriend') {
item = findRandomEligible(falseFriends, function(f) {
if (f.issued || f.completed) {
return false;
}
if (Array.isArray(existingFalseFriends)) {
const exists = existingFalseFriends.some(function(ef) {
if (!ef || typeof ef !== 'object') {
return false;
}
return ef.termA === f.termA && ef.termB === f.termB;
});
if (exists) {
const falseFriendIdx = falseFriends.findIndex(function(fi) { return fi.id === f.id; });
falseFriends[falseFriendIdx].completed = true;
backlogChanged = true;
return false;
}
}
return true;
});
if (!item) {
console.log('No more false friends available in backlog.');
if (backlogChanged) {
fs.writeFileSync(falseFriendsBacklogPath, JSON.stringify(falseFriends, null, 2));
}
continue;
}
const duplicateFalseFriend = await hasOpenIssueForQuery(`"Japanese False Friend" "${item.id}"`);
if (duplicateFalseFriend) {
const falseFriendIdx = falseFriends.findIndex(function(f) { return f.id === item.id; });
falseFriends[falseFriendIdx].issued = true;
fs.writeFileSync(falseFriendsBacklogPath, JSON.stringify(falseFriends, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.falseFriend;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "termA": "${item.termA}",\n "termB": "${item.termB}",\n "explanation": "${item.explanation}",\n "example": "${item.example}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.falseFriendHeader}\n\n**Term A:** ${item.termA} \n**Term B:** ${item.termB}\n\n> **Explanation:** ${item.explanation}\n> **Example:** ${item.example}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const falseFriendIndex = falseFriends.findIndex(function(f) { return f.id === item.id; });
falseFriends[falseFriendIndex].issued = true;
fs.writeFileSync(falseFriendsBacklogPath, JSON.stringify(falseFriends, null, 2));
backlogChanged = true;
} else if (nextType === 'culturalEtiquette') {
item = findRandomEligible(culturalEtiquette, function(e) {
if (e.issued || e.completed) {
return false;
}
if (Array.isArray(existingCulturalEtiquette)) {
const exists = existingCulturalEtiquette.some(function(ec) {
if (!ec || typeof ec !== 'object') {
return false;
}
return ec.situation === e.situation && ec.do === e.do && ec.dont === e.dont;
});
if (exists) {
const etiquetteIdx = culturalEtiquette.findIndex(function(ei) { return ei.id === e.id; });
culturalEtiquette[etiquetteIdx].completed = true;
backlogChanged = true;
return false;
}
}
return true;
});
if (!item) {
console.log('No more cultural etiquette entries available in backlog.');
if (backlogChanged) {
fs.writeFileSync(culturalEtiquetteBacklogPath, JSON.stringify(culturalEtiquette, null, 2));
}
continue;
}
const duplicateEtiquette = await hasOpenIssueForQuery(`"Cultural Etiquette Tip" "${item.id}"`);
if (duplicateEtiquette) {
const etiquetteIdx = culturalEtiquette.findIndex(function(e) { return e.id === item.id; });
culturalEtiquette[etiquetteIdx].issued = true;
fs.writeFileSync(culturalEtiquetteBacklogPath, JSON.stringify(culturalEtiquette, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.culturalEtiquette;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "situation": "${item.situation}",\n "do": "${item.do}",\n "dont": "${item.dont}",\n "note": "${item.note}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.etiquetteHeader}\n\n**Situation:** ${item.situation}\n\n- Do: ${item.do}\n- Avoid: ${item.dont}\n\n> **Note:** ${item.note}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const etiquetteIndex = culturalEtiquette.findIndex(function(e) { return e.id === item.id; });
culturalEtiquette[etiquetteIndex].issued = true;
fs.writeFileSync(culturalEtiquetteBacklogPath, JSON.stringify(culturalEtiquette, null, 2));
backlogChanged = true;
} else if (nextType === 'exampleSentence') {
item = findRandomEligible(exampleSentences, function(e) {
if (e.issued || e.completed) {
return false;
}
if (Array.isArray(existingExampleSentences)) {
const exists = existingExampleSentences.some(function(ee) {
if (!ee || typeof ee !== 'object') {
return false;
}
return ee.japanese === e.japanese && ee.romaji === e.romaji && ee.english === e.english;
});
if (exists) {
const sentenceIdx = exampleSentences.findIndex(function(ei) { return ei.id === e.id; });
exampleSentences[sentenceIdx].completed = true;
backlogChanged = true;
return false;
}
}
return true;
});
if (!item) {
console.log('No more example sentences available in backlog.');
if (backlogChanged) {
fs.writeFileSync(exampleSentencesBacklogPath, JSON.stringify(exampleSentences, null, 2));
}
continue;
}
const duplicateSentence = await hasOpenIssueForQuery(`"Example Sentence" "${item.id}"`);
if (duplicateSentence) {
const sentenceIdx = exampleSentences.findIndex(function(e) { return e.id === item.id; });
exampleSentences[sentenceIdx].issued = true;
fs.writeFileSync(exampleSentencesBacklogPath, JSON.stringify(exampleSentences, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.exampleSentence;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "japanese": "${item.japanese}",\n "romaji": "${item.romaji}",\n "english": "${item.english}",\n "jlpt": "${item.jlpt}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.sentenceHeader}\n\n| Japanese | Romaji | English | JLPT |\n|----------|--------|---------|------|\n| **${item.japanese}** | ${item.romaji} | ${item.english} | ${item.jlpt} |\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const sentenceIndex = exampleSentences.findIndex(function(e) { return e.id === item.id; });
exampleSentences[sentenceIndex].issued = true;
fs.writeFileSync(exampleSentencesBacklogPath, JSON.stringify(exampleSentences, null, 2));
backlogChanged = true;
} else if (nextType === 'commonMistake') {
item = findRandomEligible(commonMistakes, function(m) {
if (m.issued || m.completed) {
return false;
}
if (Array.isArray(existingCommonMistakes)) {
const exists = existingCommonMistakes.some(function(em) {
if (!em || typeof em !== 'object') {
return false;
}
return em.wrong === m.wrong && em.correct === m.correct;
});
if (exists) {
const mistakeIdx = commonMistakes.findIndex(function(mi) { return mi.id === m.id; });
commonMistakes[mistakeIdx].completed = true;
backlogChanged = true;
return false;
}
}
return true;
});
if (!item) {
console.log('No more common mistakes available in backlog.');
if (backlogChanged) {
fs.writeFileSync(commonMistakesBacklogPath, JSON.stringify(commonMistakes, null, 2));
}
continue;
}
const duplicateMistake = await hasOpenIssueForQuery(`"Common Learner Mistake" "${item.id}"`);
if (duplicateMistake) {
const mistakeIdx = commonMistakes.findIndex(function(m) { return m.id === item.id; });
commonMistakes[mistakeIdx].issued = true;
fs.writeFileSync(commonMistakesBacklogPath, JSON.stringify(commonMistakes, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.commonMistake;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = t.buildInstructions(tmpl.file, tmpl.itemType, tmpl.prTitle).map((instr, i) => `${i + 1}. ${instr.replace(/{id}/g, item.id)}`);
const codeBlock = `\`\`\`json\n{\n "wrong": "${item.wrong}",\n "correct": "${item.correct}",\n "explanation": "${item.explanation}"\n}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.mistakeHeader}\n\n**Wrong:** ${item.wrong}\n\n**Correct:** ${item.correct}\n\n> **Why:** ${item.explanation}\n\n${common.instructionsHeader}\n\n${instructions.slice(0, 4).join('\n')}\n\n${codeBlock}\n\n${instructions.slice(4).join('\n')}\n\n---\n\n${common.footer}`;
const mistakeIndex = commonMistakes.findIndex(function(m) { return m.id === item.id; });
commonMistakes[mistakeIndex].issued = true;
fs.writeFileSync(commonMistakesBacklogPath, JSON.stringify(commonMistakes, null, 2));
backlogChanged = true;
} else if (nextType === 'wallpaperUrl') {
item = findRandomEligible(wallpaperUrls, function(entry) {
if (entry.issued || entry.completed) {
return false;
}
if (Array.isArray(existingWallpaperUrls) && existingWallpaperUrls.includes(entry.url)) {
const urlIdx = wallpaperUrls.findIndex(function(ui) { return ui.id === entry.id; });
wallpaperUrls[urlIdx].completed = true;
backlogChanged = true;
return false;
}
return true;
});
if (!item) {
console.log('No more wallpaper URLs available in backlog.');
if (backlogChanged) {
fs.writeFileSync(wallpaperUrlsBacklogPath, JSON.stringify(wallpaperUrls, null, 2));
}
continue;
}
const duplicateWallpaperUrl = await hasOpenIssueForQuery(`"Wallpaper URL #${item.id}"`);
if (duplicateWallpaperUrl) {
const urlIdx = wallpaperUrls.findIndex(function(entry) { return entry.id === item.id; });
wallpaperUrls[urlIdx].issued = true;
fs.writeFileSync(wallpaperUrlsBacklogPath, JSON.stringify(wallpaperUrls, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.wallpaperUrl;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = [
'1. Star our repo ⭐',
'2. Fork our repo 🍴',
`3. Open [\`${tmpl.file}\`](../blob/main/${tmpl.file}) in your browser`,
'4. Paste the following JSON string just before the closing `]`',
'5. Add a comma after the previous last entry if needed so the JSON stays valid',
'6. Save the file and commit the changes',
`7. Submit a Pull Request with title: \`${tmpl.prTitle.replace('{id}', item.id)}\``,
'8. Link this issue using `Closes #<issue_number>`',
'9. Wait for review!',
];
const codeBlock = `\`\`\`json\n"${item.url}"\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.urlHeader}\n\n${codeBlock}\n\n${common.instructionsHeader}\n\n${instructions.join('\n')}\n\n---\n\n${common.footer}`;
const wallpaperIndex = wallpaperUrls.findIndex(function(entry) { return entry.id === item.id; });
wallpaperUrls[wallpaperIndex].issued = true;
fs.writeFileSync(wallpaperUrlsBacklogPath, JSON.stringify(wallpaperUrls, null, 2));
backlogChanged = true;
} else if (nextType === 'communityNote') {
item = findRandomEligible(communityNotes, function(entry) {
return !entry.issued && !entry.completed;
});
if (!item) {
console.log('No more community note items available in backlog.');
continue;
}
const duplicateCommunityNote = await hasOpenIssueForQuery(`"Community Note Line #${item.id}"`);
if (duplicateCommunityNote) {
const noteIdx = communityNotes.findIndex(function(entry) { return entry.id === item.id; });
communityNotes[noteIdx].issued = true;
fs.writeFileSync(communityNotesBacklogPath, JSON.stringify(communityNotes, null, 2));
backlogChanged = true;
continue;
}
const tmpl = t.communityNote;
const common = t.common;
issueTitle = t.buildIssueTitle(pickEmoji(), tmpl.title.replace('{id}', item.id));
const instructions = [
'1. Star our repo ⭐',
'2. Fork our repo 🍴',
`3. Open [\`${item.file}\`](../blob/main/${item.file}) in your browser`,
`4. Add the following line to the ${item.position} of the file`,
'5. Save the file and commit the changes',
`6. Submit a Pull Request with title: \`${tmpl.prTitle.replace('{id}', item.id)}\``,
'7. Link this issue using `Closes #<issue_number>`',
'8. Wait for review!',
];
const codeBlock = `\`\`\`md\n${item.text}\n\`\`\``;
issueBody = `${tmpl.header}\n\n**Category:** ${tmpl.category} \n**Difficulty:** ${common.difficulty} \n**Estimated Time:** ${tmpl.estimatedTime}\n\n---\n\n### Your Task\n\n${tmpl.taskDescription}\n\n${tmpl.noteHeader}\n\n**File:** \`${item.file}\` \n**Position:** ${item.position}\n\n${codeBlock}\n\n${common.instructionsHeader}\n\n${instructions.join('\n')}\n\n---\n\n${common.footer}`;
const noteIndex = communityNotes.findIndex(function(entry) { return entry.id === item.id; });
communityNotes[noteIndex].issued = true;
fs.writeFileSync(communityNotesBacklogPath, JSON.stringify(communityNotes, null, 2));
backlogChanged = true;
}
if (issueTitle && issueBody && item) {
selectedType = nextType;
break;
}
}
const backlogFileMap = {
theme: themeBacklogPath,
fact: factsBacklogPath,
proverb: proverbsBacklogPath,
haiku: haikuBacklogPath,
trivia: triviaBacklogPath,
grammar: grammarBacklogPath,
animeQuote: animeQuotesBacklogPath,
idiom: idiomsBacklogPath,
regionalDialect: regionalDialectsBacklogPath,
falseFriend: falseFriendsBacklogPath,
culturalEtiquette: culturalEtiquetteBacklogPath,
exampleSentence: exampleSentencesBacklogPath,
commonMistake: commonMistakesBacklogPath,
videoGameQuote: videoGameQuotesBacklogPath,
wallpaperUrl: wallpaperUrlsBacklogPath,
communityNote: communityNotesBacklogPath,
};
if (!selectedType) {
console.log('No available items found across all community backlogs.');
state.lastRun = new Date().toISOString();
state.consecutiveNoIssueRuns = (state.consecutiveNoIssueRuns || 0) + 1;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
core.setOutput('issue_created', 'false');
core.setOutput('reason', 'no_available_items');
core.setOutput('consecutive_no_issue_runs', String(state.consecutiveNoIssueRuns));
core.setOutput('should_commit', backlogChanged ? 'true' : 'false');
core.setOutput('notify_discord', state.consecutiveNoIssueRuns >= 8 ? 'true' : 'false');
return;
}
if (dryRun) {
console.log(`Dry run enabled; would create a ${selectedType} issue: ${issueTitle}`);
state.lastType = selectedType;
state.lastRun = new Date().toISOString();
state.consecutiveNoIssueRuns = 0;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
core.setOutput('issue_created', 'false');
core.setOutput('reason', 'dry_run');
core.setOutput('selected_type', selectedType);
core.setOutput('should_commit', backlogChanged ? 'true' : 'false');
core.setOutput('backlog_file', backlogFileMap[selectedType] || 'community/backlog/');
core.setOutput('notify_discord', 'false');
return;
}
// Randomly pick 2-3 labels from the secondary pool
function pickRandomLabels(pool, count) {
const shuffled = [...pool].sort(function() { return Math.random() - 0.5; });
return shuffled.slice(0, count);
}
const secondaryCount = 2 + Math.floor(Math.random() * 2); // 2 or 3
const selectedSecondary = pickRandomLabels(templates.labels.secondaryIssuePool, secondaryCount);
const labels = [...templates.labels.coreIssue, ...selectedSecondary];
if (selectedType === 'wallpaperUrl' || selectedType === 'communityNote') {
labels.push('documentation');
}
// Prepend body preamble (§6.2.1–6.2.4: H1 header, badges, callout, metadata)
const preambleLabelMap = {
theme: item && item.name ? `Color Theme: ${item.name}` : 'Color Theme',
fact: 'Japan Fact',
proverb: 'Japanese Proverb',
haiku: 'Japanese Haiku',
trivia: 'Trivia Question',
grammar: 'Grammar Point',
animeQuote: 'Anime Quote',
videoGameQuote: 'Video Game Quote',
idiom: 'Japanese Idiom',
regionalDialect: 'Dialect Entry',
falseFriend: 'False Friend Pair',
culturalEtiquette: 'Etiquette Tip',
exampleSentence: 'Example Sentence',
commonMistake: 'Learner Mistake',
wallpaperUrl: 'Wallpaper URL',
communityNote: 'Community Note Line',
};
const preambleLabel = preambleLabelMap[selectedType] || selectedType;
const preambleId = selectedType !== 'theme' ? (item && item.id) : null;
issueBody = t.buildBodyPreamble(preambleLabel, preambleId) + issueBody;
const resolvedMilestoneNumber = await resolveMilestoneNumber();
const createIssuePayload = {
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body: issueBody,
type: 'Task'
};
if (typeof resolvedMilestoneNumber === 'number') {
createIssuePayload.milestone = resolvedMilestoneNumber;
}
const response = await github.rest.issues.create(createIssuePayload);
console.log(`Created issue #${response.data.number}: ${issueTitle}`);
// Pass labels to the next step via output (labels/reactions/comments use GITHUB_TOKEN)
core.setOutput('issue_labels', labels.join(','));
state.lastType = selectedType;
state.lastRun = new Date().toISOString();
state.totalIssuesCreated = (state.totalIssuesCreated || 0) + 1;
state.lastIssueCreatedAt = new Date().toISOString();
state.consecutiveNoIssueRuns = 0;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
core.setOutput('issue_number', response.data.number);
core.setOutput('issue_type', selectedType);
core.setOutput('item_id', item.id);
core.setOutput('issue_created', 'true');
core.setOutput('reason', 'created');
core.setOutput('consecutive_no_issue_runs', String(state.consecutiveNoIssueRuns));
core.setOutput('should_commit', 'true');
core.setOutput('notify_discord', 'false');
core.setOutput('backlog_file', backlogFileMap[selectedType] || 'community/backlog/');
- name: Apply labels, reactions, and welcome comment
if: steps.create-issue.outputs.issue_created == 'true'
uses: actions/github-script@v7
env:
ISSUE_NUMBER: ${{ steps.create-issue.outputs.issue_number }}
ISSUE_LABELS: ${{ steps.create-issue.outputs.issue_labels }}
with:
github-token: ${{ secrets.AUTOMATION_PR_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const issueNumber = Number(process.env.ISSUE_NUMBER);
const labels = process.env.ISSUE_LABELS.split(',').filter(Boolean);
const templates = require('./.github/templates/messages.cjs');
const t = templates.issueCreation;
const reactionPool = ['+1', '-1', 'laugh', 'hooray', 'confused', 'heart', 'rocket', 'eyes'];
function pickRandomReactions(count) {
const shuffled = [...reactionPool].sort(function() { return Math.random() - 0.5; });
return shuffled.slice(0, Math.min(count, reactionPool.length));
}
// Apply labels
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels
});
console.log(`Applied labels: ${labels.join(', ')}`);
} catch (e) {
console.log(`Could not apply labels: ${e.message}`);
}
// Add five random unique reactions to issue
for (const content of pickRandomReactions(5)) {
try {
await github.rest.reactions.createForIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
content
});
} catch (e) {
console.log(`Reaction ${content} failed: ${e.message}`);
}
}
// Add welcome comment and five random unique reactions on that comment
try {
const comment = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: t.common.welcomeComment
});
for (const content of pickRandomReactions(5)) {
try {
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.data.id,
content
});
} catch (reactionError) {
console.log(`Welcome comment reaction ${content} failed: ${reactionError.message}`);
}
}
} catch (e) {
console.log(`Welcome comment failed: ${e.message}`);
}
- name: Commit backlog updates directly to main
if: steps.create-issue.outputs.should_commit == 'true'
run: |
git config user.name "てんとう虫"
git config user.email "reservecrate@gmail.com"
git add community/backlog/
if git diff --cached --quiet; then
echo "No backlog changes to commit"
exit 0
fi
commit_msg="chore(automation): mark item as issued"
max_attempts=5
attempt=1
while [ "$attempt" -le "$max_attempts" ]; do
git commit -m "$commit_msg"
if git pull --rebase origin main && git push origin HEAD:main; then
echo "Backlog push succeeded on attempt $attempt"
exit 0
fi
echo "Backlog push attempt $attempt failed; retrying..."
git rebase --abort || true
git fetch origin main
git reset --hard origin/main
git add community/backlog/
if git diff --cached --quiet; then
echo "No backlog changes remain after sync"
exit 0
fi
attempt=$((attempt + 1))
done
echo "Failed to push backlog updates after $max_attempts attempts"
exit 1
- name: Notify Discord if no issues are being created
if: steps.create-issue.outputs.notify_discord == 'true'
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL || secrets.DISCORD_WEBHOOK }}
REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
COUNT: ${{ steps.create-issue.outputs.consecutive_no_issue_runs }}
run: |
if [ -z "$DISCORD_WEBHOOK" ]; then
echo "No Discord webhook configured; skipping"
exit 0
fi
python3 << 'PY' > /tmp/payload.json
import json
import os
repo = os.environ.get('REPO', 'unknown')
run_url = os.environ.get('RUN_URL', '')
count = os.environ.get('COUNT', 'unknown')
payload = {
"content": f"⚠️ Community issue creation has not created any issues for {count} consecutive runs in `{repo}`.\n{run_url}"
}
print(json.dumps(payload))
PY
curl -sf -H "Content-Type: application/json" -X POST -d @/tmp/payload.json "$DISCORD_WEBHOOK"