Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 63 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,69 @@ Instead, you can make use of this GitHub Action from the comfort of your own rep
1. [Create a Twitter app](docs/01-create-twitter-app.md) with your shared Twitter account and store the credentials as `TWITTER_API_KEY`, `TWITTER_API_SECRET_KEY`, `TWITTER_ACCESS_TOKEN` and `TWITTER_ACCESS_TOKEN_SECRET` in your repository’s secrets settings.
2. [Create a `.github/workflows/twitter-together.yml` file](docs/02-create-twitter-together-workflow.md) with the content below. Make sure to replace `'main'` if you changed your repository's default branch.

```yml
on: [push, pull_request]
name: Twitter, together!
jobs:
preview:
name: Preview
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: twitter-together/action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tweet:
name: Tweet
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: checkout main
uses: actions/checkout@v3
- name: Tweet
uses: twitter-together/action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
TWITTER_API_KEY: ${{ secrets.TWITTER_API_KEY }}
TWITTER_API_SECRET_KEY: ${{ secrets.TWITTER_API_SECRET_KEY }}
```
```yml
on: [push, pull_request]
name: Twitter, together!
jobs:
preview:
name: Preview
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: checkout pull request
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Tweets
uses: twitter-together/action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tweet:
name: Tweet
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: checkout main
uses: actions/checkout@v3
- name: Tweet
uses: twitter-together/action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
TWITTER_API_KEY: ${{ secrets.TWITTER_API_KEY }}
TWITTER_API_SECRET_KEY: ${{ secrets.TWITTER_API_SECRET_KEY }}
```

TODO: CONFIRM

If you wish to have this action create preview comments in the PR thread, you can use the following config.

Note that `pull_request_target` events have elevated permissions, so if you are using this config, you should configure your repository to only trigger actions that are trusted. You can do this in [various](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/) ways, including preventing outside contributors from triggering actions automatically, and requiring only allowing actions from Verified Creators in your repository Settings -> Actions -> General.

You can also securely enable PR comments only for local branch commits, using the `pull_request` events with an `ENABLE_COMMENTS: 1` env variable, but comments will not be created for PRs from forks.

```yml
# enable comments, but beware of security implications
on: [push, pull_request_target]
name: Twitter, together!
jobs:
preview:
name: Preview
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
permissions:
pull-requests: write
steps:
- name: checkout pull request
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Tweets
uses: twitter-together/action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

3. After creating or updating `.github/workflows/twitter-together.yml` in your repository’s default branch, a pull request will be created with further instructions.

Expand Down
7 changes: 6 additions & 1 deletion docs/02-create-twitter-together-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: twitter-together/action@v2
- name: checkout pull request
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Tweets
uses: twitter-together/action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tweet:
Expand Down
4 changes: 4 additions & 0 deletions lib/common/parse-tweet-file-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { existsSync } = require("fs");
const { join } = require("path");
const { parseTweet } = require("twitter-text");
const { load } = require("js-yaml");
const parseTweetId = require("./parse-tweet-id");

const OPTION_REGEX = /^\(\s?\)\s+/;
const FRONT_MATTER_REGEX = new RegExp(
Expand Down Expand Up @@ -87,6 +88,9 @@ function parseTweetFileContent(text, dir, isThread = false) {
}

function validateOptions(options, text, dir) {
if (options.retweet || options.reply)
parseTweetId(options.retweet || options.reply);

if (options.retweet && !text && options.poll)
throw new Error("Cannot attach a poll to a retweet");

Expand Down
16 changes: 16 additions & 0 deletions lib/common/parse-tweet-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = parseTweetId;

const TWEET_REGEX = /^https:\/\/twitter\.com\/[^/]+\/status\/(\d+)$/;

// TODO allow differently formatted URLs and tweet ids ?
// https://github.com/twitter-together/action/issues/221

// TODO: Should we check if the referenced tweet actually exists?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a great idea, can you file a follow up issue?


function parseTweetId(tweetRef) {
const match = tweetRef.match(TWEET_REGEX);
if (!match) {
throw new Error(`Invalid tweet reference: ${tweetRef}`);
}
return match[1];
}
19 changes: 8 additions & 11 deletions lib/common/tweet.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module.exports = tweet;
const { TwitterApi } = require("twitter-api-v2");
const mime = require("mime-types");

const TWEET_REGEX = /^https:\/\/twitter\.com\/[^/]+\/status\/(\d+)$/;
const parseTweetId = require("./parse-tweet-id");

async function tweet({ twitterCredentials }, tweetData, tweetFile) {
const client = new TwitterApi(twitterCredentials);
Expand All @@ -16,9 +16,8 @@ async function tweet({ twitterCredentials }, tweetData, tweetFile) {

async function handleTweet(client, self, tweet, name) {
if (tweet.retweet && !tweet.text) {
// TODO: Should this throw if an invalid tweet is passed and there is no match?
const match = tweet.retweet.match(TWEET_REGEX);
if (match) return createRetweet(client, self, match[1]);
const tweetId = parseTweetId(tweet.retweet);
if (tweetId) return createRetweet(client, self, tweetId);
}

const tweetData = {
Expand All @@ -33,19 +32,17 @@ async function handleTweet(client, self, tweet, name) {
}

if (tweet.reply) {
// TODO: Should this throw if an invalid reply is passed and there is no match?
const match = tweet.reply.match(TWEET_REGEX);
if (match) {
const tweetId = parseTweetId(tweet.reply);
if (tweetId) {
tweetData.reply = {
in_reply_to_tweet_id: match[1],
in_reply_to_tweet_id: tweetId,
};
}
}

if (tweet.retweet) {
// TODO: Should this throw if an invalid tweet is passed and there is no match?
const match = tweet.retweet.match(TWEET_REGEX);
if (match) tweetData.quote_tweet_id = match[1];
const tweetId = parseTweetId(tweet.retweet);
if (tweetId) tweetData.quote_tweet_id = tweetId;
}

if (tweet.media?.length) {
Expand Down
18 changes: 11 additions & 7 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ async function main() {
const ref = process.env.GITHUB_REF;
const sha = process.env.GITHUB_SHA;
const dir = process.env.GITHUB_WORKSPACE;
const trigger = process.env.GITHUB_EVENT_NAME;
// optionally allow enabling comments them on "pull_request" for local branches
const enableComments = !!process.env.ENABLE_COMMENTS;

const githubState = {
...state,
toolkit,
Expand All @@ -54,15 +58,15 @@ async function main() {
ref,
sha,
dir,
trigger,
enableComments,
};

switch (process.env.GITHUB_EVENT_NAME) {
case "push":
await handlePush(githubState);
break;
case "pull_request":
await handlePullRequest(githubState);
break;
if (trigger === "push") {
await handlePush(githubState);
}
if (trigger === "pull_request" || trigger === "pull_request_target") {
await handlePullRequest(githubState);
}
}

Expand Down
62 changes: 5 additions & 57 deletions lib/pull-request/create-check-run.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,25 @@
module.exports = createCheckRun;

const { autoLink } = require("twitter-text");

const parseTweetFileContent = require("../common/parse-tweet-file-content");

async function createCheckRun(
{ octokit, payload, startedAt, toolkit, dir },
newTweets
{ payload, startedAt, octokit, toolkit },
summary
) {
const parsedTweets = newTweets.map((rawTweet) => {
try {
return parseTweetFileContent(rawTweet, dir);
} catch (error) {
return {
error: error.message,
valid: false,
text: rawTweet,
};
}
});

const allTweetsValid = parsedTweets.every((tweet) => tweet.valid);

// Check runs cannot be created if the pull request was created by a fork,
// so we just log out the result.
// https://help.github.com/en/actions/automating-your-workflow-with-github-actions/authenticating-with-the-github_token#permissions-for-the-github_token
if (payload.pull_request.head.repo.fork) {
for (const tweet of parsedTweets) {
if (tweet.valid) {
toolkit.info(`### ✅ Valid\n\n${tweet.text}`);
} else {
toolkit.info(
`### ❌ Invalid\n\n${tweet.text}\n\n${tweet.error || "Unknown error"}`
);
}
}
process.exit(allTweetsValid ? 0 : 1);
}

const response = await octokit.request(
"POST /repos/:owner/:repo/check-runs",
{
headers: {
accept: "application/vnd.github.antiope-preview+json",
},
owner: payload.repository.owner.login,
repo: payload.repository.name,
name: "preview",
head_sha: payload.pull_request.head.sha,
started_at: startedAt,
completed_at: new Date().toISOString(),
status: "completed",
conclusion: allTweetsValid ? "success" : "failure",
conclusion: summary.valid ? "success" : "failure",
output: {
title: `${parsedTweets.length} tweet(s)`,
summary: parsedTweets.map(tweetToCheckRunSummary).join("\n\n---\n\n"),
title: `${summary.count} tweet(s)`,
summary: summary.body,
},
}
);

toolkit.info(`check run created: ${response.data.html_url}`);
}

function tweetToCheckRunSummary(tweet) {
let text = autoLink(tweet.text)
.replace(/(^|\n)/g, "$1> ")
.replace(/(^|\n)> (\n|$)/g, "$1>$2");

if (!tweet.valid)
return `### ❌ Invalid\n\n${text}\n\n${tweet.error || "Unknown error"}`;

if (tweet.poll)
text +=
"\n\nThe tweet includes a poll:\n\n> 🔘 " + tweet.poll.join("\n> 🔘 ");
return `### ✅ Valid\n\n${text}`;
}
50 changes: 50 additions & 0 deletions lib/pull-request/create-comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
module.exports = createComment;

const BOT_LOGIN = "github-actions[bot]";

const DIVIDER = "\n\n---\n\n*";
const PREVIEW = `${DIVIDER}Preview using `;
const UPDATED = `${DIVIDER}**Updated** preview using `;
const SIGNATURE =
" generated by [Twitter, together!](https://github.com/twitter-together/action)*";

async function createComment({ octokit, payload }, summary) {
const comment = `${summary.title}${summary.body}`;
// check for existing comments.
const comments = await octokit.request(
"GET /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.pull_request.number,
}
);

const match = comments.data.find(
({ user, body }) => user.login === BOT_LOGIN && body.endsWith(SIGNATURE)
);

if (match) {
// update the existing comment
await octokit.request(
"PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
comment_id: match.id,
body: `${comment}${UPDATED}${payload.pull_request.head.sha}${SIGNATURE}`,
}
);
} else {
// post a new comment
await octokit.request(
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.pull_request.number,
body: `${comment}${PREVIEW}${payload.pull_request.head.sha}${SIGNATURE}`,
}
);
}
}
Loading