ChatGPTSoftwareInfra & DevOpsAI score · PendingAI-assisted edit · no obvious prompt-injection riskn8n.io

n8n Engineering Daily Digest: Jira + GitHub to Slack

Scheduled trigger → parallel Jira and GitHub fetch → OpenAI summary → Slack Block Kit post → optional Google Sheets log

Some experienceStepsWorkflowPromptTip
Who
Engineers, tech leads, or ops who need automated daily engineering updates for their team
You get
Receive a weekday morning Slack digest with Jira movement, GitHub PR status, and blockers; optionally sync to a spreadsheet
You need
n8n · google-sheets · github · slack

Constraints

Prerequisites: n8n instance; credentials for Jira Cloud, GitHub, OpenAI, and Slack; Google Sheets credential only if logging is enabled. Requires access to the Jira project, GitHub repo read scope (fine-grained token needs repo + pull-request read), and Slack channel post permission. Uses gpt-4o-mini with temperature 0.2 and maxTokens 2000.

What they did

intuz published an n8n workflow template that demonstrates how to automatically collect Jira updates from the last 24 hours and stale In Progress tickets, while in parallel fetching recent GitHub PR activity and stale PRs without reviews. The combined data is summarized by OpenAI into structured JSON and posted to a Slack channel as a Block Kit message, with optional logging to Google Sheets. The template includes a complete 35-node workflow JSON, node descriptions, and configuration examples.

How it went

The author did not provide measured runtime data or ROI. The template page shows 40 total views at time of collection. The n8n API pinData includes one sample OpenAI response showing valid JSON output in the required schema (author_report).

Worth knowing

1) This site has not tested the workflow; reproduction=not_tested. 2) The GitHub side uses the review:none search qualifier to cheaply find unreviewed PRs without per-PR API calls. 3) OpenAI output is required to be STRICT JSON, but the template includes a JSON.parse fallback in the Parse LLM Summary node. 4) Google Sheets column names must be aligned by the user (ok, channel, message, message_timestamp); the template does not create the sheet. 5) userMap is optional; when absent the mention function falls back to the raw username or 'unassigned'. 6) The default repos array contains example values and must be replaced with actual repositories.

n8n workflow template

Structure checked · Not run-tested

Use “Import from File” in n8n, or copy the JSON and paste it into the editor. Connect your own accounts, then try test data. The template is inactive by default.

Setup and test checklist

Credentials to connect

  • After import, connect your own credentials on auth-required nodes; author secrets are not included.

Configuration to replace

  • Source template is n8n public workflow #19880 (~35 nodes). Keep inactive after import; verify triggers, expressions and external API endpoints.
  • Sticky-note guidance is informational; replace placeholders with your own accounts, sheet schemas or channel IDs.

Test input and expected output

  • Manually run the main path once in a test workspace and confirm credentialed nodes authenticate without errors.
  • Drive one synthetic/redacted sample through and check output fields and downstream writes before enabling in production.

Template changes

  • Author credential bindings, webhookId and execution data are removed; the workflow is inactive. Nodes, connections and original prompts are preserved. Not run-tested in n8n.
  • No business-logic edits; verify keys, quotas and privacy compliance before use.

Template retrieved: 2026-10-25

Steps

  1. Configure trigger and global parameters

    After importing the template, set the 'When Weekday 9AM' Schedule Trigger with cron 0 9 * * 1-5 and the correct timezone. Then open the 'Set Config Fields' node and fill assignments: jiraBaseUrl (e.g. https://your-domain.atlassian.net), jiraProjectKey, staleTicketDays (default 3), staleReviewHours (default 48), repos (JSON array with owner and name per entry), slackChannel (e.g. #eng-daily-digest), sheetEnabled (true/false), sheetId, sheetTabName, openAiModel (default gpt-4o-mini), and optional userMap (Jira accountId / GitHub login → Slack userId).

  2. Fetch and normalize Jira data in parallel

    The workflow splits into two Jira branches from Config: 1) 'Fetch Jira Recent Activity' uses JQL: project = {{ $json.jiraProjectKey }} AND updated >= -1d ORDER BY updated DESC, returning all results. 2) 'Fetch Jira Stale Tasks' uses JQL: project = {{ $json.jiraProjectKey }} AND status = "In Progress" AND updated <= -{{ $json.staleTicketDays }}d ORDER BY updated ASC. Downstream Code nodes normalize fields to {key, summary, status, assignee, assigneeAccountId, updated, url, daysStale?}, then 'Merge Jira Data' combines both streams into one Jira payload.

    Execution prompt · Source text。Normalize Jira Recent Data node (Code / JavaScript)

    const baseUrl = $('Set Config Fields').item.json.jiraBaseUrl;
    const issues = $input.all().map(item => {
      const issue = item.json;
      return {
        key: issue.key,
        summary: issue.fields?.summary,
        status: issue.fields?.status?.name,
        assignee: issue.fields?.assignee?.displayName || 'Unassigned',
        assigneeAccountId: issue.fields?.assignee?.accountId || null,
        updated: issue.fields?.updated,
        url: `${baseUrl}/browse/${issue.key}`
      };
    });
    return [{ json: { recentIssues: issues } }];

    Execution prompt · Source text。Normalize Jira Stale Data node (Code / JavaScript)

    const baseUrl = $('Set Config Fields').item.json.jiraBaseUrl;
    const now = Date.now();
    const issues = $input.all().map(item => {
      const issue = item.json;
      const updated = new Date(issue.fields?.updated).getTime();
      const daysStale = Math.floor((now - updated) / 86400000);
      return {
        key: issue.key,
        summary: issue.fields?.summary,
        status: issue.fields?.status?.name,
        assignee: issue.fields?.assignee?.displayName || 'Unassigned',
        assigneeAccountId: issue.fields?.assignee?.accountId || null,
        updated: issue.fields?.updated,
        daysStale,
        url: `${baseUrl}/browse/${issue.key}`
      };
    });
    return [{ json: { staleIssues: issues } }];
  3. Fetch and normalize GitHub data in parallel

    The 'Split Repository List' Code node fans out Config.repos into one item per repo and computes sinceISO (24h ago) and staleDateISO. Then two branches run in parallel: 1) 'Fetch GitHub PR Activity' uses the native GitHub node repository.getPullRequests(state=all, sort=updated, direction=desc), then 'Flatten GitHub PR Activity' expands arrays, and 'Normalize GitHub Recent Data' classifies PRs into prsOpened / prsMerged / prsClosed against a 24h cutoff. 2) 'Fetch Stale GitHub PRs' uses an HTTP Request to GET https://api.github.com/search/issues?q=repo:{owner}/{name} is:pr is:open review:none&per_page=50, then 'Flatten GitHub Stale PRs' extracts body.items, and 'Normalize GitHub Stale Data' filters by staleReviewHours and computes hoursOpen. Both streams are combined by 'Merge GitHub Data'.

    Execution prompt · Source text。Split Repository List node (Code / JavaScript)

    const config = $json;
    const sinceISO = new Date(Date.now() - 24*60*60*1000).toISOString();
    const staleHours = config.staleReviewHours || 48;
    const staleDateISO = new Date(Date.now() - staleHours*60*60*1000).toISOString().split('T')[0];
    return (config.repos || []).map(repo => ({
      json: {
        owner: repo.owner,
        name: repo.name,
        fullName: `${repo.owner}/${repo.name}`,
        sinceISO,
        staleDateISO
      }
    }));

    Execution prompt · Source text。Flatten GitHub PR Activity node (Code / JavaScript)

    const results = [];
    for (const item of $input.all()) {
      const body = item.json;
      if (Array.isArray(body)) {
        for (const pr of body) {
          if (pr && pr.number != null) results.push({ json: pr });
        }
      } else if (body && body.number != null) {
        results.push({ json: body });
      }
    }
    return results.length ? results : [{ json: { number: null } }];

    Execution prompt · Source text。Normalize GitHub Recent Data node (Code / JavaScript)

    const now = Date.now();
    const cutoff = now - 24*60*60*1000;
    const prs = $input.all().map(i => i.json).filter(pr => pr && pr.number != null);
    const opened = [], merged = [], closed = [];
    for (const pr of prs) {
      const createdAt = new Date(pr.created_at).getTime();
      const mergedAt = pr.merged_at ? new Date(pr.merged_at).getTime() : null;
      const closedAt = pr.closed_at ? new Date(pr.closed_at).getTime() : null;
      const entry = {
        number: pr.number,
        title: pr.title,
        author: pr.user?.login,
        url: pr.html_url,
        repo: pr.base?.repo?.full_name
      };
      if (createdAt >= cutoff) opened.push(entry);
      if (mergedAt && mergedAt >= cutoff) merged.push(entry);
      if (closedAt && !mergedAt && closedAt >= cutoff) closed.push(entry);
    }
    return [{ json: { prsOpened: opened, prsMerged: merged, prsClosed: closed } }];

    Execution prompt · Source text。Flatten GitHub Stale PRs node (Code / JavaScript)

    const results = [];
    for (const item of $input.all()) {
      const body = item.json;
      const list = Array.isArray(body?.items) ? body.items : [];
      for (const issue of list) {
        if (issue && issue.number != null) results.push({ json: issue });
      }
    }
    return results.length ? results : [{ json: { number: null } }];

    Execution prompt · Source text。Normalize GitHub Stale Data node (Code / JavaScript)

    const staleReviewHours = $('Set Config Fields').item.json.staleReviewHours || 48;
    const cutoffMs = staleReviewHours * 60 * 60 * 1000;
    const now = Date.now();
    const prs = $input.all().map(i => i.json).filter(pr => pr && pr.number != null);
    const stale = prs
      .filter(pr => (now - new Date(pr.created_at).getTime()) > cutoffMs)
      .map(pr => ({
        number: pr.number,
        title: pr.title,
        author: pr.user?.login,
        url: pr.html_url,
        repo: (pr.repository_url || '').split('/repos/')[1],
        hoursOpen: Math.floor((now - new Date(pr.created_at).getTime()) / 3600000)
      }));
    return [{ json: { prsAwaitingReview: stale } }];
  4. Summarize with OpenAI and parse

    'Merge Overall Digest' combines the Jira and GitHub payloads, then passes them to 'Summarize Digest with OpenAI'. The node uses native OpenAI Chat Complete with a system prompt requiring strict JSON output (no markdown, no code fences) containing whatMoved / whatShipped / whatIsBlocked / needsReview / sprintHealth. The user message serializes the merged data as JSON. Parameters: chatModel reads Config.openAiModel (default gpt-4o-mini), maxTokens=2000, temperature=0.2. Downstream 'Parse LLM Summary' parses choices[0].message.content; on failure it returns a degraded summary with error annotations.

    Execution prompt · Source text。Summarize Digest with OpenAI node system message

    You are an engineering-ops assistant. You will be given JSON describing the last 24 hours of Jira and GitHub activity for a software team. Produce a concise daily digest as STRICT JSON only, no markdown, no code fences, matching exactly this shape:
    {
      "whatMoved": "string, 2-4 sentences",
      "whatShipped": "string, 2-4 sentences",
      "whatIsBlocked": "string, 2-4 sentences, mention specific ticket keys",
      "needsReview": "string, 2-4 sentences, mention specific PR numbers/titles",
      "sprintHealth": "one sentence overall read, e.g. On track / At risk / Needs attention, with a short reason"
    }
    Only use the data given. If a category is empty, say so briefly. Do not invent tickets or PRs.

    Execution prompt · Source text。Summarize Digest with OpenAI node user message (expression, evaluated at runtime)

    ={{ JSON.stringify({ staleTicketThresholdDays: $('Set Config Fields').item.json.staleTicketDays, staleReviewThresholdHours: $('Set Config Fields').item.json.staleReviewHours, recentIssues: $json.recentIssues, staleIssues: $json.staleIssues, prsOpened: $json.prsOpened, prsMerged: $json.prsMerged, prsClosed: $json.prsClosed, prsAwaitingReview: $json.prsAwaitingReview }) }}

    Execution prompt · Source text。Parse LLM Summary node (Code / JavaScript)

    const response = $json;
    const digest = $('Merge Overall Digest').item.json;
    let parsed;
    try {
      const content = response.choices[0].message.content;
      parsed = JSON.parse(content);
    } catch (e) {
      parsed = {
        whatMoved: 'Summary unavailable — the LLM response could not be parsed.',
        whatShipped: '',
        whatIsBlocked: '',
        needsReview: '',
        sprintHealth: 'Unknown — check the digest workflow run for errors.'
      };
    }
    return [{ json: { summary: parsed, digest } }];
  5. Build and send Slack Block Kit message

    The 'Build Slack Message Blocks' Code node reads Config.userMap and maps Jira assigneeAccountId / assignee and GitHub author to Slack @mentions (<@USER_ID>). It then assembles Block Kit JSON: a header (📋 Engineering Daily Digest), sprintHealth section, divider, whatMoved / whatShipped / whatIsBlocked / needsReview sections, divider, stale tickets list, PRs awaiting review list, and an optional context block with cc @mentions. 'Post Digest to Slack' sends it with messageType=block to the channel matching Config.slackChannel.

    Execution prompt · Source text。Build Slack Message Blocks node (Code / JavaScript)

    const { summary, digest } = $json;
    const userMap = $('Set Config Fields').item.json.userMap || {};
    
    function mention(idOrLogin) {
      const slackId = userMap[idOrLogin];
      return slackId ? `<@${slackId}>` : (idOrLogin || 'unassigned');
    }
    
    const staleMentions = (digest.staleIssues || []).map(i => mention(i.assigneeAccountId || i.assignee));
    const reviewMentions = (digest.prsAwaitingReview || []).map(p => mention(p.author));
    const allMentions = [...new Set([...staleMentions, ...reviewMentions])].filter(m => m.startsWith('<@'));
    
    const blocks = [
      { type: 'header', text: { type: 'plain_text', text: '📋 Engineering Daily Digest' } },
      { type: 'section', text: { type: 'mrkdwn', text: `*Sprint health:* ${summary.sprintHealth}` } },
      { type: 'divider' },
      { type: 'section', text: { type: 'mrkdwn', text: `*What moved*\n${summary.whatMoved}` } },
      { type: 'section', text: { type: 'mrkdwn', text: `*What shipped*\n${summary.whatShipped}` } },
      { type: 'section', text: { type: 'mrkdwn', text: `*What's blocked*\n${summary.whatIsBlocked}` } },
      { type: 'section', text: { type: 'mrkdwn', text: `*Needs review*\n${summary.needsReview}` } },
      { type: 'divider' },
      { type: 'section', text: { type: 'mrkdwn', text: `*Stale tickets (${(digest.staleIssues||[]).length})*\n` + ((digest.staleIssues||[]).map(i => `• <${i.url}|${i.key}> — ${i.summary} (${i.daysStale}d, ${mention(i.assigneeAccountId || i.assignee)})`).join('\n') || '_none_') } },
      { type: 'section', text: { type: 'mrkdwn', text: `*PRs awaiting review (${(digest.prsAwaitingReview||[]).length})*\n` + ((digest.prsAwaitingReview||[]).map(p => `• <${p.url}|#${p.number} ${p.title}> — ${p.hoursOpen}h, ${mention(p.author)}`).join('\n') || '_none_') } }
    ];
    
    if (allMentions.length) {
      blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: `cc ${allMentions.join(' ')}` }] });
    }
    
    return [{ json: { blocks, text: `Engineering Daily Digest — ${summary.sprintHealth}`, digest, summary } }];
  6. Optional Google Sheets logging

    'If Logging to Sheet Enabled' checks whether Config.sheetEnabled is true. If true, 'Append Row to Google Sheets' appends a row in autoMapInputData mode with fields ok, channel, message, message_timestamp; the target sheet must already contain matching column names. If false, the 'Skip Sheet Logging' noOp branch ends the workflow.

  7. Credentials, invites, and optional user mapping

    1) Add n8n credentials for Jira Software Cloud, GitHub, OpenAI, and Slack (plus Google Sheets if sheetEnabled is true). 2) The GitHub token needs repo and pull-request read scope to support the review:none search. 3) Invite the Slack app/bot to the target channel and confirm it has permission to post messages. 4) Optionally populate Config.userMap with Jira accountId and GitHub login to Slack member ID mappings to enable @mentions in the digest.

Prompt you can copy

Setup guidance · Editorial suggestion。Setup assistant start prompt for environment checks and configuration confirmation before importing the template

This block is the prompt. Ads are never placed here.

You are setting up an n8n engineering daily digest workflow. Confirm in this order:
1) Check that your n8n instance supports the node versions used (scheduleTrigger v1.2, set v3.4, jira v1, github v1.1, openAi v1.1, slack v2.3, googleSheets v4.5, httpRequest v4.2, merge v3, if v2, code v2).
2) Ensure all required credentials are added: Jira Cloud, GitHub, OpenAI, Slack, and optionally Google Sheets.
3) After importing workflow.json, update Set Config Fields with your actual jiraBaseUrl, jiraProjectKey, repos, slackChannel, etc. Note that repos is a JSON array.
4) Verify the Schedule Trigger timezone matches your team's location.
5) Run manually once before enabling the schedule to verify OpenAI returns parseable JSON; if parsing fails, review the degraded output in Parse LLM Summary and adjust the system prompt or maxTokens.
6) If sheet logging is enabled, create the target sheet columns (ok, channel, message, message_timestamp) before running.

Update Config first, then run manually to verify OpenAI JSON parsing

Source

Send engineering daily digest to Slack with Jira, GitHub, OpenAI, and Google Sheets · intuz · n8n.io · 2026-09-24

Comments & feedback

Tried this method? Share results, problems or improvements. Comments appear after moderation. Please avoid sensitive information.

Loading…

    Related