Skip to content

Filter Function Plugin Library

JavaScript plugins that enable custom filter functions for gitStream. To learn how to use these examples, read our guide on how to use gitStream plugins.

askAI

A gitStream plugin to interact with AI models. Currently works with ChatGPR-4o-mini

Example PR description

Returns: Object - Returns the AI-generated response based on the provided context and prompt. License: MIT

Param Type Description
context Object The context that needs to be sent to the AI model for analysis.
role string Free text. If not empty, Defines the role or persona for the AI to adopt while generating the response.
prompt string The specific request or question you want the AI to respond to, after the context has been provided.
token Object The token to the AI model

Example

Encoding output

The output of AI models may be lengthy, which might cause issues when setting the comment. We recommend using the encode filter function, as shown in the example, to ensure that the comment is passed fully. The add-comment action automatically decodes encoded strings.

{{ source | askAI("QA tester", "Based on the given context, search for new functions without tests and suggest the tests to add. If all functions are covered completely, return 'no tests to suggest.'", env.OPEN_AI_TOKEN) | encode }}
Plugin Code: askAI

/**
 * @module askAI
 * @description A gitStream plugin to interact with AI models. Currently works with `ChatGPR-4o-mini`.
 * @param {Object} context - The context that will be attached to the prompt .
 * @param {string} role - Role instructions for the conversation.
 * @param {string} prompt - The prompt string.
 * @param {Object} token - The token to the AI model.
 * @returns {Object} Returns the response from the AI model.
 * @example {{ branch | generateDescription(pr, repo, source) }}
 * @license MIT
 * */

const MAX_TOKENS = 4096;
const OPEN_AI_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
const LOCK_FILES = [
  'package-lock.json',
  'yarn.lock',
  'npm-shrinkwrap.json',
  'Pipfile.lock',
  'poetry.lock',
  'conda-lock.yml',
  'Gemfile.lock',
  'composer.lock',
  'packages.lock.json',
  'project.assets.json',
  'pom.xml',
  'Cargo.lock',
  'mix.lock',
  'pubspec.lock',
  'go.sum',
  'stack.yaml.lock',
  'vcpkg.json',
  'conan.lock',
  'ivy.xml',
  'project.clj',
  'Podfile.lock',
  'Cartfile.resolved',
  'flake.lock',
  'pnpm-lock.yaml'
];
const EXCLUDE_EXPRESSIONS_LIST = [
  '.*\\.(ini|csv|xls|xlsx|xlr|doc|docx|txt|pps|ppt|pptx|dot|dotx|log|tar|rtf|dat|ipynb|po|profile|object|obj|dxf|twb|bcsymbolmap|tfstate|pdf|rbi|pem|crt|svg|png|jpeg|jpg|ttf)$',
  '.*(package-lock|packages\\.lock|package)\\.json$',
  '.*(yarn|gemfile|podfile|cargo|composer|pipfile|gopkg)\\.lock$',
  '.*gradle\\.lockfile$',
  '.*lock\\.sbt$',
  '.*dist/.*\\.js',
  '.*public/assets/.*\\.js',
  '.*ci\\.yml$'
];
const IGNORE_FILES_REGEX_LIST = [
  ...LOCK_FILES.map(f => f.replace('.', '\\.')),
  ...EXCLUDE_EXPRESSIONS_LIST
];
const EXCLUDE_PATTERN = new RegExp(IGNORE_FILES_REGEX_LIST.join('|'));

/**
 * @description Check if a file should be excluded from the context like "package-lock.json"
 * @param {*} fileObject
 * @returns returns true if the file should be excluded
 */
const shouldExcludeFile = fileObject => {
  const shouldExludeByName = EXCLUDE_PATTERN.test(fileObject.original_file);
  const shouldExludeBySize = (fileObject.diff?.split(' ').length ?? 0) > 1000;

  return shouldExludeByName || shouldExludeBySize;
};

/**
 * @description Check if a file should be included in the context
 * @param {*} fileObject
 * @returns returns true if the file should be included
 */
const shouldIncludeFile = fileObject => {
  return !shouldExcludeFile(fileObject);
};

const buildContextForGPT = context => {
  if (Array.isArray(context)) {
    return context.filter(element =>
      typeof element !== 'object' ? true : context.filter(shouldIncludeFile)
    );
  }

  if (context?.diff?.files) {
    const files = context.diff.files.filter(shouldIncludeFile);
    return files;
  }

  return context;
};

const askAI = async (context, role, prompt, token, callback) => {
  const formattedContext = buildContextForGPT(context);

  if (!formattedContext?.length) {
    const message = `There are no context files to analyze.\nAll ${context?.diff?.files?.length} files were excluded by pattern or size.`;
    console.log(message);
    return callback(null, message);
  }

  const response = await fetch(OPEN_AI_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`
    },
    body: JSON.stringify({
      model: 'gpt-4o-2024-08-06',
      messages: [
        {
          role: 'system',
          content: `You are a ${role}. Answer only to the request, without any introductory or conclusion text.`
        },
        {
          role: 'user',
          content: JSON.stringify(formattedContext)
        },
        { role: 'user', content: prompt }
      ],
      max_tokens: MAX_TOKENS
    })
  });

  const data = await response.json();

  if (data?.error?.message) {
    console.error(data.error.message);
    return callback(null, data.error.message);
  }

  const suggestion =
    data.choices?.[0]?.message?.content ??
    'context was too big for api, try with smaller context object';

  return callback(null, suggestion);
};

module.exports = {
  async: true,
  filter: askAI
};

gitStream CM Example: askAI

triggers:
  exclude:
    branch:
      - r/dependabot/

automations:
  generate_pr_desc_on_new_pr:
    on:
      - pr_created
    if:
      - true
    run:
      - action: add-comment@v1
        args:
          comment: |
            {{ source | askAI("Experienced developer", "Summarize in simple terms the changes in this PR using bullet points.", env.OPEN_AI_TOKEN) | encode }}

  generate_pr_desc_on_ask_ai_label:
    on:
      - label_added
    if:
      - {{ pr.labels | match(term="/ask-ai qa") | some }}
    run:
      - action: add-comment@v1
        args:
          comment: |
            {{ source | askAI("qa tester", "Based on the given context, search for new functions without tests and suggest the tests to add. If all functions are covered completely, return 'no tests to suggest.'", env.OPEN_AI_TOKEN) | encode }}

Download Source Code

checklist

Automatically check PRs against a checklist of conditions. This is useful if you want to ensure that PRs meet certain criteria before they can be merged.

Returns: string - Returns a formatted GitHub comment with a checklist of conditions that the PR meets.
License: MIT

Param Type Description
Input string A blank string (no input variable is required)
branch object The branch context variable.
files object The files context variable.
pr object The pr context variable.
repo object The repo context variable.
env object The env context variable.
source object The source context variable.

Example

- action: add-comment@v1
        args:
            comment: {{ "" | checklist(branch, files, pr, repo, env, source) }}

With this plugin, you can easily customize the checklist using the object in the JavaScript code. To add a new check to the list, just add a new object with a descriptive title for your own benefit, a label that'll get posted in the comment, and the condition that, if true, would cause the entry in the checklist to be checked off.

Plugin Code: checklist

/**
 * @module checklist
 * @description Automatically check PRs against a checklist of conditions.
 * This is useful if you want to ensure that PRs meet certain criteria before they can be merged. 
 * @param {string} Input - A blank string (no input variable is required)
 * @param {object} branch - The branch context variable.
 * @param {object} files - The files context variable.
 * @param {object} pr - The pr context variable.
 * @param {object} repo - The repo context variable.
 * @param {object} env - The env context variable.
 * @param {object} source - The source context variable.
 * @returns {string} Returns a formatted GitHub comment with a checklist of conditions that the PR meets.
 * @example       
 * - action: add-comment@v1
        args:
            comment: {{ "" | checklist(branch, files, pr, repo, env, source) }}
 * @license MIT
**/

const checklistFilter = async (empty, branch, files, pr, repo, env, source, callback) => { // made sync temporarily

    const checks = [
        {
            title: "low-risk",
            label: "The PR is a low-risk change",
            // our sample definition of a low-risk change is a docs-only PR from designated docs writers
            condition: files.every(file => /docs\//.test(file)) && pr.author_teams.includes("tech-writers")
        },
        {
            title: "has-jira",
            label: "The PR has a Jira reference in the title",
            condition: /\b[A-Za-z]+-\d+\b/.test(pr.title)
        },
        {
            title: "updates-tests",
            label: "The PR includes updates to tests",
            condition: files.some(file => /[^a-zA-Z0-9](spec|test|tests)[^a-zA-Z0-9]/.test(file))
        },
        {
            title: "includes-docs",
            label: "The PR includes changes to the documentation",
            condition: files.some(file => /docs\//.test(file))
        },
        {
            title: "first-time",
            label: "The PR author is a first-time contributor",
            condition: repo.author_age < 1 && repo.age > 0 // if the PR author made their first contirbution on the current day
        },
        {
            title: "requires-opsec",
            label: "The PR doesn't expose any secrets",
            condition: source.diff.files
                .map(file => file.new_content)
                .every(file_content => 
                    [
                        "MY_SECRET_ENVIRONMENT_VARIABLE"
                    ].every(env_var => !file_content.includes(env_var)) 
                       // nothing added to any file during this comment contains any of the secret environment variables in this array
                )
        }
    ];

    const comment = await Promise.resolve(checks
        .map(check => `- [${check.condition ? "x" : " "}] ${check.label}`)
        .join("\n"));

    return callback(
        null, 
        JSON.stringify(comment)
    );
};

module.exports = {
    async: true,
    filter: checklistFilter
}

gitStream CM Example: checklist

# -*- mode: yaml -*-

manifest:
  version: 1.0

automations:
  checklist: 
    if:
      - true
    run:
      - action: add-comment@v1
        args:
          comment: {{ "" | checklist(branch, files, pr, repo, env, source) }}

Download Source Code

compareMultiSemver

Processes a list of pairs of semantic version numbers and determines the most significant change among them.

Returns: string - It returns a string of either: 'major' if any pair has a major version increment. 'minor' if no pair has a major version increment but has a minor version increment. 'patch' if no pair has major or minor version increments but has a patch version increment. 'downgrade' if no pairs have a higher version. 'equal' if all pairs are equal. 'error' if the comparison is abnormal or cannot be determined.

License: MIT

Param Type Default Description
listOfPairs Array.<Array> An array of version pairs, where each pair is an array of two semantic version strings.

Example

{{ [["1.2.3", "0.2.1"], ["1.3.1", "1.2.3"]] | compareMultiSemver  == "major" }}

Plugin Code: compareMultiSemver

/**
 * @module compareMultiSemver
 * @description Processes a list of pairs of semantic version numbers and determines the most significant change among them.
 * Each pair consists of two versions to be compared.
 * @param {string[][]} listOfPairs - An array of version pairs, where each pair is an array of two semantic version strings.
 * @returns {string} It returns a string of either:
 * 'major' if any pair has a major version increment.
 * 'minor' if no pair has a major version increment but has a minor version increment.
 * 'patch' if no pair has major or minor version increments but has a patch version increment.
 * 'downgrade' if no pairs have a higher version.
 * 'equal' if all pairs are equal.
 * 'error' if the comparison is abnormal or cannot be determined.
 * @example {{ [["1.2.3", "1.2.1"], ["1.3.1", "1.2.3"]] | compareMultiSemver  == "minor" }}
 * @license MIT
 */


const compareSemver = require('../compareSemver/index.js');

module.exports = (listOfPairs) => {

  const priority = {
    'major': 3,
    'minor': 2,
    'patch': 1,
    'downgrade': 0,
    'equal': -1,
    'error': -2
  };

  let mostSignificantChange = 'equal';

  listOfPairs.forEach(pair => {
    const result = compareSemver(pair);
    if (priority[result] > priority[mostSignificantChange]) {
      mostSignificantChange = result;
    }
  });

  return mostSignificantChange;
}


const compareMultiSemver = require('./index.js');
console.assert(compareMultiSemver([["1.2.3", "1.2.1"], ["1.3.1", "1.2.3"]]) === 'minor', `compareSemver([["1.2.3", "1.2.1"], ["1.3.1", "1.2.3"]]) == 'minor'`);
console.assert(compareMultiSemver([["1.2.3", "0.2.1"], ["1.3.1", "1.2.3"]]) === 'major', `compareMultiSemver([["1.2.3", "0.2.1"], ["1.3.1", "1.2.3"]]) === 'major'`);
console.assert(compareMultiSemver([["2.2.3", "0.2.1"], ["1.3.1", "1.2.3"]]) === 'major', `compareMultiSemver([["2.2.3", "0.2.1"], ["1.3.1", "1.2.3"]]) === 'major'`);
console.assert(compareMultiSemver([["1.2.3", "1.2.1"], ["1.2.4", "1.2.3"]]) === 'patch', `compareMultiSemver([["1.2.3", "1.2.1"], ["1.2.4", "1.2.3"]]) === 'patch'`);

gitStream CM Example: compareMultiSemver

manifest:
  version: 1.0

automations:
  bump_minor:
    if:
      - {{ bump == 'minor' }}
    run:
      - action: approve@v1
      - action: add-comment@v1
        args:
          comment: |
            bot `minor` version bumps are approved automatically.

  bump_patch:
    if:
      - {{ bump == 'patch' }}
    run:
      - action: approve@v1
      - action: merge@v1
      - action: add-comment@v1
        args:
          comment: |
            bot `patch` version bumps are approved and merged automatically.

bump: {{ [["1.2.3", "1.2.1"], ["1.3.1", "1.2.3"]] | compareMultiSemver }}

Download Source Code

compareSemver

Compares two software version numbers (e.g., "1.2.1" or "1.2b") and determines the type of version change. The first version to be compared, and the second are passed as argument 1 and 2 or as array of 2 items. When V1 > V2 the it means and upgrade.

Returns: string - It returns a string of either: 'major' if the major version is incremented. 'minor' if the minor version is incremented. 'patch' if the patch version is incremented. 'downgrade' if the second version is lower than the first. 'equal' if both versions are equal. 'error' if the comparison is abnormal or cannot be determined.
License: MIT

Param Type Default Description
versions Array.<string> V1 and V2 in Semver format
[lexicographical] boolean false compares each part of the version strings lexicographically instead of naturally; this allows suffixes such as "b" or "dev" but will cause "1.10" to be considered smaller than "1.2".
[zeroExtend] boolean true changes the result if one version string has less parts than the other. In this case the shorter string will be padded with "zero" parts instead of being considered smaller.

Example

{{ ["1.2.1", "1.2.3"] | compareSemver  == "patch" }}

Plugin Code: compareSemver

/**
 * @module compareSemver
 * @description Compares two software version numbers (e.g., "1.2.1" or "1.2b") and determines the type of version change.
 * The first version to be compared, and the second are passed as argument 1 and 2 or as array of 2 items.
 * When V1 > V2 the it means and upgrade.
 * @param {string[]} versions - V1 and V2 in Semver format
 * @returns {string} It returns a string of either:
 * 'major' if the major version is incremented.
 * 'minor' if the minor version is incremented.
 * 'patch' if the patch version is incremented.
 * 'downgrade' if the second version is lower than the first.
 * 'equal' if both versions are equal.
 * 'error' if the comparison is abnormal or cannot be determined.
 * @example {{ ["1.2.3", "1.2.1"] | compareSemver  == "patch" }}
 * @license MIT
**/


module.exports = (v1, v2) => {
  const lexicographical = false;
  const zeroExtend = true;

  // support array as input
  if (Array.isArray(v1) && v2 === undefined) {
    [v1, v2] = v1; // Destructure the first two elements of the array into v1 and v2
  }

  let v1parts = (v1 || "0").split('.');
  let v2parts = (v2 || "0").split('.');

  const isValidPart = x => lexicographical ? /^\d+[A-Za-zαß]*$/.test(x) : /^\d+[A-Za-zαß]?$/.test(x);

  if (!v1parts.every(isValidPart) || !v2parts.every(isValidPart)) {
    return 'error';
  }

  if (zeroExtend) {
    const maxLength = Math.max(v1parts.length, v2parts.length);
    v1parts = [...v1parts, ...Array(maxLength - v1parts.length).fill("0")];
    v2parts = [...v2parts, ...Array(maxLength - v2parts.length).fill("0")];
  }

  const convertPart = x => {
    const match = /[A-Za-zαß]/.exec(x);
    return Number(match ? x.replace(match[0], "." + x.charCodeAt(match.index)) : x);
  };

  if (!lexicographical) {
    v1parts = v1parts.map(convertPart);
    v2parts = v2parts.map(convertPart);
  }

  for (let i = 0; i < v1parts.length; i++) {
    if (v1parts[i] !== v2parts[i]) {
      if (v1parts[i] < v2parts[i]) {
        return 'downgrade';
      }
      switch (i) {
        case 0: return 'major';
        case 1: return 'minor';
        case 2: return 'patch';
        default: return 'error';
      }
    }
  }

  return 'equal';
}


const compareSemver = require('./index.js');
console.assert(compareSemver(["1.2.3", "1.2.1"]) === 'patch', `compareSemver(["1.2.3", "1.2.1"]) == 'patch'`);
console.assert(compareSemver(["1.2.0", "1.2.1"]) === 'downgrade', `compareSemver(["1.2.0", "1.2.1"]) === downgrade'`);
console.assert(compareSemver(["1.3.0", "1.2.1"]) === 'minor', `compareSemver(["1.3.0", "1.2.1"]) == 'minor'`);
console.assert(compareSemver(["2.0.0", "1.2.1"]) === 'major', `compareSemver(["2.0.0", "1.2.1"]) == 'major'`);
console.assert(compareSemver(["1.2.1", "1.2.1"]) === 'equal', `compareSemver(["1.2.1", "1.2.1"]) == 'equal'`);
console.assert(compareSemver(["1.2b", "1.2.1"]) === 'minor', `compareSemver(["1.2b", "1.2.1"]) == 'error'`);
console.assert(compareSemver(["1.2.0", "1.2"]) === 'equal', `compareSemver(["1.2.0", "1.2"]) == 'equal'`);
console.assert(compareSemver(["1.2.1.0", "1.2.1"]) === 'equal', `compareSemver(["1.2.1.0", "1.2.1"]) == 'equal'`);
console.assert(compareSemver(["1.2.1.0a", "1.2.1"]) === 'error', `compareSemver(["1.2.1.0a", "1.2.1"]) === downgrade'`);

gitStream CM Example: compareSemver

manifest:
  version: 1.0

automations:
  bump_minor:
    if:
      - {{ bump == 'minor' }}
    run:
      - action: approve@v1
      - action: add-comment@v1
        args:
          comment: |
            bot `minor` version bumps are approved automatically.

  bump_patch:
    if:
      - {{ bump == 'patch' }}
    run:
      - action: approve@v1
      - action: merge@v1
      - action: add-comment@v1
        args:
          comment: |
            bot `patch` version bumps are approved and merged automatically.

bump: {{ ["1.2.3", "1.2.1"] | compareSemver }}

Download Source Code

extractDependabotVersionBump

Extract version bump information from Dependabot PRs description

Returns: Array.<string> - V1 (to) and V2 (from)
License: MIT

Param Type Description
description string the PR description

Example

{{ pr.description | extractDependabotVersionBump | compareSemver }}

Plugin Code: extractDependabotVersionBump

/**
 * @module extractDependabotVersionBump
 * @description Extract version bump information from Dependabot PRs description
 * @param {string} description - the PR description
 * @returns {string[]} V1 (to) and V2 (from)
 * @example {{ pr.description | extractDependabotVersionBump | compareSemver }}
 * @license MIT
**/


module.exports = (desc) => {
  if (desc && desc !== '""' && desc !== "''" ) {    
    const matches = /Bumps.*from ([\d\.]+[A-Za-zαß]*) to ([\d\.]+[A-Za-zαß]*)/.exec(desc);
    if (matches && matches.length == 3) {
      var [_, from, to] = matches;
      // remove trailing dot on to
      if (to[to.length - 1] === ".") {
        to = to.slice(0, -1);
      }
      return [to, from];
    }
  }

  return null;
}

gitStream CM Example: extractDependabotVersionBump

manifest:
  version: 1.0

automations:
  bump_minor:
    if:
      - {{ bump == 'minor' }}
      - {{ branch.name | includes(term="dependabot") }}
      - {{ branch.author | includes(term="dependabot") }}
    run:
      - action: approve@v1
      - action: add-comment@v1
        args:
          comment: |
            Dependabot `minor` version bumps are approved automatically.

  bump_patch:
    if:
      - {{ bump == 'patch' }}
      - {{ branch.name | includes(term="dependabot") }}
      - {{ branch.author | includes(term="dependabot") }}
    run:
      - action: approve@v1
      - action: merge@v1
      - action: add-comment@v1
        args:
          comment: |
            Dependabot `patch` version bumps are approved and merged automatically.

bump: {{ pr.description | extractDependabotVersionBump | compareSemver }}

Download Source Code

extractRenovateVersionBump

Extract version bump information from Renovate PRs description

Returns: Array.<string> - V1 (to) and V2 (from) License: MIT

Param Type Description
description string the PR description

Example

{{ pr.description | extractRenovateVersionBump | compareMultiSemver }}

Plugin Code: extractRenovateVersionBump

/**
 * @module extractRenovateVersionBump
 * @description Extract version bump information from Renovate PRs description
 * @param {string} description - the PR description
 * @returns {string[]} V1 (to) and V2 (from)
 * @example {{ pr.description | extractRenovateVersionBump | compareMultiSemver }}
 * @license MIT
**/


module.exports = (desc) => {
  const results = [];
  if (desc && desc !== '""' && desc !== "''") {
    const regex =
      /\[[\\]*`([\d\.]+[A-Za-zαß]*)[\\]*` -> [\\]*`([\d\.]+[A-Za-zαß]*)[\\]*`\]/g;
    let matches = null;
    do {
      matches = regex.exec(desc);
      if (matches?.length === 3) {
        let [_, from, to] = matches;
        // remove trailing dot on to
        if (to.at(-1) === ".") {
          to = to.slice(0, -1);
        }
        results.push([to, from]);
      }
    } while (matches !== null);
  }
  return results;
}

gitStream CM Example: extractRenovateVersionBump

manifest:
  version: 1.0

automations:
  bump_minor:
    if:
      - {{ bump == 'minor' }}
      - {{ branch.name | includes(term="renovate") }}
      - {{ branch.author | includes(term="renovate") }}
    run:
      - action: approve@v1
      - action: add-comment@v1
        args:
          comment: |
            Renovate `minor` version bumps are approved automatically.

  bump_patch:
    if:
      - {{ bump == 'patch' }}
      - {{ branch.name | includes(term="renovate") }}
      - {{ branch.author | includes(term="renovate") }}
    run:
      - action: approve@v1
      - action: merge@v1
      - action: add-comment@v1
        args:
          comment: |
            Renovate `patch` version bumps are approved and merged automatically.

bump: {{ pr.description | extractRenovateVersionBump | compareMultiSemver }}

Download Source Code

extractSnykVersionBump

Extract version bump information from Snyk PRs description

Returns: Array.<string> - V1 (to) and V2 (from)
License: MIT

Param Type Description
description string the PR description

Example

{{ pr.description | extractSnykVersionBump | compareSemver }}

Plugin Code: extractSnykVersionBump

/**
 * @module extractSnykVersionBump
 * @description Extract version bump information from Snyk PRs description
 * @param {string} description - the PR description
 * @returns {string[]} V1 (to) and V2 (from)
 * @example {{ pr.description | extractSnykVersionBump | compareSemver }}
 * @license MIT
**/



module.exports = (desc) => {
  if (desc && desc !== '""' && desc !== "''" ) {    
    const matches = /Upgrade.*from ([\d\.]+[A-Za-zαß]*) to ([\d\.]+[A-Za-zαß]*)/.exec(desc);
    if (matches && matches.length == 3) {
      var [_, from, to] = matches;
      // remove trailing dot on to
      if (to[to.length - 1] === ".") {
        to = to.slice(0, -1);
      }
      return [to, from];
    }
  }

  return null;
}

gitStream CM Example: extractSnykVersionBump

manifest:
  version: 1.0

automations:
  bump_minor:
    if:
      - {{ bump == 'minor' }}
      - {{ branch.name | includes(term="snyk-update"") }}
      - {{ branch.author | includes(term="snyk-update"") }}
    run:
      - action: approve@v1
      - action: add-comment@v1
        args:
          comment: |
            Snyk `minor` version bumps are approved automatically.

  bump_patch:
    if:
      - {{ bump == 'patch' }}
      - {{ branch.name | includes(term="snyk-update"") }}
      - {{ branch.author | includes(term="snyk-update"") }}
    run:
      - action: approve@v1
      - action: merge@v1
      - action: add-comment@v1
        args:
          comment: |
            Snyk `patch` version bumps are approved and merged automatically.

bump: {{ pr.description | extractSnykVersionBump | compareSemver }}

Download Source Code

extractOrcaFindings

Extract security issues information from Orca PR reviews

Returns: Object - Findings Findings.infrastructure_as_code: { count: null, priority: '' }, Findings.vulnerabilities: { count: null, priority: '' }, Findings.secrets: { count: null, priority: '' },
License: MIT

Param Type Description
PR Object the gitStream's PR context variable

Example

{{ pr | extractOrcaFindings }}

Usage example, that adds lables based on Orca Secuirty findings.

Plugin Code: extractOrcaFindings

/**
 * @module extractOrcaFindings
 * @description Extract security issues information from Orca PR reviews
 * @param {Object} pr - the gitStream's PR context variable
 * @returns {Object} Findings
 * Findings.infrastructure_as_code: { count: null, priority: '' },
 * Findings.vulnerabilities: { count: null, priority: '' },
 * Findings.secrets: { count: null, priority: '' },
 * @example {{ pr | extractOrcaFindings }}
 * @license MIT
**/


function getOrcaPropertyRating(lines, lineIdentifierRegex, findingsCellIndex) {
  const matches = lines.filter(x => x.match(lineIdentifierRegex));
  const [firstMatch] = matches;
  const cells = firstMatch.split('|');
  const [_, high, medium, low, info] = /"High"> ([\d]+).*"Medium"> ([\d]+).*"Low"> ([\d]+).*"Info"> ([\d]+)/
    .exec(cells[findingsCellIndex])
    .map(x => parseInt(x));
  return {high, medium, low, info};
}

module.exports = (pr) => {
  let orcaObject = {
    infrastructure_as_code: { count: null, priority: '' },
    vulnerabilities: { count: null, priority: '' },
    secrets: { count: null, priority: '' },
  };

  // Orca comments are added as PR review
  const orcaComment = pr.reviews.filter(x => x.commenter.includes('orca-security'));

  if (orcaComment.length) {
    const orcaCommentArray = orcaComment[orcaComment.length - 1].content.split('\n');

    var priority = getOrcaPropertyRating(orcaCommentArray, /Infrastructure as Code/, 3);
    orcaObject.infrastructure_as_code = {
      count: priority.high + priority.medium + priority.low + priority.info,
      priority,
    };

    var priority = getOrcaPropertyRating(orcaCommentArray, /Vulnerabilities/, 3);
    orcaObject.vulnerabilities = {
      count: priority.high + priority.medium + priority.low + priority.info,
      priority,
    };

    var priority = getOrcaPropertyRating(orcaCommentArray, /Secrets/, 3);
    orcaObject.secrets = {
      count: priority.high + priority.medium + priority.low + priority.info,
      priority,
    };
  }

  return JSON.stringify(orcaObject);
}

gitStream CM Example: extractOrcaFindings

# -*- mode: yaml -*-

manifest:
  version: 1.0

automations:
  {% for item in reports %}
  label_orca_{{ item.name }}:
    if:
      - {{ item.count > 0 }}
    run:
      - action: add-label@v1
        args:
          label: 'orca:{{ item.name }}'
  {% endfor %}

orca: {{ pr | extractOrcaFindings }}

reports:
  - name: introduced-cves
    count: {{ orca.vulnerabilities.count }}
  - name: iac-misconfigurations
    count: {{ orca.infrastructure_as_code.count }}
  - name: exposed-secrets 
    count: {{ orca.secrets.count }}

colors:
  red: 'b60205'

Download Source Code

generateDescription

A gitStream plugin to auto-generate pull request descriptions based on commit messages and other criteria.

Example PR description

Returns: Object - Returns the generated PR description. License: MIT

Param Type Description
branch Object The current branch object.
pr Object The pull request object.
repo Object The repository object.
source Object The source object containing diff information.
callback function The callback function.

Example

{{ branch | generateDescription(pr, repo, source) }}

Plugin Code: generateDescription

/**
 * @module generateDescription
 * @description A gitStream plugin to auto-generate pull request descriptions based on commit messages and other criteria.
 * @param {Object} branch - The current branch object.
 * @param {Object} pr - The pull request object.
 * @param {Object} repo - The repository object.
 * @param {Object} source - The source object containing diff information.
 * @param {function} callback - The callback function.
 * @returns {Object} Returns the generated PR description.
 * @example {{ branch | generateDescription(pr, repo, source) }}
 * @license MIT
**/


// Parse commit messages
function parseCommitMessages(messages) {
  const commitTypes = {
    feat: [],
    fix: [],
    chore: [],
    docs: [],
    style: [],
    refactor: [],
    perf: [],
    test: [],
    build: [],
    ci: [],
    other: [],
  };

  messages
    .filter((message) => !message.includes("Merge branch"))
    .forEach((message) => {
      const match = message.match(
        /^(feat|fix|chore|docs|style|refactor|perf|test|build|ci):/,
      );
      if (match) {
        commitTypes[match[1]].push(message.replace(`${match[1]}:`, "").trim());
      } else {
        commitTypes.other.push(message);
      }
    });

  return commitTypes;
}

// Format commit section
function formatCommitSection(type, commits) {
  return commits.length
    ? `> - **${type}:**\n${commits.map((msg) => `>     - ${msg}`).join("\n")}\n`
    : "";
}

function containsNewTests(files) {
  const testPattern = /(test_|spec_|__tests__|_test|_tests|\.test|\.spec)/i;
  const testDirectoryPattern = /[\\/]?(tests|test|__tests__)[\\/]/i;
  const testKeywords = /describe\(|it\(|test\(|expect\(/i; // Common test keywords for JavaScript

  for (const file of files) {
    const { new_file, diff, new_content } = file;

    // Check if the filename indicates it's a test file
    if (testPattern.test(new_file) || testDirectoryPattern.test(new_file)) {
      return true;
    }

    // Check if the diff or new content contains test-related code
    if (testKeywords.test(diff) || testKeywords.test(new_content)) {
      return true;
    }
  }

  return false;
}

function extractUserAdditions(description) {
  const match = description.match(
    /<!--- user additions start --->([\s\S]*?)<!--- user additions end --->/,
  );
  return match ? match[1].trim() : description.trim();
}

// Generate PR description
async function generateDescription(branch, pr, repo, source, callback) {
  if (process.env[__filename]) {
    return callback(null, process.env[__filename]);
  }

  const commitTypes = parseCommitMessages(branch.commits.messages);

  const addTests = containsNewTests(source.diff.files) ? "X" : " ";
  const codeApproved = pr.approvals > 0 ? "X" : " ";

  const changes = Object.entries(commitTypes)
    .map(([type, commits]) => formatCommitSection(type, commits))
    .join("");
  const changesWithoutLastBr = changes.slice(0, -1);
  const userAdditions = extractUserAdditions(pr.description);

  const result = `
<!--- user additions start --->
${userAdditions}
<!--- user additions end --->


**PR description below is managed by gitStream**
<!--- Auto-generated by gitStream--->
> #### Commits Summary
> This pull request includes the following changes:
${changesWithoutLastBr}
> #### Checklist
> - [${addTests}] Add tests
> - [${codeApproved}] Code Reviewed and approved
<!--- Auto-generated by gitStream end --->
`;

  process.env[__filename] = result.split("\n").join("\n            ");
  return callback(null, process.env[__filename]);
}

module.exports = { filter: generateDescription, async: true };

gitStream CM Example: generateDescription

triggers:
  exclude:
    branch:
      - r/dependabot/

automations:
  generate_pr_desc:
    if:
      - true
    run:
      - action: update-description@v1
        args:
          description: |
            {{ branch | generateDescription(pr, repo, source) }}

Download Source Code

getCodeowners

Resolves the PR's code owners based on the repository's CODEOWNERS file

Returns: Array.<string> - user names
License: MIT

Param Type Description
files Array.<string> the gitStream's files context variable
pr Object the gitStream's pr context variable
token string access token with repo:read scope, used to read the CODEOWNERS file

Example

{{ files | getCodeowners(pr, env.CODEOWNERS_TOKEN) }}

When used, create a secret TOKEN, and add it to the workflow file, in GitHub:

jobs:
  gitStream:
    ...
    env:
      CODEOWNERS: ${{ secrets.GITSTREAM_CODEOWNERS }}
    steps:
      - name: Evaluate Rules
        uses: linear-b/gitstream-github-action@v2
Plugin Code: getCodeowners

/**
 * @module getCodeowners
 * @description Resolves the PR's code owners based on the repository's CODEOWNERS file
 * @param {string[]} files - the gitStream's files context variable
 * @param {Object} pr - the gitStream's pr context variable
 * @param {string} token - access token with repo:read scope, used to read the CODEOWNERS file
 * @returns {string[]} user names
 * @example {{ files | getCodeowners(pr, env.CODEOWNERS_TOKEN) }}
 * @license MIT
**/


const { Octokit } = require("@octokit/rest");
const ignore = require('./ignore/index.js');

async function loadCodeownersFile(owner, repo, auth) {
  const octokit = new Octokit({
    request: { fetch },
    auth,
  });

  const res = await octokit.repos.getContent({
    owner,
    repo,
    path: 'CODEOWNERS'
  });

  return Buffer.from(res.data.content, 'base64').toString()
}

function codeownersMapping(data) {
  return data
    .toString()
    .split('\n')
    .filter(x => x && !x.startsWith('#'))
    .map(x => x.split("#")[0])
    .map(x => {
        const line = x.trim();
        const [path, ...owners] = line.split(/\s+/);
        return {path, owners};
    });
}

function resolveCodeowner(mapping, file) {
    const match = mapping
      .slice()
      .reverse()
      .find(x =>
          ignore()
              .add(x.path)
              .ignores(file)
      );
    if (!match) return false;
    return match.owners;
}

module.exports = {
   async: true,
   filter: async (files, pr, token, callback) => {
    const fileData = await loadCodeownersFile(pr.author, pr.repo, token);
    const mapping = codeownersMapping(fileData);

    const resolved = files
      .map(f => resolveCodeowner(mapping, f))
      .flat()
      .filter(i => typeof i === 'string')
      .map(u => u.replace(/^@/, ""));

    const unique = [...new Set(resolved)];

    return callback(null, unique); 
  },
}

gitStream CM Example: getCodeowners

# -*- mode: yaml -*-
manifest:
  version: 1.0

automations:
  senior_review:
    if:
      - true
    run:
      - action: explain-code-experts@v1
        args:
          gt: 10
      - action: add-reviewers@v1
        args:
          reviewers: {{ experts | intersection(list=owners) }}

experts: {{ repo | codeExperts(gt=10) }}
owners: {{ files | codeowners(pr, env.CODEOWNERS) }}

Download Source Code

hasJiraIssue

Check to see if the input string matches a specified field for one or more Jira issues.

Returns: boolean - Returns true if the input string matches a Jira task title.
License: MIT

Param Type Description
input string The string to search for a Jira task title.
password string Your Jira API token
key string The Jira key to search for matches against the input string.
jiraSpaceName string The name of the Jira space to search for tasks.
email string The email address associated with the Jira API token.

Example

{{ "https://github.com/{{ repo.owner }}/{{ repo.name }}/pull/{{ pr.number }}" | hasJiraIssue(password, key, jiraSpaceName, email) }}

Prerequisite Configuration

You will need to complete the following steps to use this plugin:

  1. Create an API token for your Jira account.
  2. Make the token available to gitStream via an environment variable.
Plugin Code: hasJiraIssue

/**
 * @module hasJiraIssue
 * @description Check to see if the input string matches a specified field for one or more Jira issues.
 * @param {string} input - The string to search for a Jira task title.
 * @param {string} password - Your Jira API token
 * @param {string} key - The Jira key to search for matches against the input string.
 * @param {string} jiraSpaceName - The name of the Jira space to search for tasks.
 * @param {string} email - The email address associated with the Jira API token.
 * @returns {boolean} Returns true if the input string matches a Jira task title. 
 * @example {{ "https://github.com/{{ repo.owner }}/{{ repo.name }}/pull/{{ pr.number }}" | hasJiraIssue(password, key, jiraSpaceName, email) }}
 * @license MIT
 */
module.exports = {
    async: true,
    filter: async (inputString, password, key, jiraSpaceName, email, callback) => {
        const jql = `"${key}" = "${inputString}"`;

    const resp = await fetch(`https://${jiraSpaceName}.atlassian.net/rest/api/2/search`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Basic ' + btoa(`${email}:${password}`)
            },
            body: JSON.stringify({
                'jql': jql,
                'maxResults': 1,
                "fieldsByKeys": true,
                'fields': [ 'assignee' ]
            })
        });
        const results = await resp.json();
        return callback(null,  !!results.issues?.length);
    }
}

gitStream CM Example: hasJiraIssue

# -*- mode: yaml -*-

manifest:
  version: 1.0

###### ** Configure This Section ** ######

# Configure this for your Jira instance and the email associated with your API key.
# You can safely use these values because only your API key is sensitive. 
jiraSpaceName: "my-company" # e.g. my-company.atlassian.net
email: "my.email@example.com"
# If you're concerned about exposing this information,
# we recommend using environment variables for your production environment.

# -----------

# Pass the API token associated with the email above to gitStream via an environment variable.
jiraAuth: {{ env.JIRA_API_TOKEN }}
# Learn more about env: https://docs.gitstream.cm/context-variables/#env

# -----------

# Change this to the Jira field you want to match the input string against.
jiraField: "myField"
# If you want to search a custom field, you should provide the ID like so:
# jiraField: "cf[XXXXX]"
# Replace XXXXX with the ID of the custom field you want to search.
# More information:
# Using JQL to search the Jira API: https://support.atlassian.com/jira-service-management-cloud/docs/jql-fields/
# How to find the ID of a custom field: https://confluence.atlassian.com/jirakb/how-to-find-any-custom-field-s-ids-744522503.html

# -----------


prUrl: "https://github.com/{{ repo.owner }}/{{ repo.name }}/pull/{{ pr.number }}"
has_jira_issue: {{ prUrl  | hasJiraIssue(jiraAuth, jiraField, jiraSpaceName, email) }}

automations:
  has_jira_issue: 
    if:
      - {{ not has_jira_issue }}
    run:
      - action: add-comment@v1
        args:
          comment: "This PR is missing a related issue in Jira. Please create a Jira task."

Download Source Code

isFlaggedUser

Returns true if the username that is passed to this function is specified in a predefined list of users. This is useful if you want gitStream automations to run only for specified users.

Returns: boolean - Returns true if the user is specified in the flaggedUsers list, otherwise false.
License: MIT

Param Type Description
Input string The GitHub username to check.

Example

{{ pr.author | isFlaggedUser }}

Plugin Code: isFlaggedUser

// Add users who you want to add to the flag list.
const flaggedUsers = ["user1", "user2"];
/**
 * @module isFlaggedUser
 * @description Returns true if the username that is passed to this function is specified in a predefined list of users. 
 * This is useful if you want gitStream automations to run only for specified users.
 * @param {string} Input - The GitHub username to check.
 * @returns {boolean} Returns true if the user is specified in the flaggedUsers list, otherwise false.
 * @example {{ pr.author | isFlaggedUser }}
 * @license MIT
 */
function isFlaggedUser(username) {
    if (flaggedUsers.includes(username)) {
        return true;
    } else {
        return false;
    }
};

function containsString(arr, str) {
    return arr.includes(str);
};

module.exports = isFlaggedUser;

gitStream CM Example: isFlaggedUser

# -*- mode: yaml -*-

manifest:
  version: 1.0

automations:
  detect_flagged_user:
    if:
      - {{ pr.author | isFlaggedUser }}
    run:
      - action: add-comment@v1
        args:
          comment: {{ pr.author }} is a gitStream user.

Download Source Code