Skip to content
Merged
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
261 changes: 64 additions & 197 deletions .github/workflows/pr-to-feishu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,24 @@ on:
types:
- opened
- reopened
- ready_for_review
- synchronize
- closed

permissions:
contents: read

jobs:
sync-task:
name: Sync PR to Feishu Task
notify-reviewers:
name: Notify Feishu Code Reviewers
if: ${{ github.event.pull_request.base.repo.full_name == 'gkit-org/libgkit' }}
runs-on: ubuntu-latest
steps:
- name: Create/complete Feishu task for PR
- name: Notify Feishu group and mention code reviewers
uses: actions/github-script@v7
env:
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
FEISHU_TASKLIST_GUID: ${{ secrets.FEISHU_TASKLIST_GUID }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
FEISHU_CODE_REVIEWER_GROUP_ID: ${{ secrets.FEISHU_CODE_REVIEWER_GROUP_ID }}
with:
script: |
const pr = context.payload.pull_request
Expand All @@ -36,8 +34,8 @@ jobs:
const requiredSecrets = [
'FEISHU_APP_ID',
'FEISHU_APP_SECRET',
'FEISHU_TASKLIST_GUID',
'FEISHU_WEBHOOK_URL'
'FEISHU_WEBHOOK_URL',
'FEISHU_CODE_REVIEWER_GROUP_ID'
]

for (const key of requiredSecrets) {
Expand All @@ -47,28 +45,6 @@ jobs:
}
}

async function getTenantToken() {
const authResp = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: process.env.FEISHU_APP_ID,
app_secret: process.env.FEISHU_APP_SECRET
})
})

if (!authResp.ok) {
throw new Error(`Failed to request Feishu access token: HTTP ${authResp.status}`)
}

const authData = await authResp.json()
if (authData.code !== 0 || !authData.tenant_access_token) {
throw new Error(`Failed to get Feishu access token: ${JSON.stringify(authData)}`)
}

return authData.tenant_access_token
}

async function feishuRequest(url, options) {
const resp = await fetch(url, options)
const raw = await resp.text()
Expand All @@ -86,45 +62,33 @@ jobs:
return data
}

function getTaskGuidFromTask(task) {
return task?.guid || task?.task_guid || task?.id || null
}

function getSummaryText(task) {
const summary = task?.summary
if (typeof summary === 'string') {
return summary
}
async function getTenantToken() {
const data = await feishuRequest('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_id: process.env.FEISHU_APP_ID,
app_secret: process.env.FEISHU_APP_SECRET
})
})

if (summary && typeof summary === 'object') {
if (typeof summary.content === 'string') {
return summary.content
}
if (typeof summary.text === 'string') {
return summary.text
}
const token = data.tenant_access_token
if (!token) {
throw new Error(`Failed to parse tenant_access_token: ${JSON.stringify(data)}`)
}

return ''
return token
}

function isPRTask(task, prNumber) {
const summary = getSummaryText(task)
const m = summary.match(/\[PR\s*#(\d+)\]/i)
return !!m && Number(m[1]) === Number(prNumber)
}

function buildTaskLink(taskGuid) {
return `https://applink.feishu.cn/client/todo/detail?guid=${encodeURIComponent(taskGuid)}`
}

async function listTasklistTasks(tenantToken) {
const tasks = []
async function listGroupMembersOpenId(tenantToken) {
const members = []
let pageToken = ''

while (true) {
const url = new URL(`https://open.feishu.cn/open-apis/task/v2/tasklists/${encodeURIComponent(process.env.FEISHU_TASKLIST_GUID)}/tasks`)
url.searchParams.set('page_size', '100')
const url = new URL(`https://open.feishu.cn/open-apis/contact/v3/group/${encodeURIComponent(process.env.FEISHU_CODE_REVIEWER_GROUP_ID)}/member/simplelist`)
url.searchParams.set('page_size', '50')
url.searchParams.set('member_id_type', 'open_id')
url.searchParams.set('member_type', 'user')
if (pageToken) {
url.searchParams.set('page_token', pageToken)
}
Expand All @@ -137,8 +101,12 @@ jobs:
}
})

const items = data?.data?.items || []
tasks.push(...items)
const list = data?.data?.memberlist || []
for (const member of list) {
if (member?.member_type === 'user' && member?.member_id) {
members.push(member.member_id)
}
}

if (!data?.data?.has_more) {
break
Expand All @@ -150,36 +118,10 @@ jobs:
}
}

return tasks
}

async function findTaskByPRNumber(tenantToken, prNumber) {
const tasks = await listTasklistTasks(tenantToken)
for (const task of tasks) {
if (isPRTask(task, prNumber)) {
return task
}
}

return null
}

async function createTask(tenantToken, taskTitle, taskDescription) {
const endpoint = `https://open.feishu.cn/open-apis/task/v2/tasklists/${encodeURIComponent(process.env.FEISHU_TASKLIST_GUID)}/tasks`
return await feishuRequest(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${tenantToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
summary: taskTitle,
description: taskDescription
})
})
return Array.from(new Set(members))
}

async function notifyGroup(lines) {
async function notifyWebhook(text) {
const resp = await fetch(process.env.FEISHU_WEBHOOK_URL, {
method: 'POST',
headers: {
Expand All @@ -188,7 +130,7 @@ jobs:
body: JSON.stringify({
msg_type: 'text',
content: {
text: lines.join('\n')
text
}
})
})
Expand All @@ -215,116 +157,41 @@ jobs:
core.info(`base=${pr.base?.repo?.full_name || 'unknown'}`)
core.info(`head=${pr.head?.repo?.full_name || 'unknown'}`)

if (['opened', 'reopened', 'ready_for_review', 'synchronize'].includes(context.payload.action)) {
const tenantToken = await getTenantToken()
core.setSecret(tenantToken)

const taskTitle = `[PR #${pr.number}] ${pr.title}`
const taskDescription = [
`PR: ${pr.html_url}`,
`Contributor: ${pr.user?.login || 'unknown'}`,
`Created At (UTC): ${pr.created_at}`
].join('\n')

const existingTask = await findTaskByPRNumber(tenantToken, pr.number)
const existingTaskGuid = getTaskGuidFromTask(existingTask)

let finalTaskGuid = existingTaskGuid
let actionText = 'updated'

if (existingTaskGuid) {
await feishuRequest(`https://open.feishu.cn/open-apis/task/v2/tasks/${existingTaskGuid}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${tenantToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
summary: taskTitle,
description: taskDescription
})
})
} else {
const createData = await createTask(tenantToken, taskTitle, taskDescription)

finalTaskGuid = createData?.data?.task?.guid || createData?.data?.guid || null
actionText = 'created'
}
const tenantToken = await getTenantToken()
core.setSecret(tenantToken)

if (!finalTaskGuid) {
core.setFailed(`Feishu task ${actionText} but guid not found in response`)
return
}

await notifyGroup([
`GitHub PR 已同步到飞书任务清单(${actionText}):`,
const reviewerOpenIds = await listGroupMembersOpenId(tenantToken)
if (reviewerOpenIds.length === 0) {
await notifyWebhook([
'GitHub 有新的 PR,但未查询到 code_reviewer 用户组成员:',
`- 事件: ${context.payload.action}`,
`- 标题: ${pr.title}`,
`- 链接: ${pr.html_url}`,
`- Contributor: ${pr.user?.login || 'unknown'}`,
`- 时间(UTC): ${pr.created_at}`,
`- 编号: #${pr.number}`,
`- Task GUID: ${finalTaskGuid}`,
`- 任务链接: ${buildTaskLink(finalTaskGuid)}`
])

core.info(`Feishu task ${actionText}: ${finalTaskGuid} for PR #${pr.number}`)
return
}

if (context.payload.action === 'closed') {
const tenantToken = await getTenantToken()
core.setSecret(tenantToken)

const task = await findTaskByPRNumber(tenantToken, pr.number)
const taskGuid = getTaskGuidFromTask(task)
if (!taskGuid) {
core.info(`No Feishu task found for PR #${pr.number}. Skip completion.`)
await notifyGroup([
'GitHub PR 已关闭,但未找到关联的飞书任务:',
`- 标题: ${pr.title}`,
`- 链接: ${pr.html_url}`,
`- Contributor: ${pr.user?.login || 'unknown'}`,
`- 状态: ${pr.merged ? 'merged' : 'closed'}`,
`- 编号: #${pr.number}`
])
return
}

try {
await feishuRequest(`https://open.feishu.cn/open-apis/task/v2/tasks/${taskGuid}/complete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${tenantToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({})
})
} catch {
await feishuRequest(`https://open.feishu.cn/open-apis/task/v2/tasks/${taskGuid}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${tenantToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
completed: true
})
})
}

await notifyGroup([
'GitHub PR 已关闭,飞书任务已自动完成:',
`- 标题: ${pr.title}`,
`- 链接: ${pr.html_url}`,
`- Contributor: ${pr.user?.login || 'unknown'}`,
`- 状态: ${pr.merged ? 'merged' : 'closed'}`,
`- 编号: #${pr.number}`,
`- Task GUID: ${taskGuid}`,
`- 任务链接: ${buildTaskLink(taskGuid)}`
])

core.info(`Completed Feishu task ${taskGuid} for PR #${pr.number}`)
`- 用户组ID: ${process.env.FEISHU_CODE_REVIEWER_GROUP_ID}`
].join('\n'))
core.info('No members found in reviewer group; sent fallback notification.')
return
}

core.info(`No handling for action: ${context.payload.action}`)
const action = context.payload.action
const titleLine = action === 'closed'
? `GitHub PR 已关闭(${pr.merged ? 'merged' : 'closed'}):`
: `GitHub PR 需要 Review(${action}):`

const mentions = action === 'closed'
? ''
: reviewerOpenIds.map((id) => `<at user_id="${id}">code_reviewer</at>`).join(' ')

const message = [
mentions,
titleLine,
`- 标题: ${pr.title}`,
`- 链接: ${pr.html_url}`,
`- Contributor: ${pr.user?.login || 'unknown'}`,
`- 编号: #${pr.number}`
].filter((line) => line && line.trim().length > 0).join('\n')

await notifyWebhook(message)
core.info(`Notified reviewer group members: ${reviewerOpenIds.length}`)
Loading