feat(desktop): reference bounded Session snapshots from Composer #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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-24.04 | |
| 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)) | |
| } |