源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
name: Issue and PR lifecycle
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Report planned lifecycle changes without writing to GitHub.
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
issues:
|
||||
types: [reopened]
|
||||
# Reopen handling mutates labels/comments. Use the trusted base workflow so
|
||||
# fork PRs cannot force writes through a read-only pull_request token.
|
||||
pull_request_target:
|
||||
types: [reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: issue-pr-lifecycle
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
lifecycle:
|
||||
if: github.repository == 'apache/maka'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}
|
||||
with:
|
||||
script: |
|
||||
const path = require("node:path")
|
||||
const { pathToFileURL } = require("node:url")
|
||||
|
||||
const {
|
||||
LIFECYCLE_LABELS,
|
||||
STALE_CLOSE_MARKER,
|
||||
planLifecycle,
|
||||
} = await import(
|
||||
pathToFileURL(
|
||||
path.join(process.env.GITHUB_WORKSPACE, "scripts/issue-pr-lifecycle.mjs"),
|
||||
).href
|
||||
)
|
||||
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = process.env.DRY_RUN === "true"
|
||||
|
||||
const fragments = `
|
||||
fragment IssueFields on Issue {
|
||||
number
|
||||
state
|
||||
createdAt
|
||||
labels(first: 100) { nodes { name } }
|
||||
assignees(first: 1) { totalCount }
|
||||
comments(last: 100) {
|
||||
totalCount
|
||||
nodes { createdAt body author { __typename login } }
|
||||
}
|
||||
}
|
||||
|
||||
fragment PullRequestFields on PullRequest {
|
||||
number
|
||||
state
|
||||
createdAt
|
||||
labels(first: 100) { nodes { name } }
|
||||
comments(last: 100) {
|
||||
totalCount
|
||||
nodes { createdAt body author { __typename login } }
|
||||
}
|
||||
commits(last: 1) { nodes { commit { committedDate } } }
|
||||
}
|
||||
`
|
||||
|
||||
const normalize = (node) => ({
|
||||
number: node.number,
|
||||
state: node.state,
|
||||
kind: node.__typename === "Issue" ? "issue" : "pull_request",
|
||||
createdAt: node.createdAt,
|
||||
lastCommitAt: node.commits?.nodes?.[0]?.commit?.committedDate,
|
||||
labels: (node.labels?.nodes ?? []).map((label) => label.name),
|
||||
assigneeCount: node.assignees?.totalCount ?? 0,
|
||||
comments: node.comments?.nodes ?? [],
|
||||
commentCount: node.comments?.totalCount ?? node.comments?.nodes?.length ?? 0,
|
||||
})
|
||||
|
||||
async function hydrateComments(item) {
|
||||
if (item.commentCount <= item.comments.length) return item
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
per_page: 100,
|
||||
})
|
||||
return {
|
||||
...item,
|
||||
comments: comments.map((comment) => ({
|
||||
createdAt: comment.created_at,
|
||||
body: comment.body,
|
||||
author: {
|
||||
__typename: comment.user?.type === "Bot" ? "Bot" : "User",
|
||||
login: comment.user?.login,
|
||||
},
|
||||
})),
|
||||
commentCount: comments.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function listOpenItems() {
|
||||
const query = `
|
||||
query LifecycleItems($searchQuery: String!, $cursor: String) {
|
||||
search(query: $searchQuery, type: ISSUE, first: 100, after: $cursor) {
|
||||
nodes {
|
||||
__typename
|
||||
...IssueFields
|
||||
...PullRequestFields
|
||||
}
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
${fragments}
|
||||
`
|
||||
const items = []
|
||||
let cursor
|
||||
|
||||
do {
|
||||
const result = await github.graphql(query, {
|
||||
searchQuery: `repo:${owner}/${repo} is:open`,
|
||||
cursor,
|
||||
})
|
||||
items.push(...result.search.nodes.map(normalize))
|
||||
cursor = result.search.pageInfo.hasNextPage
|
||||
? result.search.pageInfo.endCursor
|
||||
: undefined
|
||||
} while (cursor)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
async function refresh(number) {
|
||||
const query = `
|
||||
query LifecycleItem($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issueOrPullRequest(number: $number) {
|
||||
__typename
|
||||
...IssueFields
|
||||
...PullRequestFields
|
||||
}
|
||||
}
|
||||
}
|
||||
${fragments}
|
||||
`
|
||||
const result = await github.graphql(query, { owner, repo, number })
|
||||
const node = result.repository.issueOrPullRequest
|
||||
return node ? normalize(node) : undefined
|
||||
}
|
||||
|
||||
async function ensureLabels() {
|
||||
for (const label of LIFECYCLE_LABELS) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: label.name })
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error
|
||||
if (dryRun) {
|
||||
core.info(`[dry-run] create label ${label.name}`)
|
||||
continue
|
||||
}
|
||||
await github.rest.issues.createLabel({ owner, repo, ...label })
|
||||
core.info(`created label ${label.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStale(number) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
name: "stale",
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function apply(item, plan, revalidateClose = true) {
|
||||
const prefix = `${item.kind === "issue" ? "issue" : "PR"} #${item.number}`
|
||||
core.info(`${dryRun ? "[dry-run] " : ""}${prefix}: ${plan.action} (${plan.reason ?? "policy threshold"})`)
|
||||
if (dryRun || plan.action === "none") return
|
||||
|
||||
if (plan.action === "warn") {
|
||||
if (!item.labels.includes("stale")) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: ["stale"],
|
||||
})
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: plan.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (plan.action === "unstale") {
|
||||
await removeStale(item.number)
|
||||
return
|
||||
}
|
||||
|
||||
if (plan.action === "close" && revalidateClose) {
|
||||
let current = await refresh(item.number)
|
||||
if (!current || current.state !== "OPEN") {
|
||||
core.info(`${prefix}: close cancelled because the item is no longer open`)
|
||||
return
|
||||
}
|
||||
current = await hydrateComments(current)
|
||||
const currentPlan = planLifecycle(current)
|
||||
if (currentPlan.action !== "close") {
|
||||
core.info(`${prefix}: close cancelled after revalidation`)
|
||||
await apply(current, currentPlan, false)
|
||||
return
|
||||
}
|
||||
item = current
|
||||
plan = currentPlan
|
||||
}
|
||||
|
||||
const alreadyExplained = item.comments.some(
|
||||
(comment) =>
|
||||
comment.author?.__typename === "Bot" &&
|
||||
comment.body.includes(STALE_CLOSE_MARKER),
|
||||
)
|
||||
if (!alreadyExplained) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: plan.message,
|
||||
})
|
||||
}
|
||||
|
||||
if (item.kind === "issue") {
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: "closed",
|
||||
})
|
||||
} else {
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: item.number,
|
||||
state: "closed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await ensureLabels()
|
||||
|
||||
if (context.payload.action === "reopened") {
|
||||
const number = context.payload.issue?.number ?? context.payload.pull_request?.number
|
||||
if (number !== undefined) {
|
||||
core.info(`${dryRun ? "[dry-run] " : ""}reopened #${number}: remove stale and reset lifecycle clock`)
|
||||
if (!dryRun) {
|
||||
await removeStale(number)
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
body: "<!-- maka-lifecycle:reopened -->",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const items = await listOpenItems()
|
||||
core.info(`evaluating ${items.length} open issues and pull requests`)
|
||||
for (const item of items) {
|
||||
const hydrated = await hydrateComments(item)
|
||||
await apply(hydrated, planLifecycle(hydrated))
|
||||
}
|
||||
Reference in New Issue
Block a user