Create Community Contribution Issue #15804
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |