Skip to content

Instantly share code, notes, and snippets.

@jimangel
Last active August 15, 2025 20:57
Show Gist options
  • Save jimangel/457068192e616029bd2564585a45ddd0 to your computer and use it in GitHub Desktop.
Save jimangel/457068192e616029bd2564585a45ddd0 to your computer and use it in GitHub Desktop.
Automatically create gmail labels from GitHub labels

GitHub → Gmail Labels (Minimal + Robust, Allow-List)

Automatically create/add labels to emails using GitHub's X-GitHub-Labels header.

This Apps Script scans recent GitHub notification emails and applies only the labels you allow as Gmail labels (e.g., gh/kind/bug, gh/sig/docs).


What it does

  • Reads X-GitHub-Labels (case-insensitive, folded lines OK).
  • Splits labels on ; or , (outside quotes) and handles "" escaped quotes.
  • Applies Gmail labels as gh/<original-label> (keeps / nesting by default).
  • Allow-list only: labels are applied only if they match your patterns.
  • Adds gh/processed to handled threads.

Example Gmail searches

  • label:"gh/kind/bug"
  • label:"gh/sig/docs"
  • label:"gh/cncf-cla: yes" (quote labels with : or space)
  • label:gh/processed (already handled)

Requirements

  • Gmail + Google Apps Script (free).
  • You receive GitHub notifications from [email protected].

Install

  1. Open Apps Script: https://script.google.comNew project → name it (e.g., GitHub Email Labels).
  2. Paste code: replace Code.gs with the script below:
/**
 * GitHub → Gmail (labels-only, allow-list + ; parsing)
 * - Reads X-GitHub-Labels (case-insensitive, folded headers OK)
 * - Parses labels separated by ";" or "," (outside quotes)
 * - Applies ONLY labels that match ALLOW (exact or prefix* glob)
 * - Applies Gmail labels as: gh/<original-label>
 * - Marks threads with gh/processed so we don't reprocess
 */

const QUERY        = 'from:[email protected] newer_than:30d -label:"gh/processed"';
const MAX_THREADS  = 100;
const ROOT_PREFIX  = 'gh/';
const PROCESSED    = 'gh/processed';
const KEEP_SLASHES = true; // true -> nested labels (gh/area/kubelet). false -> replace "/" with fullwidth slash.

/**
 * EXPLICIT allow-list. Only labels matching one of these patterns will be applied.
 * Matching is case-insensitive.
 * - Exact: "kind/bug"
 * - Prefix glob: "sig/docs*" matches "sig/docs" and "sig/docs/anything"
 * Examples below — edit for your needs.
 */
const ALLOW = [
  'kind/bug',
  'kind/cleanup',
  'kind/documentation',
  'priority/important-soon',
  'release-note*',
  'sig/docs*',
  'help wanted',
  'good first issue',
  'cncf-cla: yes'
];

function processGitHubEmails() {
  const threads = GmailApp.search(QUERY, 0, MAX_THREADS);
  const processedLabel = getOrCreateLabel(PROCESSED);

  threads.forEach(thread => {
    try {
      const message = thread.getMessages().pop();
      const header = getGitHubLabelsHeader(message);
      if (!header) {
        thread.addLabel(processedLabel);
        return;
      }

      // Parse labels split by ";" or "," outside quotes
      const parsed = splitQuotedMulti(header, [';', ',']);

      // Trim + de-dupe (case-insensitive), then filter by ALLOW
      const seen = new Set();
      for (const raw of parsed) {
        const lbl = raw.trim();
        if (!lbl) continue;

        const key = lbl.toLowerCase();
        if (seen.has(key)) continue;
        seen.add(key);

        if (!isAllowed(lbl)) continue; // <- only apply allowed labels

        const finalSegment = KEEP_SLASHES ? lbl : lbl.replace(/\//g, '/');
        thread.addLabel(getOrCreateLabel(`${ROOT_PREFIX}${finalSegment}`));
      }

      // mark as processed so future runs skip it (without changing read state)
      thread.addLabel(processedLabel);

    } catch (e) {
      console.log(`Error processing thread ${thread.getId()}: ${e && e.message}`);
    }
  });
}

// ---- Helpers ----

// Case-insensitive header fetch with folded-lines fallback
function getGitHubLabelsHeader(message) {
  const direct =
    message.getHeader('X-GitHub-Labels') ||
    message.getHeader('X-Github-Labels');
  if (direct) return direct;

  const raw = message.getRawContent();
  return extractFoldedHeader(raw, 'x-github-labels');
}

function extractFoldedHeader(raw, headerNameLower) {
  const lines = raw.split(/\r?\n/);
  let collecting = false, value = '';
  const startRe = new RegExp(`^${escapeRe(headerNameLower)}\\s*:`, 'i');
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!collecting) {
      if (startRe.test(line)) {
        collecting = true;
        value = line.replace(startRe, '').trim();
      }
    } else {
      if (/^[ \t]/.test(line)) {
        value += ' ' + line.trim();
      } else {
        break;
      }
    }
  }
  return value || null;
}

// Split on any of the delimiters provided (e.g., ";" or ",") outside quotes.
// Supports double-quote escaping with "" inside quoted segments.
function splitQuotedMulti(input, delimiters) {
  if (!input) return [];
  const delimSet = new Set(delimiters);
  const out = [];
  let cur = '';
  let inQ = false;

  for (let i = 0; i < input.length; i++) {
    const ch = input[i];

    if (ch === '"') {
      if (inQ && input[i + 1] === '"') { // escaped quote
        cur += '"'; i++;
      } else {
        inQ = !inQ;
      }
      continue;
    }

    if (!inQ && delimSet.has(ch)) {
      out.push(cur.trim());
      cur = '';
      continue;
    }

    cur += ch;
  }
  if (cur.trim()) out.push(cur.trim());
  return out.filter(Boolean);
}

// Allow-list matcher: case-insensitive exact or prefix* glob
function isAllowed(label) {
  const ll = label.toLowerCase();
  for (const pat of ALLOW) {
    const p = String(pat || '').toLowerCase().trim();
    if (!p) continue;

    if (p.endsWith('*')) {
      const prefix = p.slice(0, -1);
      if (ll.startsWith(prefix)) return true;
    } else if (ll === p) {
      return true;
    }
  }
  return false;
}

// Labels are created lazily; idempotent
function getOrCreateLabel(name) {
  let label = GmailApp.getUserLabelByName(name);
  if (!label) label = GmailApp.createLabel(name);
  return label;
}

function escapeRe(s) {
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Manual runner
function test() { processGitHubEmails(); }
  1. Save (disk icon).

First run (authorize)

image
  1. In the functions dropdown (bar), choose test (or processGitHubEmails) → Run → authorize → Allow.
  2. Check View → Executions (or Logs) for results.

On first run, labels are created on demand and gh/processed is added to processed threads.


Automate

  1. Click Triggers (clock) → Add Trigger

    • Function: processGitHubEmails
    • Event source: Time-driven
    • Type: Minutes timer
    • Interval: Every 5 minutes (or your preference)
  2. Save.

image

Testing & Verification

After running one test, you should see your sidebar has labels now:

image

Uninstall

  • Delete the time-based trigger.
  • Optionally delete gh/... labels in Gmail.
  • Remove the Apps Script project.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment