From 1fbc685228347994c0c7816b96b3a58b08637e97 Mon Sep 17 00:00:00 2001 From: Robert Brunhage Date: Thu, 5 Dec 2024 14:07:19 +0100 Subject: [PATCH] feat: voting through github --- .github/workflows/create-discussions.yml | 221 +++++++++++++++++++++++ .github/workflows/update-votes.yml | 59 ++++++ src/data/votes.json | 1 + src/pages/index.astro | 18 +- src/utils/votes.ts | 15 ++ 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/create-discussions.yml create mode 100644 .github/workflows/update-votes.yml create mode 100644 src/data/votes.json create mode 100644 src/utils/votes.ts diff --git a/.github/workflows/create-discussions.yml b/.github/workflows/create-discussions.yml new file mode 100644 index 0000000..34fbdaf --- /dev/null +++ b/.github/workflows/create-discussions.yml @@ -0,0 +1,221 @@ +name: Create GitHub Discussions + +on: + pull_request: + types: [opened] + paths: + - 'src/content/apps/**' + workflow_dispatch: + inputs: + createForExisting: + description: 'Create discussions for existing apps' + required: true + default: 'true' + type: boolean + +jobs: + create-discussion: + runs-on: ubuntu-latest + permissions: + discussions: write + pull-requests: read + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Create Discussion for PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const query = `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + discussionCategories(first: 10) { + nodes { + id + name + } + } + } + }`; + + const { repository } = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo + }); + + let category = repository.discussionCategories.nodes.find(c => c.name === "app-votes"); + + if (!category) { + throw new Error("The 'app-votes' category does not exist. Please create it manually in the repository."); + } + + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + const appFiles = files.filter(file => + file.status === 'added' && + file.filename.startsWith('src/content/apps/') && + !file.filename.endsWith('_template.md') + ); + + for (const file of appFiles) { + const content = Buffer.from((await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: file.filename, + ref: context.payload.pull_request.head.sha + })).data.content, 'base64').toString(); + + const frontmatterMatch = content.match(/---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) continue; + + const frontmatter = frontmatterMatch[1]; + const appData = {}; + frontmatter.split('\n').forEach(line => { + const match = line.match(/^(\w+):\s*(?:"([^"]*)"|'([^']*)'|([^"'\s].*))\s*$/); + if (match) { + appData[match[1]] = match[2] || match[3] || match[4]; + } + }); + + try { + if (!appData.name || !appData.author) { + console.log(`Skipping file ${file.filename || file.path}: Missing required fields in frontmatter`); + continue; + } + + const query = `query($searchQuery: String!) { + search(query: $searchQuery, type: DISCUSSION, first: 10) { + nodes { + ... on Discussion { + title + } + } + } + }`; + + const searchResult = await github.graphql(query, { + searchQuery: `repo:${context.repo.owner}/${context.repo.repo} "${appData.name} by ${appData.author}" in:title` + }); + + const existingDiscussion = searchResult.search.nodes.find(node => + node.title === `Vote: ${appData.name} by ${appData.author}` + ); + + if (!existingDiscussion) { + await github.rest.discussions.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Vote: ${appData.name} by ${appData.author}`, + body: `🗳️ **Vote for this app by giving it a 👍 reaction!**\n\n${appData.description}`, + category_id: category.id + }); + console.log(`Created discussion for ${appData.name}`); + } else { + console.log(`Discussion already exists for ${appData.name}`); + } + } catch (error) { + console.error(`Error processing ${file.filename || file.path}:`, error); + continue; + } + } + + - name: Create Discussions for Existing Apps + if: github.event_name == 'workflow_dispatch' && github.event.inputs.createForExisting == 'true' + uses: actions/github-script@v7 + with: + script: | + const query = `query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + discussionCategories(first: 10) { + nodes { + id + name + } + } + } + }`; + + const { repository } = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo + }); + + let category = repository.discussionCategories.nodes.find(c => c.name === "app-votes"); + + if (!category) { + throw new Error("The 'app-votes' category does not exist. Please create it manually in the repository."); + } + + const { data: contents } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'src/content/apps' + }); + + for (const file of contents) { + if (file.type !== 'file' || file.name.endsWith('_template.md')) continue; + + const content = Buffer.from((await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: file.path + })).data.content, 'base64').toString(); + + const frontmatterMatch = content.match(/---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) continue; + + const frontmatter = frontmatterMatch[1]; + const appData = {}; + frontmatter.split('\n').forEach(line => { + const match = line.match(/^(\w+):\s*(?:"([^"]*)"|'([^']*)'|([^"'\s].*))\s*$/); + if (match) { + appData[match[1]] = match[2] || match[3] || match[4]; + } + }); + + try { + if (!appData.name || !appData.author) { + console.log(`Skipping file ${file.filename || file.path}: Missing required fields in frontmatter`); + continue; + } + + const query = `query($searchQuery: String!) { + search(query: $searchQuery, type: DISCUSSION, first: 10) { + nodes { + ... on Discussion { + title + } + } + } + }`; + + const searchResult = await github.graphql(query, { + searchQuery: `repo:${context.repo.owner}/${context.repo.repo} "${appData.name} by ${appData.author}" in:title` + }); + + const existingDiscussion = searchResult.search.nodes.find(node => + node.title === `Vote: ${appData.name} by ${appData.author}` + ); + + if (!existingDiscussion) { + await github.rest.discussions.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Vote: ${appData.name} by ${appData.author}`, + body: `🗳️ **Vote for this app by giving it a 👍 reaction!**\n\n${appData.description}`, + category_id: category.id + }); + console.log(`Created discussion for ${appData.name}`); + } else { + console.log(`Discussion already exists for ${appData.name}`); + } + } catch (error) { + console.error(`Error processing ${file.filename || file.path}:`, error); + continue; + } + } diff --git a/.github/workflows/update-votes.yml b/.github/workflows/update-votes.yml new file mode 100644 index 0000000..f78539c --- /dev/null +++ b/.github/workflows/update-votes.yml @@ -0,0 +1,59 @@ +name: Update Vote Counts + +on: + schedule: + - cron: '0 */6 * * *' # Run every 6 hours + workflow_dispatch: # Allow manual triggers + +jobs: + update-votes: + runs-on: ubuntu-latest + permissions: + contents: write + discussions: read + steps: + - uses: actions/checkout@v4 + + - name: Update Vote Counts + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs').promises; + const path = require('path'); + const yaml = require('js-yaml'); + + // Get all discussions in the Flutter of the Year category + const discussions = await github.paginate(github.rest.discussions.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + category_id: process.env.DISCUSSION_CATEGORY_ID + }); + + // Create votes.json with the current vote counts + const votes = {}; + for (const discussion of discussions) { + if (discussion.title.startsWith('Vote: ')) { + const appName = discussion.title.replace('Vote: ', '').split(' by ')[0]; + // Count thumbs up reactions + const reactions = await github.rest.reactions.listForDiscussion({ + owner: context.repo.owner, + repo: context.repo.repo, + discussion_number: discussion.number + }); + votes[appName] = reactions.data.filter(r => r.content === '+1').length; + } + } + + // Write the votes to a JSON file + await fs.writeFile( + 'src/data/votes.json', + JSON.stringify(votes, null, 2) + ); + + // Commit and push the changes + const date = new Date().toISOString(); + await exec.exec('git', ['config', 'user.name', 'github-actions[bot]']); + await exec.exec('git', ['config', 'user.email', 'github-actions[bot]@users.noreply.github.com']); + await exec.exec('git', ['add', 'src/data/votes.json']); + await exec.exec('git', ['commit', '-m', `Update vote counts - ${date}`]); + await exec.exec('git', ['push']); diff --git a/src/data/votes.json b/src/data/votes.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/data/votes.json @@ -0,0 +1 @@ +{} diff --git a/src/pages/index.astro b/src/pages/index.astro index dd9ee21..852718b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,9 +2,18 @@ import { getCollection } from 'astro:content'; import { Image } from 'astro:assets'; import Layout from '../layouts/Layout.astro'; +import { getVotes, type VoteData } from '../utils/votes'; const currentYear = new Date().getFullYear(); const allApps = await getCollection('apps'); +const votes: VoteData = getVotes(); + +// Sort apps by vote count (descending) +const sortedApps = allApps.sort((a, b) => { + const votesA = votes[a.data.name] || 0; + const votesB = votes[b.data.name] || 0; + return votesB - votesA; +}); --- @@ -51,7 +60,7 @@ const allApps = await getCollection('apps');
- {allApps.map((app) => ( + {sortedApps.map((app) => (
@@ -89,6 +98,13 @@ const allApps = await getCollection('apps'); ))}
+
+

{app.data.name}

+
+ 👍 + {votes[app.data.name] || 0} +
+
))} diff --git a/src/utils/votes.ts b/src/utils/votes.ts new file mode 100644 index 0000000..9205ed5 --- /dev/null +++ b/src/utils/votes.ts @@ -0,0 +1,15 @@ +export interface VoteData { + [appName: string]: number; +} + +export function getVotes(): VoteData { + try { + // During build time, this file will be created by GitHub Actions + // We import it as a module to get the data + const votes = import.meta.glob('/src/data/votes.json', { eager: true }); + return Object.values(votes)[0] as VoteData || {}; + } catch (error) { + console.error('Error reading votes:', error); + return {}; + } +}