Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/actions/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .github/actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"author": "allison-truhlar",
"license": "BSD-3 Clause",
"dependencies": {
"@octokit/graphql": "^8.1.1",
"yaml": "^2.8.3"
}
}
157 changes: 157 additions & 0 deletions .github/actions/update-project-dates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { readFileSync, writeFileSync, readdirSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import yaml from "yaml";
import { graphql } from "@octokit/graphql";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECTS_DIR = join(__dirname, "..", "..", "src", "content", "projects");

const authToken = process.env.GITHUB_TOKEN || process.env.OSSI_SITE_TOKEN;
if (!authToken) {
console.error(
"Missing GITHUB_TOKEN (or OSSI_SITE_TOKEN) environment variable."
);
process.exit(1);
}

const graphqlWithAuth = graphql.defaults({
headers: { authorization: `token ${authToken}` },
});

// extracts the frontmatter block, split into the pieces needed to splice a
// single field back in without reserializing (and reformatting) the rest of it
function extractFrontMatter(content) {
const match = content.match(/^(---\n)([\s\S]+?)(\n---)/);
return match
? { full: match[0], prefix: match[1], body: match[2], suffix: match[3] }
: null;
}

function parseOwnerRepo(githubUrl) {
let url;
try {
url = new URL(githubUrl);
} catch {
return null;
}
const parts = url.pathname.split("/").filter((part) => part !== "");
if (parts.length < 2) return null;
return { owner: parts[0], repo: parts[1].replace(/\.git$/, "") };
}

function upsertField(frontMatterBody, key, value) {
const lineRegex = new RegExp(`^${key}:.*$`, "m");
const newLine = `${key}: ${value}`;
if (lineRegex.test(frontMatterBody)) {
return frontMatterBody.replace(lineRegex, newLine);
}
const githubLinkRegex = /^(github link:.*)$/m;
if (githubLinkRegex.test(frontMatterBody)) {
return frontMatterBody.replace(githubLinkRegex, `$1\n${newLine}`);
}
return `${frontMatterBody}\n${newLine}`;
}

// 1. gather project files and the owner/repo their "github link" points to
const projectFiles = readdirSync(PROJECTS_DIR).filter(
(file) => file.endsWith(".md") && !file.startsWith("_")
);

const projectRepos = [];
for (const file of projectFiles) {
const filePath = join(PROJECTS_DIR, file);
const content = readFileSync(filePath, "utf8");
const frontMatter = extractFrontMatter(content);
if (!frontMatter) {
console.warn(`Skipping ${file}: no frontmatter found.`);
continue;
}

const parsed = yaml.parse(frontMatter.body);
const githubUrl = parsed["github link"];
const ownerRepo = githubUrl ? parseOwnerRepo(githubUrl) : null;
if (!ownerRepo) {
console.warn(
`Skipping ${file}: could not parse "github link" (${githubUrl}).`
);
continue;
}

projectRepos.push({ file, filePath, content, frontMatter, ...ownerRepo });
}

// 2. batch-fetch pushedAt for every unique repo in a single GraphQL call
const repoKey = (owner, repo) => `${owner}/${repo}`.toLowerCase();
const uniqueRepos = [
...new Map(
projectRepos.map((p) => [repoKey(p.owner, p.repo), p])
).values(),
];

const queryParts = [];
const variableDefs = [];
const variables = {};
uniqueRepos.forEach((p, i) => {
queryParts.push(
`r${i}: repository(owner: $owner${i}, name: $name${i}) { nameWithOwner pushedAt }`
);
variableDefs.push(`$owner${i}: String!`, `$name${i}: String!`);
variables[`owner${i}`] = p.owner;
variables[`name${i}`] = p.repo;
});

const query = `query(${variableDefs.join(", ")}) {\n${queryParts.join(
"\n"
)}\n}`;

let result;
try {
result = await graphqlWithAuth(query, variables);
Comment thread
maryLLay marked this conversation as resolved.
} catch (error) {
if (error.data){
console.warn("GraphQL returned partial data:", error.message);
result = error.data;
}
else {
console.error("GraphQL query failed:", error.message);
process.exit(1);
}
}

// 3. build repoDates in memory: repo (owner/name, lowercased) -> pushedAt
const repoDates = {};
uniqueRepos.forEach((p, i) => {
const repoResult = result[`r${i}`];
if (repoResult && repoResult.pushedAt) {
repoDates[repoKey(p.owner, p.repo)] = repoResult.pushedAt;
} else {
console.warn(
`No pushedAt found for ${p.owner}/${p.repo} (referenced by ${p.file})`
);
}
});

// 4. splice the resulting update_date into each project's frontmatter
let updatedCount = 0;
for (const project of projectRepos) {
const pushedAt = repoDates[repoKey(project.owner, project.repo)];
if (!pushedAt) continue;

const updateDate = pushedAt.slice(0, 10); // YYYY-MM-DD
const newBody = upsertField(
project.frontMatter.body,
"update_date",
updateDate
);
if (newBody === project.frontMatter.body) continue;

const newContent = project.content.replace(
project.frontMatter.full,
`${project.frontMatter.prefix}${newBody}${project.frontMatter.suffix}`
);
writeFileSync(project.filePath, newContent);
updatedCount++;
}

console.log(`Updated update_date in ${updatedCount} project file(s).`);
57 changes: 57 additions & 0 deletions .github/workflows/update-project-dates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Update project last-updated dates

# Refreshes the update_date frontmatter field on every project .md file from
# each project's GitHub repository pushedAt timestamp, so the site's
# "Last updated" sort stays current without manual upkeep.
on:
schedule:
- cron: "0 6 * * 1" # every Monday at 06:00 UTC
workflow_dispatch:

permissions:
contents: write
actions: write

jobs:
update-dates:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
cache-dependency-path: .github/actions/package-lock.json

- name: Install npm dependencies
run: npm ci
working-directory: .github/actions

- name: Fetch and update project dates
run: node update-project-dates.js
working-directory: .github/actions
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Commit and push changes
id: commit
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src/content/projects
if git diff --cached --quiet; then
echo "No changes to commit."
else
git commit -m "chore: update project last-updated dates"
git push
Comment thread
maryLLay marked this conversation as resolved.
echo "pushed=true" >> "$GITHUB_OUTPUT"
fi

- name: Trigger deploy
if: steps.commit.outputs.pushed == 'true'
run: gh workflow run deploy-to-github-pages.yml
env:
GH_TOKEN: ${{ github.token }}
15 changes: 13 additions & 2 deletions src/components/projects/CardContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import { selectedTags } from "@stores/selectedTagsStore";
import { selectedProjectType } from "@stores/selectedProjectTypeStore";
import { selectedSort } from "@stores/selectedSortStore";
import { getTagValues } from "@utils/getTags";
import { $projectData } from "@stores/projectSearchResultsStore";
import { $ecosystemData } from "@stores/ecosystemSearchResultsStore";
Expand All @@ -17,6 +18,8 @@ export default function CardContainer({
cardContent,
contentType,
maxLength,
//sortedTitlesMap,
sortOrder
}) {
const [visible, setVisible] = useState("relative");
const [order, setOrder] = useState(0);
Expand All @@ -25,6 +28,7 @@ export default function CardContainer({
const ecosystemData = useStore($ecosystemData);
const $selectedTags = useStore(selectedTags);
const $selectedProjectType = useStore(selectedProjectType);
const $selectedSort = useStore(selectedSort);
const tagsArray = getTagValues(tagsObj);

let contentData = null;
Expand Down Expand Up @@ -53,8 +57,14 @@ export default function CardContainer({
useEffect(() => {
if (typeof window !== "undefined") {
const matchingIndex = findMatchingIndex(contentData, title);
const itemOrder =
urlQuery === "" ? Math.floor(Math.random() * maxLength) : matchingIndex;
let itemOrder;
if (urlQuery !== "") {
itemOrder = matchingIndex;
} else if (sortOrder && Number.isInteger(sortOrder[$selectedSort])) {
itemOrder = sortOrder[$selectedSort];
} else {
itemOrder = Math.floor(Math.random() * maxLength);
}
setOrder(itemOrder);

const isSearchMatch =
Expand Down Expand Up @@ -83,6 +93,7 @@ export default function CardContainer({
contentData,
$selectedProjectType,
$selectedTags,
$selectedSort,
contentType,
projectType,
]);
Expand Down
Loading
Loading