diff --git a/.github/workflows/notify-non-employee-comments.yml b/.github/workflows/notify-non-employee-comments.yml new file mode 100644 index 000000000000..b10fba9f32db --- /dev/null +++ b/.github/workflows/notify-non-employee-comments.yml @@ -0,0 +1,172 @@ +name: Daily non-employee comment digest + +# Include issue/PR conversation comments, not inline review comments. +on: + workflow_dispatch: + schedule: + - cron: '20 16 * * *' + +permissions: + contents: read + +jobs: + notify: + if: github.repository == 'github/docs' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Build daily digest + id: digest + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + // Octokit request logs include author names, even for expected non-member 404s. + github.log = { + ...github.log, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; + // Fixed schedule boundaries avoid gaps or overlaps when scheduled runs start late. + const { data: run } = await github.rest.actions.getWorkflowRun({ + ...context.repo, + run_id: context.runId, + }).catch((error) => { + throw new Error(`Fetching workflow run failed (HTTP ${error.status || 'unknown'}).`); + }); + const runCreatedAt = new Date(run.created_at); + const until = new Date(runCreatedAt.getTime()); + until.setUTCHours(16, 20, 0, 0); + if (until.getTime() > runCreatedAt.getTime()) { + until.setUTCDate(until.getUTCDate() - 1); + } + const since = new Date(until.getTime() - 24 * 60 * 60 * 1000); + core.info(`Building digest for ${since.toISOString()} to ${until.toISOString()}.`); + + const authors = new Map(); + let page = 0; + try { + for await (const { data: comments } of github.paginate.iterator( + github.rest.issues.listCommentsForRepo, + { + owner: 'github', + repo: 'docs', + since: since.toISOString(), + sort: 'created', + direction: 'asc', + per_page: 100, + }, + )) { + core.info(`Fetched comments page ${++page} (${comments.length} comments).`); + for (const comment of comments) { + const createdAt = Date.parse(comment.created_at); + if (!(createdAt >= since.getTime() && createdAt < until.getTime())) { + continue; + } + const author = comment.user; + if (!author) { + core.warning('Skipping a comment whose author is unavailable.'); + continue; + } + const login = author.login.toLowerCase(); + if (author.type === 'Bot' || ['docs-bot', 'copilot'].includes(login)) { + continue; + } + if (!authors.has(login)) { + authors.set(login, { login: author.login, comments: [] }); + } + authors.get(login).comments.push({ + url: comment.html_url, + conversation: comment.issue_url, + }); + } + } + } catch (error) { + throw new Error(`Fetching digest comments failed (HTTP ${error.status || 'unknown'}).`); + } + + const nonEmployees = []; + let checked = 0; + for (const author of authors.values()) { + core.info(`Checking employee membership (${++checked}/${authors.size}).`); + try { + await github.rest.teams.getMembershipForUserInOrg({ + org: 'github', + team_slug: 'employees', + username: author.login, + }); + } catch (error) { + if (error.status !== 404) { + throw new Error(`Employee membership check failed (HTTP ${error.status || 'unknown'}).`); + } + nonEmployees.push(author); + } + } + + const totalComments = nonEmployees.reduce((sum, author) => sum + author.comments.length, 0); + core.info(`Found ${totalComments} comments by ${nonEmployees.length} non-employees.`); + if (totalComments === 0) { + return; + } + + let message = `Daily non-employee comment digest for github/docs\n` + + `${since.toISOString()} to ${until.toISOString()} (UTC)\n` + + `Non-employee users: ${nonEmployees.length}; comments: ${totalComments}\n` + + 'Comment bodies omitted; follow the links to read them on GitHub.'; + const shortened = '\n\n[Digest shortened to fit one Slack message; some authors or comment links are omitted.]'; + const maximumLength = 35000 - shortened.length; + const lines = nonEmployees.flatMap((author) => [ + `\n\n@${author.login} (https://github.com/${author.login})\n` + + `Comments: ${author.comments.length}; conversations: ` + + `${new Set(author.comments.map((comment) => comment.conversation)).size}`, + ...author.comments.map((comment) => `\n ${comment.url}`), + ]); + for (const line of lines) { + if (message.length + line.length > maximumLength) { + message += shortened; + core.warning('Digest shortened to fit one Slack message.'); + break; + } + message += line; + } + for (const author of nonEmployees) { + core.setSecret(author.login); + } + core.setOutput('slack_message', message); + + - name: Notify the docs-open-source channel + if: ${{ steps.digest.outputs.slack_message != '' }} + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} + errors: true + # Slack channel ID for docs-open-source. + payload: | + channel: C017M6T23GA + text: ${{ toJSON(steps.digest.outputs.slack_message) }} + mrkdwn: false + link_names: false + unfurl_links: false + unfurl_media: false + + - uses: ./.github/actions/create-workflow-failure-issue + id: create-failure-issue + if: ${{ failure() }} + with: + token: ${{ secrets.DOCS_BOT_PAT_BASE }} + + - name: Notify Slack about workflow failure + if: ${{ failure() }} + uses: ./.github/actions/slack-alert + with: + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} + slack_channel_id: C017M6T23GA + issue_url: ${{ steps.create-failure-issue.outputs.issue_url }} diff --git a/content/code-security/concepts/code-scanning/ai-applications-scanning-for-pull-requests.md b/content/code-security/concepts/code-scanning/ai-applications-scanning-for-pull-requests.md index 3b9f66253fa3..fe9e4330edcd 100644 --- a/content/code-security/concepts/code-scanning/ai-applications-scanning-for-pull-requests.md +++ b/content/code-security/concepts/code-scanning/ai-applications-scanning-for-pull-requests.md @@ -36,7 +36,7 @@ Scans run when a pull request is created and after each new commit. Findings from {% data variables.product.prodname_ai_applications_scanning %} appear as pull request comments, the same way other code scanning findings do. Each finding includes: -* The same "AI" label and thumbs up/down feedback control as findings from AI Scan for pull requests +* "AI app" label and thumbs up/down feedback control * A dismiss button As with AI Scan, these findings are advisory. They don't block a pull request from being merged. diff --git a/content/copilot/get-started/whats-changing-with-github-enterprise-and-copilot.md b/content/copilot/get-started/whats-changing-with-github-enterprise-and-copilot.md index 1e75daa63f2a..76d5d29b7607 100644 --- a/content/copilot/get-started/whats-changing-with-github-enterprise-and-copilot.md +++ b/content/copilot/get-started/whats-changing-with-github-enterprise-and-copilot.md @@ -70,3 +70,5 @@ Before your transition takes effect: For an existing enterprise with {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %}, contact your {% data variables.product.github %} or Microsoft account team. They can confirm eligibility, review the expected access and billing impact, and coordinate an available transition date. Contacting your account team does not itself change access or billing or guarantee a transition date. Transitions take effect on the first day of a month. Confirm the effective date with your account team. + +Don't have a dedicated account team? [Contact sales](https://github.com/enterprise/contact). \ No newline at end of file diff --git a/src/workflows/tests/actions-workflows.ts b/src/workflows/tests/actions-workflows.ts index 34e9394fb913..9f6bb83dbb1b 100644 --- a/src/workflows/tests/actions-workflows.ts +++ b/src/workflows/tests/actions-workflows.ts @@ -107,6 +107,8 @@ const alertWorkflows = workflows.filter(({ data }) => const dailyWorkflows = scheduledWorkflows // purge-fastly.yml soft-purges daily at 02:20 UTC, outside the standard 16:20 slot. .filter(({ filename }) => filename !== 'purge-fastly.yml') + // The non-employee digest runs every day, including weekends. + .filter(({ filename }) => filename !== 'notify-non-employee-comments.yml') .filter(({ data }) => data.on.schedule!.find(({ cron }: { cron: string }) => /^20 \d{1,2} /.test(cron)), )