/blog/

2025 1108 Automated GitHub Issues search

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:

  1. I added support for more than one search term
  2. I added the ability to find more than one result per run, which also means we don’t have to run it as often.
  3. 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.js in 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 write to Contents and Issues.
  • Add token as secret to this repo under name MY_PAT.
  • Configure the workflow by editing .github/workflows/search.yml:
    • Set the cron schedule 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_TERMS to a JSON array of search terms to monitor.
    • Set ASSIGN_USER to the GitHub username that should be assigned to new issues, if you like.

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).