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
318 lines
11 KiB
YAML
318 lines
11 KiB
YAML
# 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))
|
|
}
|