Terence Eden wanted to know if his website was ever mentioned in a GitHub issue. This sounded like a good way to procrastinate, so I decided that I wanted to know that too.
In pursuit of that goal, I couldn’t stop myself from making a few improvements:
- I added support for more than one search term
- I added the ability to find more than one result per run, which also means we don’t have to run it as often.
- I put all the logic into JavaScript, which avoids bash and combines several actionsteps into one. I also split that JavaScript out into a separate file which keeps the workflow file small and makes the logic easier to edit in an IDE.
GitHub workflow
name: API Issue Watcher
on:
schedule:
# Once per day at 05:39 UTC, picked a random time that wasn't on minute 0
- cron: "39 5 * * *"
# Allow manual triggering from GitHub UI from the master branch
workflow_dispatch:
branches:
- master
# Automatically run on every push to master branch
push:
branches:
- master
permissions:
issues: write
contents: write
jobs:
watch-and-create:
runs-on: ubuntu-latest
env:
# JSON array of search terms to query GitHub API
# Each search term will be queried separately
SEARCH_TERMS: '["micahrl.com", "keymap.click"]'
# ASSIGN_USER: mrled
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore latest seen ID
id: cache-latest
uses: actions/cache@v4
with:
path: .github/latest_seen.txt
key: latest-seen
- name: Fetch items from API and process new ones
id: fetch
uses: actions/github-script@v7
with:
github-token: ${{ secrets.MY_PAT }}
script: |
const script = require('./script.js');
await script({ github, context, core });
- name: Update cache with new latest seen ID
if: steps.fetch.outputs.HAS_NEW_ITEMS == 'true'
run: |
echo "Updated latest seen ID, cache will be saved"
- name: Save cache
uses: actions/cache@v4
with:
path: .github/latest_seen.txt
key: latest-seen
JavaScript logic
/**
* Fetch items from GitHub API and create issues for new ones
*
* This script is used by the API Issue Watcher GitHub Action to:
* 1. Fetch search results from GitHub API
* 2. Compare with previously seen items
* 3. Create new issues for any new search results
* 4. Update the latest seen ID
*/
const fs = require("fs");
/**
* Read the last seen IDs from cache file
* @returns {Object} Object mapping search terms to their last seen IDs
*/
function readLastSeenIds() {
try {
const content = fs.readFileSync(".github/latest_seen.txt", "utf8").trim();
return JSON.parse(content);
} catch (e) {
console.log("No previous IDs found, will process all items");
return {};
}
}
/**
* Save the last seen IDs to cache file
* @param {Object} lastSeenIds - Object mapping search terms to their last seen IDs
*/
function saveLastSeenIds(lastSeenIds) {
fs.mkdirSync(".github", { recursive: true });
fs.writeFileSync(
".github/latest_seen.txt",
JSON.stringify(lastSeenIds, null, 2),
);
}
/**
* Fetch items from GitHub API for a specific search term
* @param {string} searchTerm - The search term to query
* @returns {Promise<Array>} Array of items from the API response
*/
async function fetchItemsForSearchTerm(searchTerm) {
const encodedTerm = encodeURIComponent(searchTerm);
const response = await fetch(
`https://api.github.com/search/issues?q=${encodedTerm}&s=created&order=desc`,
);
const data = await response.json();
return data.items || [];
}
/**
* Find new items that haven't been seen before
* @param {Array} items - All items from the API
* @param {string} lastSeenId - The ID of the last item we processed
* @returns {Array} Array of new items
*/
function findNewItems(items, lastSeenId) {
const newItems = [];
for (const item of items) {
if (item.id.toString() === lastSeenId) {
break;
}
newItems.push(item);
}
return newItems;
}
/**
* Create GitHub issues for new items
* @param {Array} items - Items to create issues for
* @param {string} searchTerm - The search term that found these items
* @param {Object} github - GitHub API client
* @param {Object} context - GitHub Actions context
* @param {string} assignUser - Username to assign the issue to
*/
async function createIssuesForItems(
items,
searchTerm,
github,
context,
assignUser,
) {
// Process in reverse order so oldest is created first
for (const item of items.reverse()) {
console.log(`Creating issue for: ${item.title}`);
// Format body as block quote
const quotedBody = item.body
? item.body
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
: "";
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[API: ${searchTerm}] ${item.title}`,
body: `Found new item: [${item.title}](${item.html_url})\n\n${quotedBody}`,
assignees: assignUser ? [assignUser] : [],
});
}
}
/**
* Process a single search term: fetch items, find new ones, and create issues
* @param {string} searchTerm - The search term to process
* @param {string} lastSeenId - The last seen ID for this search term
* @param {Object} github - GitHub API client
* @param {Object} context - GitHub Actions context
* @param {string} assignUser - Username to assign the issue to
* @returns {Promise<Object>} Object with newItemsCount and latestId
*/
async function processSearchTerm(
searchTerm,
lastSeenId,
github,
context,
assignUser,
) {
console.log(`\n=== Processing search term: "${searchTerm}" ===`);
console.log(`Old ID for "${searchTerm}": ${lastSeenId || "(none)"}`);
// Fetch items from API
const items = await fetchItemsForSearchTerm(searchTerm);
console.log(`Total items found: ${items.length}`);
if (items.length === 0) {
console.log(`No items found for "${searchTerm}"`);
return { newItemsCount: 0, latestId: null };
}
// Find new items
const newItems = findNewItems(items, lastSeenId);
console.log(`New items to process: ${newItems.length}`);
// Create issues for new items
if (newItems.length > 0) {
await createIssuesForItems(
newItems,
searchTerm,
github,
context,
assignUser,
);
const latestId = items[0].id.toString();
console.log(`Updated latest seen ID for "${searchTerm}" to: ${latestId}`);
return { newItemsCount: newItems.length, latestId };
}
return { newItemsCount: 0, latestId: null };
}
/**
* Main coordination function - processes all search terms
*/
module.exports = async ({ github, context, core }) => {
// Parse configuration
const searchTerms = JSON.parse(process.env.SEARCH_TERMS || "[]");
const assignUser = process.env.ASSIGN_USER;
if (searchTerms.length === 0) {
console.log("No search terms configured");
core.setOutput("NEW_ITEMS_COUNT", 0);
core.setOutput("HAS_NEW_ITEMS", "false");
return;
}
console.log(
`Processing ${searchTerms.length} search term(s): ${searchTerms.join(", ")}`,
);
// Read last seen IDs
const lastSeenIds = readLastSeenIds();
// Process each search term
let totalNewItemsCount = 0;
let hasAnyNewItems = false;
for (const searchTerm of searchTerms) {
const lastSeenId = lastSeenIds[searchTerm] || "";
const { newItemsCount, latestId } = await processSearchTerm(
searchTerm,
lastSeenId,
github,
context,
assignUser,
);
if (newItemsCount > 0) {
lastSeenIds[searchTerm] = latestId;
totalNewItemsCount += newItemsCount;
hasAnyNewItems = true;
}
}
// Save results and set outputs
if (hasAnyNewItems) {
saveLastSeenIds(lastSeenIds);
console.log(`\n=== Summary ===`);
console.log(
`Total new items across all search terms: ${totalNewItemsCount}`,
);
core.setOutput("NEW_ITEMS_COUNT", totalNewItemsCount);
core.setOutput("HAS_NEW_ITEMS", "true");
} else {
console.log("\n=== Summary ===");
console.log("No new items found for any search term");
core.setOutput("NEW_ITEMS_COUNT", 0);
core.setOutput("HAS_NEW_ITEMS", "false");
}
};
To make this work:
- Create a new repository.
- Save the JavaScript logic to
script.jsin the repository root and the workflow YAML to.github/workflows/search.yml. - Set repository to private (so that it doesn’t notify all the repos it links to in issues).
- Give actions read and write permissions under Repository Settings -> Actions -> General.
- Create a new Personal Access Token.
- GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens.
- Set scope to the repo in question, and allow
Read and writetoContentsandIssues.
- Add token as secret to this repo under name
MY_PAT. - Configure the workflow by editing
.github/workflows/search.yml:- Set the
cronschedule at the top to something that makes sense for you. It would be polite to run this on a minute that isn’t divisible by 5. - Set
SEARCH_TERMSto a JSON array of search terms to monitor. - Set
ASSIGN_USERto the GitHub username that should be assigned to new issues, if you like.
- Set the