SKIP TO

Tools, Gatsby

One problem I sometime face is I will be out and about and have an idea for an article or a new project for this site. I have been known to let my site get out of date in the past, so this got me thinking about solutions to unblock this flow. If I have some downtime (for whatever reason) wouldn't it be nice to be able to start drafting these ideas in a user-friendly way that could let us use Markdown as a base so we could actually start formatting things. I had recently been evaluating Sweep AI and found myself drawn to its prompting system: Basically you install their Github app onto your repository and it begins watching for issued that start with Sweep: and this kicks off a code task that eventually creates a PR, linking it all together so you have proper metadata. Smart! Lets steal this idea and make it our own!

Initial script

Initially I just wanted a way to template out a new project locally with a CLI so I had already started the createPost.js node script using inquirer to ask the user (me) questions about the project. I built this using Typescript and using TSX a esbuild-based TS compilation wrapper for Node.

Expand createProject.ts
createProject.jsts
1
2import fs from "fs";
3import path from "path";
4import slugify from "slugify";
5import inquirer from "inquirer";
6import fetch from "node-fetch";
7
8import collaborators from "../src/content/json/collaborators.json";
9
10let imageCounter = 1;
11
12async function fetchImage(url: string): Promise<Buffer> {
13 const response = await fetch(url, {
14 headers: {
15 'Authorization': `token ${process.env.GITHUB_TOKEN}`
16 },
17 });
18
19 if (!response.ok) {
20 throw new Error(`HTTP error! status: ${response.status}`);
21 }
22
23 return await response.buffer();
24}
25
26async function createProject(title?: string, body: string = ''): Promise<void> {
27 let answers;
28 if (title && body) {
29 answers = { title, body };
30 } else {
31 answers = await inquirer.prompt([
32 {
33 type: "input",
34 name: "title",
35 message: "Project title:",
36 validate: function (value) {
37 if (value.length) {
38 return true;
39 } else {
40 return "Please provide a title.";
41 }
42 },
43 },
44 {
45 type: "list",
46 name: "status",
47 message: "Project status:",
48 choices: ["Featured", "Draft", "Published"],
49 default: "Draft",
50 },
51 {
52 type: "input",
53 name: "date",
54 message: "Project date:",
55 default: new Date().toISOString().split("T")[0],
56 },
57 {
58 type: "checkbox",
59 name: "collaborators",
60 message: "Project collaborators:",
61 choices: collaborators.map(collaborator => collaborator.name),
62 },
63 {
64 type: "input",
65 name: "client",
66 message: "Client:",
67 },
68 {
69 type: "confirm",
70 name: "ctaConfirm",
71 message: "Do you want to add a CTA?",
72 default: false,
73 },
74 {
75 type: "input",
76 name: "ctaHeading",
77 message: "CTA Heading:",
78 when: answers => answers.ctaConfirm,
79 },
80 {
81 type: "input",
82 name: "ctaLinkDesc",
83 message: "CTA Link Description:",
84 when: answers => answers.ctaConfirm,
85 },
86 {
87 type: "input",
88 name: "ctaLinkType",
89 message: "CTA Link Type:",
90 when: answers => answers.ctaConfirm,
91 },
92 {
93 type: "input",
94 name: "ctaLinkURL",
95 message: "CTA Link URL:",
96 when: answers => answers.ctaConfirm,
97 },
98 {
99 type: "input",
100 name: "ctaSubHeading",
101 message: "CTA Sub Heading:",
102 when: answers => answers.ctaConfirm,
103 },
104 {
105 type: "checkbox",
106 name: "categories",
107 message: "Project categories:",
108 choices: [
109 "Product Development",
110 "Web Development",
111 "Backend Development",
112 ],
113 },
114 ]);
115 }
116 const slug = slugify(answers.title, { lower: true });
117 const dir = path.join(__dirname, "../src/content/projects");
118 const filePath = path.join(dir, `${slug}/index.mdx`);
119
120 if (body) {
121 // Parse the body to find image URLs
122 const imageUrls = [];
123 body = body.replace(/![.*?]((.*?))/g, (match, url) => {
124 imageUrls.push(url as never);
125 return match;
126 });
127 console.log("imageUrls", imageUrls);
128
129 // Ensure the /images directory exists
130 const imagesDir = path.join(
131 __dirname,
132 `../src/content/projects/${slug}/images`
133 );
134 if (!fs.existsSync(imagesDir)) {
135 fs.mkdirSync(imagesDir, { recursive: true });
136 }
137
138 // Download the images and update the image references
139 for (const url of imageUrls) {
140 try {
141 const buffer = await fetchImage(url);
142 const filename = `image-${imageCounter}.png`;
143 const localPath = path.join(imagesDir, filename);
144
145 // Save the image to a local file
146 fs.writeFileSync(localPath, buffer);
147
148 // Update the image reference in the body
149 body = body.replace(url, `./images/${filename}`);
150
151 // Increment the counter for the next image
152 imageCounter++;
153 } catch (error) {
154 console.error(`Failed to download image from ${url}: ${error.message}`);
155 }
156 }
157 }
158 // Conditionally include featuredImage
159 let featuredImage = "";
160 if (answers.featuredImage) {
161 featuredImage = `featuredImage: ${answers.featuredImage}
162 `;
163 }
164 const postStatus = answers.status || "Draft";
165 const date = answers.date || new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
166
167 if (fs.existsSync(filePath)) {
168 console.error(`File ${filePath} already exists.`);
169 process.exit(1);
170 }
171
172 let ctaContent = "";
173 if (answers.ctaConfirm) {
174 ctaContent = `
175cta:
176 heading: '${answers.ctaHeading}'
177 linkDesc: '${answers.ctaLinkDesc}'
178 linkType: '${answers.ctaLinkType}'
179 linkURL: '${answers.ctaLinkURL}'
180 subHeading: '${answers.ctaSubHeading}'`;
181 }
182
183 const content = `---
184title: "${answers.title}"
185status: ${postStatus}
186date: ${date}
187collaborators: [${
188 Array.isArray(answers.collaborators) ? answers.collaborators.join(", ") : ""
189 }]
190
191${featuredImage}
192client: "${answers.client}"
193${ctaContent}
194categories: [${
195 Array.isArray(answers.categories) ? answers.categories.join(", ") : "TBD"
196 }]
197---
198
199# ${answers.title}
200
201${body}
202`;
203
204 fs.mkdirSync(path.join(dir, slug), { recursive: true });
205 fs.writeFileSync(filePath, content);
206}
207
208
209let title: string | undefined, body: string | undefined;
210if (process.argv.length >= 4) {
211 [title, body] = process.argv.slice(2);
212 body = fs.readFileSync(body, 'utf8');
213}
214createProject(title, body);
215
Expand createPost.ts
createPost.jsts
1
2import fs from "fs";
3import path from "path";
4import slugify from "slugify";
5import inquirer from "inquirer";
6import fetch from "node-fetch";
7
8let imageCounter = 1;
9
10async function fetchImage(url: string): Promise<Buffer> {
11 const response = await fetch(url, {
12 headers: {
13 'Authorization': `token ${process.env.GITHUB_TOKEN}`
14 },
15 });
16
17 if (!response.ok) {
18 throw new Error(`HTTP error! status: ${response.status}`);
19 }
20
21 return await response.buffer();
22}
23
24interface Answers {
25 title: string;
26 subtitle?: string;
27 status?: string;
28 date?: string;
29 categories?: string[];
30 body?: string;
31}
32
33async function createPost(title?: string, body: string = ''): Promise<void> {
34 let answers: Answers;
35 if (title && body) {
36 answers = { title, body };
37 } else {
38 answers = await inquirer.prompt([
39 {
40 type: "input",
41 name: "title",
42 message: "Post title:",
43 validate: function (value) {
44 if (value.length) {
45 return true;
46 } else {
47 return "Please provide a title.";
48 }
49 },
50 },
51 {
52 type: "input",
53 name: "subtitle",
54 message: "Post subtitle:",
55 },
56 {
57 type: "list",
58 name: "status",
59 message: "Post status:",
60 choices: ["Featured", "Draft", "Published"],
61 default: "Draft",
62 },
63 {
64 type: "input",
65 name: "date",
66 message: "Post date:",
67 default: new Date().toISOString().split("T")[0],
68 },
69 {
70 type: "checkbox",
71 name: "categories",
72 message: "Post categories:",
73 choices: ["Updates", "Tutorial", "News", "Review"],
74 },
75 ]);
76 }
77 const slug = slugify(answers.title, { lower: true });
78 const dir = path.join(__dirname, "../src/content/posts");
79 const filePath = path.join(dir, `${slug}/index.mdx`);
80
81 if (body) {
82 // Parse the body to find image URLs
83 const imageUrls = [];
84 body = body.replace(/![.*?]((.*?))/g, (match, url) => {
85 imageUrls.push(url as never);
86 return match;
87 });
88 console.log("imageUrls", imageUrls);
89
90 // Ensure the /images directory exists
91 const imagesDir = path.join(
92 __dirname,
93 `../src/content/posts/${slug}/images`
94 );
95 if (!fs.existsSync(imagesDir)) {
96 fs.mkdirSync(imagesDir, { recursive: true });
97 }
98
99 // Download the images and update the image references
100 for (const url of imageUrls) {
101 try {
102 const buffer = await fetchImage(url);
103 const filename = `image-${imageCounter}.png`;
104 const localPath = path.join(imagesDir, filename);
105
106 // Save the image to a local file
107 fs.writeFileSync(localPath, buffer);
108
109 // Update the image reference in the body
110 body = body.replace(url, `./images/${filename}`);
111
112 // Increment the counter for the next image
113 imageCounter++;
114 } catch (error) {
115 console.error(`Failed to download image from ${url}: ${error.message}`);
116 }
117 }
118 }
119
120 // Conditionally include subtitle
121 let subtitle = "";
122 if (answers.subtitle) {
123 subtitle = `subtitle: ${answers.subtitle}
124 `;
125 }
126 const postStatus = answers.status || "Draft";
127 const date = answers.date || new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
128
129 if (fs.existsSync(filePath)) {
130 console.error(`File ${filePath} already exists.`);
131 process.exit(1);
132 }
133
134 const content = `---
135title: ${answers.title}
136${subtitle}
137status: ${postStatus}
138date: ${date}
139categories: [${Array.isArray(answers.categories) ? answers.categories.join(", ") : "TBD"
140 }]
141---
142
143# ${answers.title}
144
145${body}
146`;
147
148 fs.mkdirSync(path.join(dir, slug), { recursive: true });
149 fs.writeFileSync(filePath, content);
150
151}
152
153let title: string | undefined, body: string | undefined;
154if (process.argv.length >= 4) {
155 [title, body] = process.argv.slice(2);
156 body = fs.readFileSync(body, 'utf8').toString();
157}
158createPost(title, body);
159

Creating a Github Action

Next, I wanted to be able to run this script in a Github action based on the creation of a new issue just like Sweep. So off I went researching and creating a github action that can:

  • Support full markdown syntax for easy writing
  • Support for grabbing any images attached to an issue (this was tricky)
Expand github-actions-issue-to-post.yml
github-actions-issue-to-post.ymlyml
1
2name: Create Post/Project
3
4on:
5 issues:
6 types:
7 - opened
8
9jobs:
10 create-post-project:
11 runs-on: ubuntu-latest
12
13 steps:
14 - name: Check issue title
15 run: |
16 if [[ ! "${{ github.event.issue.title }}" =~ ^(Post:|Project:) ]]; then
17 echo "Issue title does not start with 'Post:' or 'Project:', exiting..."
18 exit 78
19 fi
20 shell: bash
21
22 - name: Checkout code
23 uses: actions/checkout@v2
24
25 - name: Setup Node
26 uses: actions/setup-node@v2
27 with:
28 node-version: "18"
29 cache: "yarn"
30 - run: |
31 cd scripts
32 yarn install --frozen-lockfile
33
34 - name: Write issue body to temp file
35 run: |
36 cat << 'EOF' > issue_body.txt
37 ${{ github.event.issue.body }}
38 EOF
39
40 - name: Create Post
41 run: |
42 BRANCH_NAME="${{ github.event.issue.title }}"
43 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix
44 BRANCH_NAME=$(echo "${BRANCH_NAME}" | sed 's/[^a-zA-Z0-9]/-/g') # Sanitize further
45 export GITHUB_TOKEN=${{ secrets.BROAD_GITHUB_TOKEN }}
46 yarn post "$BRANCH_NAME" issue_body.txt
47 shell: bash
48 if: startsWith(github.event.issue.title, 'Post:')
49
50 - name: Create Project
51 run: |
52 BRANCH_NAME="${{ github.event.issue.title }}"
53 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix
54 BRANCH_NAME=$(echo "${BRANCH_NAME}" | sed 's/[^a-zA-Z0-9]/-/g') # Sanitize further
55 export GITHUB_TOKEN=${{ secrets.BROAD_GITHUB_TOKEN }}
56 yarn project "$BRANCH_NAME" issue_body.txt
57 shell: bash
58 if: startsWith(github.event.issue.title, 'Project:')
59
60 - name: Set up Git user
61 run: |
62 git config --global user.email "actions@github.com"
63 git config --global user.name "GitHub Actions"
64
65 - name: Delete temp file
66 run: rm issue_body.txt
67
68 - name: Create branch
69 id: create_branch
70 run: |
71 BRANCH_NAME="${{ github.event.issue.title }}"
72 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix
73 BRANCH_NAME=${BRANCH_NAME// /-} # This will replace spaces with hyphens
74 BRANCH_NAME=$(echo "${BRANCH_NAME}" | sed 's/[^a-zA-Z0-9]/-/g') # Sanitize further
75 if [[ "${{ startsWith(github.event.issue.title, 'Project:') }}" == "true" ]]; then
76 BRANCH_TYPE="project"
77 else
78 BRANCH_TYPE="post"
79 fi
80 BRANCH_NAME="${BRANCH_TYPE}/${BRANCH_NAME}" # This will add the branch type as a prefix
81 git checkout -b "$BRANCH_NAME"
82 git add .
83 git commit -m "Created post/project from issue"
84 git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$BRANCH_NAME"
85 echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV
86
87 - name: Link branch to issue
88 run: |
89 BRANCH_NAME="${{ github.event.issue.title }}"
90 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix
91 BRANCH_NAME=${BRANCH_NAME// /-} # This will replace spaces with hyphens
92 curl -X POST -H "Authorization: token ${{ secrets.BROAD_GITHUB_TOKEN }}" -d "{ "body": "Branch: [${BRANCH_NAME}](https://github.com/${{ github.repository }}/tree/${BRANCH_NAME})" }" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments"
93
94 - name: Close issue
95 run: |
96 curl -X PATCH -H "Authorization: token ${{ secrets.BROAD_GITHUB_TOKEN }}" -d '{ "state": "closed" }' "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}"
97
98 - name: Create Pull Request
99 uses: actions/github-script@v5
100 with:
101 github-token: ${{ secrets.GITHUB_TOKEN }}
102 script: |
103 const { owner, repo } = context.repo
104 const branch = process.env.branch_name
105 const issue_number = "${{ github.event.issue.number }}"
106 const pr = await github.rest.pulls.create({
107 owner,
108 repo,
109 title: `PR for ${branch}`,
110 head: branch,
111 base: 'main', // or the branch you want to merge into
112 body: `Related to issue #${issue_number}`,
113 // draft: true, // not supporting on GitHub free plans
114 })
115 console.log(`Created PR ${pr.data.html_url}`)
116

Conclusion

This lets me draft content wherever I am, on mobile or otherwise! Once the issue is created 5 mins later I have a new PR created for me, and thanks to Netlify, I have a deploy link to review the draft before it goes live!