DEV Community

Imam Ali Mustofa
Imam Ali Mustofa

Posted on

#GitHubHack23 - The Action Story

Just so you know, this ain't no suspenseful action story, but a tale of fun action as a Software Freestyle Engineer.

As an SFE, I'm hella lucky to get to know so many peeps who dedicate themselves not just to money, ya feel me? Does that sound too provocative?

Money's important and all, but it can't get you everything, y'all! - Me


Anyway, back to the topic at hand, homie! This action is a crucial component of the Metaphore Story - SCP, 'cause with this action, all the stories are distributed to the public.

The Idea of The Action

Punk Idea: Create a GitHub workflow to generate .md when we add a label to metaphore issue
It's amazing how @mkubdev simple yet brilliant idea sparked my passion as a Freestyler to craft the words into a phenomenal story wrapped in a metaphorical style.

The Action Struct

The Action Struct

The Template Story

The story template serves as a placeholder for a story that will be distributed based on the closed issue.

---
layout: post
title: {title}
author: {author}
created_at: {created_at}
language: {language}
---

{content}
Enter fullscreen mode Exit fullscreen mode

The Slugify and Git File

Slugify

This here is some JavaScript module called "slugify.js", my friend! The goal is to take some text string as input and turn it into a "slug" format, ya dig? A "slug" is a URL-friendly version of a string, where all the characters are in lowercase, spaces are replaced with hyphens ("-"), and all non-letter characters are removed. And I just had to write it all in one line, no surprises there, man!

// Filename: slugify.js
module.exports = text => text.toString().normalize('NFKD').toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/\-$/g, '');
Enter fullscreen mode Exit fullscreen mode

Check it out, punk! Here's the lowdown in just one funky fresh line!

This code exports a function as a module so it can be imported into other files. It takes an input text and casts it to a string type (just in case it isn't already a string). It normalizes the string using the Unicode normalization form "NFKD", which replaces accented characters with their base equivalents. It converts the string to lowercase. It removes leading or trailing white space from the string (optional). It replaces one or more consecutive white space characters with a single hyphen ("-"). It removes all non-word characters (including spaces) from the string. It replaces any consecutive occurrence of two or more hyphens with a single hyphen. It removes any remaining hyphens from the string.
Enter fullscreen mode Exit fullscreen mode

This code exports a function as a module so it can be imported into other files. It takes an input text and casts it to a string type (just in case it isn't already a string). It normalizes the string using the Unicode normalization form "NFKD", which replaces accented characters with their base equivalents. It converts the string to lowercase. It removes leading or trailing white space from the string (optional). It replaces one or more consecutive white space characters with a single hyphen ("-"). It removes all non-word characters (including spaces) from the string. It replaces any consecutive occurrence of two or more hyphens with a single hyphen. It removes any remaining hyphens from the string.
So when you use this function on a text string, it will return a new string that has been transformed into a slug format, making it easier to use as a URL or identifier.

Git

GitHub Action Bot

// Filename: git.js
module.exports = {
    ghBotUsername: 'github-actions[bot]',
    ghBotEmail: '41898282+github-actions[bot]@users.noreply.github.com',
}
Enter fullscreen mode Exit fullscreen mode

Yo yo yo! Check out this code, dawg! It's all about this bot, right? The bot has a name, man! It's called ghBotUsername! And that's not just any name, bro! It's the name of github-actions[bot]! That's like, super cool, right?

And wait, wait! The bot even has an email, man! It's ghBotEmail! And it's like, really crazy! It has all these numbers and symbols and stuff! It's like 41898282+github-actions[bot]@users.noreply.github.com! Can you even handle it? It seems like this bot is totally legit, with its own special email and everything!

The Action YAY-ML 🤣

name: "Check Closed Issues and Generate New Story"
description: "Punk will check cloesed Issue and generate story"
inputs:
  github-token:
    description: "GitHub token for repo"
    required: true
  issue-message:
    description: "Message to reply to new issue as a comment"
    default: "Thank you for creating an Issue and contributing to our community project :tada:. Someone from the community will get back to you soon, usually within 24 hours"
  pr-message:
    description: "Message to reply to new pull request as a comment"
    default: "Thank you for creating a Pull Request and contributing to our community project :tada:. Someone from the community will get back to you soon, usually within 24 hours"
  footer:
    description: "Append issue and pull request message with this message"
    default: ""
runs:
  using: "node16"
  main: "index.js"
Enter fullscreen mode Exit fullscreen mode

Wait a minute, why are there so many input parameters there? OhMyPunk!!!

Ok, I'll explain it very briefly. So here's the explanation 👆👀👆.

Dancing while read the github action file
Yoooooo, you can shake to find out why there are some input parameters in the GitHub Action file above.

(Note: "Shake" here means "scroll up and down quickly to see the context" in a casual and playful way.) 😕

The Main Action

I intentionally didn't write the steps sequentially because they are already very common and ordinary. Consider this post as a truly abstract, abstract painting.

// Filename: index.js
const core = require('@actions/core');
const github = require('@actions/github');
const greetingContributor = require('./scripts/greetingContributor');
const storyGenerator = require('./scripts/storyGenerator');

(async () => {
  try {
    const githubToken = core.getInput('github-token', { required: true });
    const issueMessage = core.getInput('issue-message');
    const prMessage = core.getInput('pr-message');
    const footer = core.getInput('footer');
    const client = github.getOctokit(githubToken);
    const context = github.context;

    switch (context.payload.action) {
      case 'closed':
        await storyGenerator(client, context)
        break;
      case 'opened':
        await greetingContributor(client, context, issueMessage, prMessage, footer)
        break;
      default:
        console.log('No action, skipping');
        core.notice('No action, skipping!');
        break;
    }
  } catch (error) {
    core.setFailed(error.message);
  }
})()
Enter fullscreen mode Exit fullscreen mode

Inject The Core Pack (Whatever you called)

You certainly saw these lines of code above, didn't you?

const core = require('@actions/core');
const github = require('@actions/github');
Enter fullscreen mode Exit fullscreen mode

Yup! Inject those 2 packages with composer 😝 npm

npm install @actions/core @action/github
Enter fullscreen mode Exit fullscreen mode

And you also saw this line of code:

const greetingContributor = require('./scripts/greetingContributor');
const storyGenerator = require('./scripts/storyGenerator');
Enter fullscreen mode Exit fullscreen mode

So, where do they come from and what do they look like?

The Greeting Contributor

An action to greet the contributor in the StreetCommunityProgrammer/metaphore GitHub repository.

const core = require('@actions/core');

module.exports = async (client, context, issueMessage, prMessage, footer) => {
    try {
        const issue = await client.rest.issues.get({
            owner: context.issue.owner,
            repo: context.issue.repo,
            issue_number: context.issue.number,
        })
        const issueData = issue.data
        const labels = issueData.labels.map(label => label.name)

        if (context.payload.action !== 'opened' || labels.includes('story::comment') === true) {
            console.log('No issue / pull request was opened, skipping');
            return;
        }

        const footerTags = `<p>${footer}</p>`;

        if (!!context.payload.issue) {
            await client.rest.issues.createComment({
                owner: context.issue.owner,
                repo: context.issue.repo,
                issue_number: context.issue.number,
                body: issueMessage + footerTags
            });
        } else {
            await client.rest.pulls.createReview({
                owner: context.issue.owner,
                repo: context.issue.repo,
                pull_number: context.issue.number,
                body: prMessage + footerTags,
                event: 'COMMENT'
            });
        }
    } catch (error) {
        core.setFailed(error.message);
    }
}
Enter fullscreen mode Exit fullscreen mode

When the action greets the contributor is triggered (when context.payload.action === opened), the result will be like this.

The Greeting Contributor

The Story Action

Hmmmmmmmmm.... this is the most important feature and functionality of the Metaphore Story - SCP website. Because all the stories displayed on the website are created from here.

module.exports = async (client, context) => {
  try {
    const issue = await client.rest.issues.get({
      owner: context.issue.owner,
      repo: context.issue.repo,
      issue_number: context.issue.number,
    })

    const assignees = issue.data.assignees
    const isReviewerPresence = assignees.some(assignee => ['darkterminal', 'mkubdev'].includes(assignee.login))
    if (issue.data.state === 'closed' && isReviewerPresence) {
      const labels = issue.data.labels.map(label => label.name)

      const metaphors = [
        ['css', 'css'],
        ['golang', 'golang'],
        ['javascript', 'javascript'],
        ['java', 'java'],
        ['maths', 'maths'],
        ['python', 'python'],
        ['php', 'php'],
        ['physics', 'physics'],
        ['ruby', 'ruby'],
        ['rust', 'rust'],
        ['zig', 'zig']
      ]

      const isMetaphor = metaphors.some(([category, label]) => labels.every(l => ['metaphore', category].includes(l)))

      if (isMetaphor) {
        const [category, label] = metaphors.find(([category, label]) => labels.every(l => ['metaphore', category].includes(l)))
        console.log(`Is ${category} metaphor`)
        createMetaphorFile(client, issue.data, context, label)
      }

      addLabelToClosedIssue(client, context.issue.owner, context.issue.repo, context.issue.number, [...labels, 'published'])
    }
  } catch (error) {
    console.log(`Error on storyGenerator: ${error}`)
    return false
  }
}
Enter fullscreen mode Exit fullscreen mode

And this is the code I had before writing this post, it looks really embarrassing and definitely goes against the principles of "A Clean Code" 😎.

module.exports = async (client, context) => {
  try {
    const issue = await client.rest.issues.get({
      owner: context.issue.owner,
      repo: context.issue.repo,
      issue_number: context.issue.number,
    })
    const issueData = issue.data

    const assignees = issue.data.assignees

    const isReviewerPresence = assignees.some(assignee => {
      return assignee.login === "darkterminal" || assignee.login === "mkubdev";
    });

    if (issueData.state === 'closed' && isReviewerPresence) {
      const labels = issueData.labels.map(label => label.name)

      // Metaphor Categories
      const isCssMetaphor = labels.every(label => ['metaphore', 'css'].includes(label))
      const isGolangMetaphor = labels.every(label => ['metaphore', 'golang'].includes(label))
      const isJavaScriptMetaphor = labels.every(label => ['metaphore', 'javascript'].includes(label))
      const isJavaMetaphor = labels.every(label => ['metaphore', 'java'].includes(label))
      const isMathsMetaphor = labels.every(label => ['metaphore', 'maths'].includes(label))
      const isPythonMetaphor = labels.every(label => ['metaphore', 'python'].includes(label))
      const isPhpMetaphor = labels.every(label => ['metaphore', 'php'].includes(label))
      const isPhysicsMetaphor = labels.every(label => ['metaphore', 'physics'].includes(label))
      const isRubyMetaphor = labels.every(label => ['metaphore', 'ruby'].includes(label))
      const isRustMetaphor = labels.every(label => ['metaphore', 'rust'].includes(label))
      const isZigMetaphor = labels.every(label => ['metaphore', 'zig'].includes(label))

      if (isCssMetaphor) {
        console.log(`Is css metaphor`)
        createMetaphorFile(client, issueData, context, 'css')
      } else if (isGolangMetaphor) {
        console.log(`Is golang metaphor`)
        createMetaphorFile(client, issueData, context, 'golang')
      } else if (isJavaScriptMetaphor) {
        console.log(`Is javascript metaphor`)
        createMetaphorFile(client, issueData, context, 'javascript')
      } else if (isJavaMetaphor) {
        console.log(`Is java metaphor`)
        createMetaphorFile(client, issueData, context, 'java')
      } else if (isMathsMetaphor) {
        console.log(`Is maths metaphor`)
        createMetaphorFile(client, issueData, context, 'maths')
      } else if (isPythonMetaphor) {
        console.log(`Is python metaphor`)
        createMetaphorFile(client, issueData, context, 'python')
      } else if (isPhpMetaphor) {
        console.log(`Is php metaphor`)
        createMetaphorFile(client, issueData, context, 'php')
      } else if (isPhysicsMetaphor) {
        console.log(`Is physics metaphor`)
        createMetaphorFile(client, issueData, context, 'physics')
      } else if (isRubyMetaphor) {
        console.log(`Is ruby metaphor`)
        createMetaphorFile(client, issueData, context, 'ruby')
      } else if (isRustMetaphor) {
        console.log(`Is rust metaphor`)
        createMetaphorFile(client, issueData, context, 'rust')
      } else if (isZigMetaphor) {
        console.log(`Is zig metaphor`)
        createMetaphorFile(client, issueData, context, 'zig')
      }
      addLabelToClosedIssue(client, context.issue.owner, context.issue.repo, context.issue.number, [...labels, 'published'])
    }
  } catch (error) {
    console.log(`Erorr on storyGenerator: ${error}`)
    return false
  }
}
Enter fullscreen mode Exit fullscreen mode

Man, there's just way too many if-else statements in this code. We call this Barbarian Coding where I'm from, ha ha! 😝
Barbarian Coding

Another Func That Not Yet Mentioned

createMetaphorFile, addLabelToClosedIssue, dan createFileContent. 3 fungsi yang sangat indah untuk diceritakan.

1. The createMetaphorFile Function

/**
 * Creates a new metaphor file in the specified category, based on the provided issue data and context.
 * @async
 * @function createMetaphorFile
 * @param {Object} client - The client object containing information about the GitHub repository and issue, including owner, repo, and issue number.
 * @param {Object} issueData - The issue data object containing information about the issue, including title, user, created date, and body content.
 * @param {Object} context - The context object containing information about the GitHub repository and issue, including owner, repo, and issue number.
 * @param {string} category - The category of the metaphor file to create.
 * @returns {Promise} A Promise that resolves when the metaphor file has been created in the GitHub repository.
 */
async function createMetaphorFile(client, issueData, context, category) {
  const metaphorTitle = slugify(issueData.title);

  const template = `---
layout: post
title: {title}
author: {author}
created_at: {created_at}
language: {language}
---

{content}`;

  const placeholders = ['{title}', '{author}', '{created_at}', '{language}', '{content}'];
  const values = [
    issueData.title,
    issueData.user.login,
    issueData.created_at,
    category,
    issueData.body,
  ];

  const replacedTemplate = placeholders.reduce((template, placeholder, index) => {
    return template.replace(new RegExp(placeholder, 'g'), values[index]);
  }, template);
  console.log('Replacement result: ' + JSON.stringify(replacedTemplate, undefined, 2))

  const metaphorContent = Buffer.from(replacedTemplate).toString('base64');
  const createContent = await createFileContent({
    client: client,
    owner: context.issue.owner,
    repo: context.issue.repo,
    path: `public/collections/stories/${category}/${metaphorTitle}.md`,
    message: `docs(generate): new metaphor from @${issueData.user.login}`,
    content: metaphorContent,
  });

  console.log(`Content Metadata: ${JSON.stringify(createContent, undefined, 2)}`);
}
Enter fullscreen mode Exit fullscreen mode

let me tell you a funky story about the magical function "createMetaphorFile". This function was like a superhero, with the power to create a brand new metaphor file using data from a GitHub issue. And it did all this using the funky language called JavaScript.

This superhero function had some special helpers to make its job easier. They were named client, issueData, and context, and they were like the sidekicks to createMetaphorFile. They helped it read and write to GitHub repositories and get important info about the issue that the metaphor would be based on.

But the most important part of createMetaphorFile was its secret code called "category". This was like a magical spell that would transform the raw issue data into a groovy new story.

The first thing createMetaphorFile did was to use a spell called "slugify" to turn the title of the issue into a slug. It was like turning a regular sentence into a funky, lowercase string with no spaces or special characters.

Next, createMetaphorFile whipped up a special template for the new metaphor. This template had some placeholders for the title, author, created date, language, and content of the metaphor. It was like a blueprint for the new story.

Then, createMetaphorFile busted out another spell called "reduce". This helped it fill in the blanks of the template by replacing each placeholder with the corresponding value from the issue data. It was like painting a new picture using the colors from an old one.

After that, createMetaphorFile used one more spell to transform the completed template into a funky, computer-friendly code called "base64". This code was like a secret language that only computers could understand.

And finally, createMetaphorFile used its GitHub magic to create a brand new file in the repository. It gave the file a cool name based on the slugified title, and it put the completed template in base64 format inside the file as the content.

And just like that, createMetaphorFile had created a dope new metaphor using the power of GitHub and JavaScript.

The createMetaphorFile Function

2. The createFileContent Function

/**
 * Creates or updates a file in a GitHub repository with the specified content.
 * @async
 * @function createFileContent
 * @param {Object} options - An object containing options for the file creation/update operation.
 * @param {string} options.owner - The owner of the GitHub repository.
 * @param {string} options.repo - The name of the GitHub repository.
 * @param {string} options.path - The path to the file in the repository.
 * @param {string} options.message - The commit message to use for the file update/creation.
 * @param {string} options.content - The content of the file to create/update, encoded in base64.
 * @returns {Promise<Object>} A Promise that resolves with the metadata for the created/updated file.
 */
async function createFileContent({ client, owner, repo, path, message, content }) {
  return await client.rest.repos.createOrUpdateFileContents({
    owner,
    repo,
    path,
    message,
    content,
    committer: {
      name: ghBotUsername,
      email: ghBotEmail
    },
    author: {
      name: ghBotUsername,
      email: ghBotEmail
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

So, there's this function called "createFileContent" that's totally lit! It's written in JavaScript, which is like the coolest language out there.

Basically, createFileContent is like a messenger that helps you talk to GitHub. It can create or update a file in a GitHub repository, which is like a big shared folder where lots of people can work together on projects.

To use createFileContent, you have to give it some options. These options are like instructions that tell createFileContent what to do. You need to tell it who the owner of the repository is, what the name of the repository is, where in the repository you want to put the file, what the message for the commit should be, and most importantly, what the content of the file should be.

Once you've given createFileContent all the options it needs, it goes to work. It uses some magic GitHub spells to create or update the file in the repository with the content you specified. And just like that, your file is saved and ready to be shared with the world!

When createFileContent is done, it gives you back some metadata about the file it created or updated. This metadata is like information about the file, such as when it was last updated, who updated it, and what the file's name and path are.

So there you have it, createFileContent is a totally chill function that helps you create and update files in a GitHub repository like a 🐶!

3. The addLabelToClosedIssue Function

/**
 * Adds labels to a closed issue.
 *
 * @param {Object} client - The authenticated Octokit REST client.
 * @param {string} owner - The owner of the repository.
 * @param {string} repo - The name of the repository.
 * @param {number} issue_number - The number of the issue to add labels to.
 * @param {Array} [labels] - An array of labels to add to the issue.
 * @returns {Promise<void>} A Promise that resolves when the labels have been added to the issue.
 */
async function addLabelToClosedIssue(client, owner, repo, issue_number, labels) {
  await client.rest.issues.addLabels({
    owner,
    repo,
    issue_number,
    labels
  })
  console.log(`Label added: ${labels.join(', ')}`)
}
Enter fullscreen mode Exit fullscreen mode

This code is about adding labels to a closed issue on a website called GitHub.

When someone creates an issue, they can add labels to it to categorize it. Sometimes issues get closed, but you might still want to add labels to them to help people find them later. That's what this code does!

The code takes in four pieces of information: the owner of the repository (kind of like the boss of the project), the name of the repository (where the project lives), the number of the issue (like a code for the issue), and an optional array of labels to add to the issue.

Then, the code uses a magic spell called "addLabels" to add the labels to the issue. Once it's done, it says "Label added" and the names of the labels that were added.

Overall, this code is like adding stickers to a book you've already finished reading, so that when you want to find it again later, you can just look for the stickers!

Thanks to @mkubdev

The Full Metaphor Story

GitHub logo StreetCommunityProgrammer / action-collections

This is repository of SCP Action Collections

SCP Action Collections

This is repository of SCP Action Collections




Top comments (2)

Collapse
 
mkubdev profile image
Maxime Kubik • Edited

Thanks a lot Ali! It was a very fun adventure! Long live Metaphore!

Collapse
 
darkterminal profile image
Imam Ali Mustofa

Spread our punk vibes to the world!!! 🛸