user@namudi: ~

Webhook과 Cloudflare Workers로 Github와 Asana 자동화하기

>2025.02.04.

Asana와 Github 어떻게 연결하지?

연결 방법에는 뭐가 있을까?

Github Actions

Asana에서는 기본적으로 Github와 연결할 수 있는 방법을 제공한다. Asana For Github가 그 예인데, 이것의 문제는 Github Actions로 이루어져 있다는 것이다. 간단하게 작업에 PR을 연동하는 데에는 큰 문제가 없지만, 더 다양하고 복잡한 워크플로를 구성하기에는 어려움이 있다.

Zapier, Make, Unito

다음으로는 Third-Party 앱을 사용할 수 있다. 사실 제일 간편하고 쉬운 방법이지만 이걸 선택하지 않은 이유는 “유료” 라는 것이다.
물론 회사 단위에서는 크게 부담가는 금액이 아니겠지만, 사이드 프로젝트를 진행하며 지불하기에는 비용이 꽤 컸다.
Zapier는 가장 저렴한 유료 플랜이 19달러쯤이고, Make는 9달러 정도이다. Unito는 좀 많이 비싸다. Make의 경우 9달러 정도로도 제공하는게 많아 사용해보려 했는데, Zapier에 비하면 러닝커브가 꽤 있었다.
그래서 그냥 내 입맛대로 한 번 해보자! 는 생각으로 마지막 방법을 찾아봤는데..

Webhooks

웹훅이란,
웹 개발에서 사용자 정의 콜백을 사용하여 웹 페이지 또는 웹 애플리케이션의 동작을 강화하거나 변경하는 방법
이라고 설명이 나와있다.
일반적인 REST API로 구축된 웹은 하나의 요청에 하나의 응답을 제공한다. 특정 이벤트가 발생했는지 조회하려면 클라이언트가 서버에 요청을 보내 응답을 받아야 하는게 먼저라는 것이다.
하지만 웹훅은 반대로 서버에서 특정 이벤트가 발생했을 때, 클라이언트를 호출하는 방식이다. 그래서 서버 측에서는 클라이언트의 어떤 URL로 데이터를 보낼지 주소를 정해놓는데, 이 주소를 Callback URL이라 한다.
이 Callback URL을 클라이언트가 서버에 제공해두면, 서버에서 어떠한 이벤트가 발생했을 때 이 Callback URL로 이벤트 데이터가 넘어온다. 그러면 이 이벤트 데이터를 클라이언트(나)가 처리하면 되는 것이다.
이해가 잘 안간다면 예제로 이해해보자.
  1. AWS Lambda, Cloudflare Wokers 등을 이용해 Callback URL을 만든다.
  2. Github Repository에 이 Callback URL을 Webhook로 등록하고, Repository에 Push 이벤트가 발생하면 이벤트 데이터를 받도록 설정한다.
  3. Callback URL로 이벤트 데이터가 들어오면 Slack의 채널에 메세지를 보내도록 한다.
  4. Github Repository에 Commit이 Push되면 Slack의 채널에 메세지가 보내진다.

Webhooks를 통해 연동하는 방법

Cloudflare Workers를 이용해 서버를 구성하고 Callback URL을 만들기로 했다. 받은 이벤트 데이터를 내 입맛대로 조작할 수 있는데다, 서드파티보다는 훨씬 저렴하고 사이드 프로젝트 단위에서는 무료로 사용할 수도 있기 때문이다.
이제 내가 Github와 Asana를 어떤 식으로 연동할지 플로우를 작성해보았다.

Asana + Github

아사나는 먼저 4개의 섹션으로 할 일을 구분했다.
  1. To Do (할 일, 시작 전)
  2. In Progress (진행 중, 작업 중)
  3. In Review (풀 리퀘스트)
  4. Done (머지됨, 완료됨)
Github는 Issue와 Pull Request로 작업을 관리한다. 이제 이 둘을 잘 조합해야 하는데, 먼저 핵심 툴을 하나 정해야 했다.
내가 생각해 낸 방법은 다음과 같다.
  1. Asana에서는 작업을 관리한다. (작업의 우선순위, 마감일, 연결된 작업 등 자유도가 높다.)
  2. Github에서는 개발 진행상황과 코드 리뷰를 관리한다. (코드를 블럭 단위로 코멘트를 남기거나 커밋 단위의 대화가 가능해서 개발 협업에는 Github가 더 유리하다.)
따라서 이 둘을 조화롭게 연동시켜야 했다.

연동 흐름

Dev Backlog를 모두 작성했고 이를 각 팀원에게 분배해서 작업을 시작해야 하는 상황이라고 가정하자.
  1. 아사나에 Dev Backlog를 작성한다. 담당자가 할당되지 않으면 Github Issue는 생성되지 않는다.
  2. 아사나 작업에 담당자가 할당되면 Github Issue가 생성된다.
    1. 이슈 내용, 레이블은 모두 아사나 작업에서 설정한대로 생성되며, 브랜치도 자동 생성된다.
    2. 아사나 작업 ID는 이슈 내용에 포함되어 이슈를 추적할 수 있게 해준다.
    3. 현재 진행 상황(아사나 섹션)은 Issue의 레이블로 구별한다.
    4. 이슈를 생성하는 아이디는 아사나 아이디와 깃허브 아이디를 매칭해, 할당된 담당자가 이슈를 생성한다.
  3. 해당 작업을 시작하게 되면 아사나 작업을 In Progress 섹션으로 옮긴다.
    1. 해당 아사나 작업에 대응되는 Github Issue의 레이블이 To Do에서 In Progress로 바뀐다.
  4. 개발이 완료되어 Pull Request 요청을 보낸다.
    1. 아사나 작업의 섹션이 자동으로 In Review로 옮겨진다.
    2. 해당 아사나 작업에 대응되는 Github Issue의 레이블이 In Progress에서 In Review로 옮겨진다.
  5. 문제가 없어 정상적으로 머지된다.
    1. PR이 닫히며 Issue도 닫히게 되고, Issue의 레이블은 Done으로 바뀐다.
    2. 해당하는 아사나 작업의 섹션도 Done으로 옮겨지며 작업 완료 처리된다.

Cloudflare Workers를 설정해보자

https://workers.cloudflare.com/에 접속해서 로그인(회원가입)을 한 후, 대시보드로 이동한다.
중앙 오른쪽 상단에 있는 “생성” 버튼을 클릭해서 Worker를 생성할 수 있다. Hello World Worker로 일단 생성한 후, 코드를 편집할 수 있다.
상단 탭에서 메트릭을 누르면 요청, 오류 등등의 데이터를 볼 수 있고 오른쪽 위에 “코드 편집”을 눌러 코드를 편집할 수 있다. 상단 탭에서 로그를 누르게 되면 현재 베타이긴 하지만 실시간 스트림으로 로그를 확인할 수 있다.
“코드 편집”을 누르고 코드를 편집해보자.
처음엔 Hello World로 되어있을텐데, 이걸 수정해볼 것이다. 자바스크립트 코드이니 자바스크립트를 잘 다루면 좋을 것이다.
일단 위의 플로우에 해당하는 내 코드는 아래와 같다.
// Configuration
const GITHUB_TOKENS = {
  'asana_user_id_1': 'GITHUB_PERSONAL_TOKEN',
  'asana_user_id_2': 'GITHUB_PERSONAL_TOKEN',
  'default': 'GITHUB_PERSONAL_TOKEN',
};
 
const ASANA_PERSONAL_ACCESS_TOKEN = 'ENTER_HERE';
const GITHUB_REPO_OWNER = 'ENTER_HERE';
const GITHUB_REPO_NAME = 'ENTER_HERE';
 
// Main event listener
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
});
 
async function handleRequest(request) {
  console.log('Received request:', request.method, request.url);
 
  // Asana webhook verification
  const hookSecret = request.headers.get('X-Hook-Secret');
  if (hookSecret) {
    return new Response('', {
      status: 200,
      headers: { 'X-Hook-Secret': hookSecret }
    });
  }
 
  if (request.method === 'POST') {
    const contentType = request.headers.get('content-type');
    let payload;
 
    if (contentType.includes('application/json')) {
      payload = await request.json();
    } else if (contentType.includes('application/x-www-form-urlencoded')) {
      const formData = await request.formData();
      payload = JSON.parse(formData.get('payload'));
    } else {
      return new Response('Unsupported Media Type', { status: 415 });
    }
 
    const source = request.headers.get('User-Agent').startsWith('GitHub-Hookshot/') ? 'github' : 'asana';
 
    if (source === 'asana') {
      await handleAsanaWebhook(payload);
    } else if (source === 'github') {
      await handleGitHubWebhook(payload);
    }
 
    return new Response('Webhook processed', { status: 200 });
  }
 
  return new Response('Not found', { status: 404 });
}
 
async function handleAsanaWebhook(payload) {
  if (payload.events && payload.events.length > 0) {
    const processedTasks = new Set();
    for (const event of payload.events) {
      if (event.resource && event.resource.gid && !processedTasks.has(event.resource.gid)) {
        processedTasks.add(event.resource.gid);
        const taskId = event.resource.gid;
        const taskDetails = await getAsanaTaskDetails(taskId);
        
        if (taskDetails) {
          if (event.action === 'added' || event.action === 'changed') {
            await handleAsanaTaskChange(taskDetails);
          } else if (event.action === 'removed' || event.action === 'deleted') {
            await closeGitHubIssue(taskId);
          }
        }
      }
    }
  }
}
 
async function handleAsanaTaskChange(task) {
  const sectionName = task.memberships[0]?.section.name;
  if (task.assignee) {
    const issue = await findOrCreateGitHubIssue(task);
    if (issue) {
      await updateGitHubIssueStatus(issue.number, sectionName);
    }
  } else if (sectionName === 'Done') {
    await closeGitHubIssue(task.gid);
  }
}
 
async function handleGitHubWebhook(payload) {
  if (payload.pull_request) {
    const asanaTaskId = extractAsanaTaskIdFromPR(payload.pull_request);
    if (asanaTaskId) {
      console.log(`Found Asana Task ID: ${asanaTaskId} in PR #${payload.pull_request.number}`);
      
      if (payload.action === 'opened') {
        console.log(`PR #${payload.pull_request.number} opened. Updating Asana task and GitHub issue to 'In Review'`);
        await updateAsanaTaskSection(asanaTaskId, 'In Review');
        await updateGitHubIssueForPR(asanaTaskId, 'In Review');
      } else if (payload.action === 'closed') {
        if (payload.pull_request.merged) {
          console.log(`PR #${payload.pull_request.number} merged. Updating Asana task and GitHub issue to 'Done'`);
          await updateAsanaTaskSection(asanaTaskId, 'Done');
          await updateGitHubIssueForPR(asanaTaskId, 'Done');
          await completeAsanaTask(asanaTaskId);  // 새로운 함수 추가
        } else {
          console.log(`PR #${payload.pull_request.number} closed without merging. Updating Asana task and GitHub issue to 'In Progress'`);
          await updateAsanaTaskSection(asanaTaskId, 'In Progress');
          await updateGitHubIssueForPR(asanaTaskId, 'In Progress');
        }
      } else if (payload.action === 'reopened') {
        console.log(`PR #${payload.pull_request.number} reopened. Updating Asana task and GitHub issue to 'In Review'`);
        await updateAsanaTaskSection(asanaTaskId, 'In Review');
        await updateGitHubIssueForPR(asanaTaskId, 'In Review');
      }
    } else {
      console.log(`No Asana Task ID found in PR #${payload.pull_request.number}`);
    }
  }
}
 
async function completeAsanaTask(taskId) {
  const asanaApiUrl = `https://app.asana.com/api/1.0/tasks/${taskId}`;
  const response = await fetch(asanaApiUrl, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${ASANA_PERSONAL_ACCESS_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        completed: true
      }
    })
  });
 
  if (response.ok) {
    console.log(`Completed Asana task ${taskId}`);
  } else {
    console.error('Failed to complete Asana task:', await response.text());
  }
}
 
async function getAsanaTaskDetails(taskId) {
  const asanaApiUrl = `https://app.asana.com/api/1.0/tasks/${taskId}`;
  const response = await fetch(asanaApiUrl, {
    headers: {
      'Authorization': `Bearer ${ASANA_PERSONAL_ACCESS_TOKEN}`,
      'Accept': 'application/json'
    }
  });
  
  if (response.ok) {
    const data = await response.json();
    return data.data;
  } else {
    console.error('Failed to fetch Asana task details:', await response.text());
    return null;
  }
}
 
async function findOrCreateGitHubIssue(asanaTask) {
  const githubApiUrl = `https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/issues`;
  const githubToken = GITHUB_TOKENS[asanaTask.assignee?.gid] || GITHUB_TOKENS['default'];
 
  // 정확한 구문 검색을 위해 따옴표로 묶고, 고유 식별자 추가
  const searchQuery = encodeURIComponent(`"Asana Task ID: ${asanaTask.gid}" in:body repo:${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}`);
  const searchUrl = `https://api.github.com/search/issues?q=${searchQuery}`;
  
  const searchResponse = await fetch(searchUrl, {
    headers: {
      'Authorization': `token ${githubToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'User-Agent': 'Asana-GitHub-Integration'
    }
  });
 
  if (searchResponse.ok) {
    const searchData = await searchResponse.json();
    if (searchData.items && searchData.items.length > 0) {
      // 정확히 일치하는 이슈 찾기
      const exactMatch = searchData.items.find(item => 
        item.body.includes(`Asana Task ID: ${asanaTask.gid}`)
      );
      
      if (exactMatch) {
        // 기존 이슈 업데이트
        const updateResponse = await fetch(`${githubApiUrl}/${exactMatch.number}`, {
          method: 'PATCH',
          headers: {
            'Authorization': `token ${githubToken}`,
            'Accept': 'application/vnd.github.v3+json',
            'Content-Type': 'application/json',
            'User-Agent': 'Asana-GitHub-Integration'
          },
          body: JSON.stringify({
            title: asanaTask.name,
            body: `Asana Task ID: ${asanaTask.gid}\n${asanaTask.permalink_url}\n\n${asanaTask.notes || ''}`,
          })
        });
 
        if (updateResponse.ok) {
          console.log(`Updated GitHub issue #${exactMatch.number}`);
          return await updateResponse.json();
        } else {
          console.error('Failed to update GitHub issue:', await updateResponse.text());
          return exactMatch;
        }
      }
    }
  } else {
    console.error('Failed to search GitHub issues:', await searchResponse.text());
  }
 
  // 기존 이슈를 찾지 못했으면 새 이슈 생성
  const createResponse = await fetch(githubApiUrl, {
    method: 'POST',
    headers: {
      'Authorization': `token ${githubToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json',
      'User-Agent': 'Asana-GitHub-Integration'
    },
    body: JSON.stringify({
      title: asanaTask.name,
      body: `Asana Task ID: ${asanaTask.gid}\n${asanaTask.permalink_url}\n\n${asanaTask.notes || ''}`,
      labels: ['To Do']
    })
  });
 
  if (createResponse.ok) {
    console.log('Created new GitHub issue');
    return await createResponse.json();
  } else {
    console.error('Failed to create GitHub issue:', await createResponse.text());
    return null;
  }
}
 
async function updateGitHubIssueStatus(issueNumber, sectionName) {
  const githubApiUrl = `https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/issues/${issueNumber}`;
  const githubToken = GITHUB_TOKENS['default'];
 
  let newLabel;
  switch (sectionName) {
    case 'To Do':
      newLabel = 'To Do';
      break;
    case 'In Progress':
      newLabel = 'In Progress';
      break;
    case 'In Review':
      newLabel = 'In Review';
      break;
    case 'Done':
      newLabel = 'Done';
      break;
    default:
      newLabel = 'To Do';
  }
 
  const response = await fetch(githubApiUrl, {
    method: 'PATCH',
    headers: {
      'Authorization': `token ${githubToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json',
      'User-Agent': 'Asana-GitHub-Integration'
    },
    body: JSON.stringify({
      state: sectionName === 'Done' ? 'closed' : 'open',
      labels: [newLabel]
    })
  });
 
  if (response.ok) {
    console.log(`Updated GitHub issue #${issueNumber} status to ${newLabel}`);
  } else {
    console.error('Failed to update GitHub issue:', await response.text());
  }
}
 
async function closeGitHubIssue(asanaTaskId) {
  const githubApiUrl = `https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/issues`;
  const githubToken = GITHUB_TOKENS['default'];
 
  const searchQuery = encodeURIComponent(`"Asana Task ID: ${asanaTaskId}" in:body repo:${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}`);
  const searchUrl = `https://api.github.com/search/issues?q=${searchQuery}`;
  const searchResponse = await fetch(searchUrl, {
    headers: {
      'Authorization': `token ${githubToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'User-Agent': 'Asana-GitHub-Integration'
    }
  });
 
  if (searchResponse.ok) {
    const searchData = await searchResponse.json();
    if (searchData.items && searchData.items.length > 0) {
      const issue = searchData.items[0];
      const updateUrl = `${githubApiUrl}/${issue.number}`;
      const updateResponse = await fetch(updateUrl, {
        method: 'PATCH',
        headers: {
          'Authorization': `token ${githubToken}`,
          'Accept': 'application/vnd.github.v3+json',
          'Content-Type': 'application/json',
          'User-Agent': 'Asana-GitHub-Integration'
        },
        body: JSON.stringify({
          state: 'closed',
          labels: ['Done']
        })
      });
 
      if (updateResponse.ok) {
        console.log(`Closed GitHub issue #${issue.number}`);
      } else {
        console.error('Failed to close GitHub issue:', await updateResponse.text());
      }
    } else {
      console.log(`No GitHub issue found for Asana Task ID: ${asanaTaskId}`);
    }
  } else {
    console.error('Failed to search for GitHub issue:', await searchResponse.text());
  }
}
 
async function updateAsanaTaskSection(taskId, sectionName) {
  const projectId = await getAsanaProjectId(taskId);
  const sectionId = await getAsanaSectionId(projectId, sectionName);
 
  if (!projectId || !sectionId) {
    console.error(`Failed to find project or section for task ${taskId}`);
    return;
  }
 
  const asanaApiUrl = `https://app.asana.com/api/1.0/sections/${sectionId}/addTask`;
  const response = await fetch(asanaApiUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${ASANA_PERSONAL_ACCESS_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        task: taskId
      }
    })
  });
 
  if (response.ok) {
    console.log(`Updated Asana task ${taskId} to section ${sectionName}`);
  } else {
    console.error('Failed to update Asana task section:', await response.text());
  }
}
 
async function getAsanaProjectId(taskId) {
  const taskDetails = await getAsanaTaskDetails(taskId);
  return taskDetails.projects[0]?.gid;
}
 
async function getAsanaSectionId(projectId, sectionName) {
  const asanaApiUrl = `https://app.asana.com/api/1.0/projects/${projectId}/sections`;
  const response = await fetch(asanaApiUrl, {
    headers: {
      'Authorization': `Bearer ${ASANA_PERSONAL_ACCESS_TOKEN}`,
      'Accept': 'application/json'
    }
  });
 
  if (response.ok) {
    const data = await response.json();
    const section = data.data.find(s => s.name === sectionName);
    return section?.gid;
  } else {
    console.error('Failed to fetch Asana sections:', await response.text());
    return null;
  }
}
 
function extractAsanaTaskIdFromPR(pullRequest) {
  const bodyLines = pullRequest.body.split('\n');
  for (const line of bodyLines) {
    if (line.startsWith('Asana Task ID:')) {
      return line.split(':')[1].trim();
    }
  }
  return null;
}
 
async function updateGitHubIssueForPR(asanaTaskId, newStatus) {
  const githubApiUrl = `https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/issues`;
  const githubToken = GITHUB_TOKENS['default'];
 
  // Search for the issue with the Asana Task ID
  const searchQuery = encodeURIComponent(`"Asana Task ID: ${asanaTaskId}" in:body repo:${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}`);
  const searchUrl = `https://api.github.com/search/issues?q=${searchQuery}`;
  
  const searchResponse = await fetch(searchUrl, {
    headers: {
      'Authorization': `token ${githubToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'User-Agent': 'Asana-GitHub-Integration'
    }
  });
 
  if (searchResponse.ok) {
    const searchData = await searchResponse.json();
    if (searchData.items && searchData.items.length > 0) {
      const issue = searchData.items[0];
      await updateGitHubIssueStatus(issue.number, newStatus);
    } else {
      console.log(`No GitHub issue found for Asana Task ID: ${asanaTaskId}`);
    }
  } else {
    console.error('Failed to search GitHub issues:', await searchResponse.text());
  }
}
 

Asana-Cloudflare Workers-Github를 본격적으로 연동해보자

연동을 위해서는 Asana와 Github 모두 Webhooks 설정을 해주어야한다. Github는 Repository의 설정에 Webhooks 섹션이 있고, 거기에서 설정하면 된다. Asana는 조금 더 복잡한데, Webhooks 생성을 위해서는 Asana가 제공하는 URL로 POST 요청을 보내야한다.
JSON 데이터와 함께 보내야 하고, 나는 Postman을 통해 진행했다.
{
  "data": {
    "resource": "YOUR_PROJECT_OR_WORKSPACE_ID",
    "target": "https://your-webhook-endpoint.com/webhook",
    "filters": [
      {
        "action": "added",
        "resource_type": "task"
      },
      {
        "action": "changed",
        "resource_type": "task"
      }
    ]
  }
}
filters 에는 원하는 이벤트 유형을 지정하면 된다. 나는 생성과 수정만 하면 되므로 두 이벤트 유형을 지정했다.
요청을 성공적으로 보냈다면, 아래와 같은 응답을 받을 것이다.
{
    "data": {
        "gid": "YOUR_PROJECT_OR_WORKSPACE_ID",
        "resource_type": "webhook",
        "target": "https://your-webhook-endpoint.com/webhook",
        "delivery_retry_count": 0,
        "last_success_at": "2024-09-29T11:08:28.466Z",
        "last_failure_content": "",
        "last_failure_at": null,
        "created_at": "2024-09-29T11:08:28.224Z",
        "failure_deletion_timestamp": null,
        "is_workspace_webhook": false,
        "active": true,
        "next_attempt_after": null,
        "filters": [
            {
                "resource_type": "task",
                "resource_subtype": null,
                "action": "added",
                "fields": null
            },
            {
                "resource_type": "task",
                "resource_subtype": null,
                "action": "changed",
                "fields": null
            }
        ],
        "resource": {
            "gid": "YOUR_PROJECT_OR_WORKSPACE_ID",
            "resource_type": "project or workspace",
            "name": "project or workspace name"
        }
    },
    "X-Hook-Secret": "매우 중요한 비밀 키"
}
위와 같이 생성된 Webhook의 정보를 응답으로 받았다면 성공이다. 여기에서 X-Hook-Secret은 잘 기억해놓자.
Github Webhooks 연동은 생략하도록 하겠다.
Webhook과 Cloudflare Workers로 Github와 Asana 자동화하기