diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4ffd970c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Changes + +- + +## Testing + +- [ ] diff --git a/.github/scripts/generate_release_notes.sh b/.github/scripts/generate_release_notes.sh new file mode 100755 index 00000000..c9e9a576 --- /dev/null +++ b/.github/scripts/generate_release_notes.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# .github/scripts/generate_release_notes.sh +# +# Generates human-readable release notes for a Digital Collections production release. +# Finds PRs merged since the last production tag, filters noise, calls +# Claude via Bedrock to summarize, then creates a GitHub Release. +# +# Required env vars: +# GITHUB_TOKEN - GitHub token with contents:write and pull-requests:read +# AWS_REGION - AWS region for Bedrock (e.g. "us-east-1") +# +# Optional env vars: +# CURRENT_TAG - Tag being released (e.g. "v1.2.3"); derived from package.json if not set +# PREVIOUS_TAG - Previous release tag; auto-detected from git if not set +# DRAFT_RELEASE - Set to "true" to create a draft GitHub Release +# GITHUB_REPOSITORY - Set automatically by GitHub Actions (owner/repo) +# DRY_RUN - Set to "true" to skip release creation and print output only +# +# Local testing (dry run): +# (For local testing you must set CURRENT_TAG and PREVIOUS_TAG explicitly) +# export GITHUB_TOKEN= +# export CURRENT_TAG=v1.2.3 +# export PREVIOUS_TAG=v1.1.4 +# export AWS_REGION=us-east-1 +# export GITHUB_REPOSITORY=nulib/dc-nextjs +# export DRY_RUN=true +# bash .github/scripts/generate_release_notes.sh + +set -euo pipefail + +REPO="${GITHUB_REPOSITORY}" +CURRENT_TAG="${CURRENT_TAG:-v$(node -p "require('./package.json').version")}" +PREVIOUS_TAG="${PREVIOUS_TAG:-}" +MODEL_ID="us.anthropic.claude-sonnet-4-6" +DRAFT_RELEASE="${DRAFT_RELEASE:-false}" +DRY_RUN="${DRY_RUN:-false}" + +# Initialize so these are always set even if all PRs are filtered out +FILTERED_COUNT=0 +PR_DETAIL_LIST="" +SUMMARY="" + +if [[ "$DRY_RUN" == "true" ]]; then + echo "==> DRY RUN MODE — no GitHub Release will be created" +fi + +# TEST_PR_LIST can be set to bypass GitHub API fetch entirely for testing +TEST_PR_LIST="${TEST_PR_LIST:-}" + +echo "==> Generating release notes for ${CURRENT_TAG}" + +if [[ -n "$TEST_PR_LIST" ]]; then + # --------------------------------------------------------------------------- + # TEST MODE: skip GitHub API and tag lookup, use provided PR list directly + # --------------------------------------------------------------------------- + echo "==> TEST MODE — using provided TEST_PR_LIST, skipping GitHub API" + FILTERED_COUNT=$(echo "$TEST_PR_LIST" | grep -c "^-" || true) + PR_DETAIL_LIST="$TEST_PR_LIST" + +else + # --------------------------------------------------------------------------- + # 1. Find the previous production tag + # --------------------------------------------------------------------------- + if [[ -z "$PREVIOUS_TAG" ]]; then + echo "==> Finding previous tag..." + + PREVIOUS_TAG=$(git tag \ + --sort=-creatordate \ + --list "v*" \ + | grep -v "^${CURRENT_TAG}$" \ + | head -1 || true) + + if [[ -z "$PREVIOUS_TAG" ]]; then + echo "No previous tag found. Skipping release notes generation." + exit 0 + fi + else + echo "==> Using provided previous tag: ${PREVIOUS_TAG}" + fi + + echo " Previous tag: ${PREVIOUS_TAG}" + echo " Current tag: ${CURRENT_TAG}" + + # --------------------------------------------------------------------------- + # 2. Fetch merged PRs between the two tags via GitHub API + # --------------------------------------------------------------------------- + echo "==> Fetching merged PRs between ${PREVIOUS_TAG} and ${CURRENT_TAG}..." + + # Get the date of the previous tag so we can filter PRs by merge date + PREVIOUS_TAG_DATE=$(git log -1 --format="%cI" "${PREVIOUS_TAG}") + echo " Previous tag date: ${PREVIOUS_TAG_DATE}" + + PR_RESPONSE=$(curl -s \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/pulls?state=closed&base=deploy/staging&sort=updated&direction=desc&per_page=100&since=${PREVIOUS_TAG_DATE}") + + # Filter to PRs merged after the previous tag, extract title + labels + number + PR_LIST=$(echo "$PR_RESPONSE" | jq -r --arg since "$PREVIOUS_TAG_DATE" ' + [ + .[] | + select( + .merged_at != null and + .merged_at > $since + ) | + { + number: .number, + title: .title, + body: (.body // ""), + labels: [.labels[].name], + merged_at: .merged_at + } + ] | sort_by(.merged_at) + ') + + PR_COUNT=$(echo "$PR_LIST" | jq 'length') + echo " Found ${PR_COUNT} merged PRs" + + if [[ "$PR_COUNT" -eq 0 ]]; then + echo "No PRs found for this release. Creating release with minimal notes." + SUMMARY="No pull requests were found for this release." + PR_DETAIL_LIST="" + else + # ------------------------------------------------------------------------- + # 3. Filter out noise PRs + # ------------------------------------------------------------------------- + echo "==> Filtering noise PRs..." + + FILTERED_PRS=$(echo "$PR_LIST" | jq -r ' + [ + .[] | + select( + (.title | ascii_downcase | test("^(dependabot|chore:|ci:|ignore:|build:|bump version|increment version|deploy v|dependency rollup)") | not) and + ((.labels | map(ascii_downcase) | any(. == "dependencies" or . == "chore" or . == "ci" or . == "ignore")) | not) + ) + ] + ') + + FILTERED_COUNT=$(echo "$FILTERED_PRS" | jq 'length') + EXCLUDED_COUNT=$(( PR_COUNT - FILTERED_COUNT )) + echo " Kept ${FILTERED_COUNT} PRs, excluded ${EXCLUDED_COUNT} noise PRs" + + if [[ "$FILTERED_COUNT" -eq 0 ]]; then + echo "All PRs were infrastructure/dependency updates. Creating release with minimal notes." + SUMMARY="This release contains dependency updates and infrastructure improvements only." + PR_DETAIL_LIST="" + else + PR_DETAIL_LIST="" + while IFS= read -r pr_json; do + number=$(echo "$pr_json" | jq -r '.number') + title=$(echo "$pr_json" | jq -r '.title') + body=$(echo "$pr_json" | jq -r '.body // ""') + + # Extract text under the ## Summary header, stopping at the next ## section. + # Strip HTML comments and image markdown; collapse to a single line. + summary=$(echo "$body" | awk ' + /^##+ Summary/{ found=1; next } + found && /^##/ { exit } + found { print } + ' | sed '/^/d; s/!\[[^]]*\]([^)]*)//g; s/]*>//g; /^[[:space:]]*$/d' \ + | head -5 | tr '\n' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + # Fall back to the first few lines of the body if no Summary section found + if [[ -z "$summary" ]]; then + summary=$(echo "$body" | sed '/^/d; s/!\[[^]]*\]([^)]*)//g; s/]*>//g; /^[[:space:]]*$/d; /^#/d' \ + | head -3 | tr '\n' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + fi + + if [[ -n "$summary" ]]; then + PR_DETAIL_LIST+="- #${number}: ${title}"$'\n'" ${summary}"$'\n' + else + PR_DETAIL_LIST+="- #${number}: ${title}"$'\n' + fi + done < <(echo "$FILTERED_PRS" | jq -c '.[]') + fi + fi + +fi # end TEST_PR_LIST bypass + +# --------------------------------------------------------------------------- +# 4. Call Claude via Bedrock to generate plain-language release notes +# --------------------------------------------------------------------------- + +if [[ -n "$PR_DETAIL_LIST" ]]; then + echo "==> Calling Claude via Bedrock..." + echo " PRs to summarize:" + echo "$PR_DETAIL_LIST" | sed 's/^/ /' + + PROMPT="You are writing release notes for Digital Collections, the public-facing digital collections discovery and access application for Northwestern University Libraries. It allows researchers, students, and the public to search, browse, and access digitized materials from the Northwestern Libraries collections. + +Given the following list of pull request titles merged into this release, write concise, plain-language release notes suitable for a general audience. Focus on what changed from the user's perspective — what they can now do, what was fixed, or what improved. Group related changes if it makes sense. Do not mention PR numbers, branch names, version numbers, or technical implementation details. Do not invent a title or header. Use plain prose or a short bullet list. Keep it under 200 words. + +Pull requests in this release: +${PR_DETAIL_LIST} + +Write only the release notes text, nothing else." + + REQUEST_PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + '{ + anthropic_version: "bedrock-2023-05-31", + max_tokens: 1024, + messages: [ + { + role: "user", + content: $prompt + } + ] + }') + + PAYLOAD_FILE=$(mktemp) + echo "$REQUEST_PAYLOAD" > "$PAYLOAD_FILE" + RESPONSE_FILE=$(mktemp) + + aws bedrock-runtime invoke-model \ + --region "${AWS_REGION}" \ + --model-id "${MODEL_ID}" \ + --content-type "application/json" \ + --accept "application/json" \ + --body "fileb://${PAYLOAD_FILE}" \ + "$RESPONSE_FILE" + + SUMMARY=$(jq -r '.content[0].text' "$RESPONSE_FILE") + rm -f "$PAYLOAD_FILE" "$RESPONSE_FILE" + + echo " Summary generated (${#SUMMARY} chars)" +fi + +RELEASE_BODY="${SUMMARY}" + +# --------------------------------------------------------------------------- +# 5. Create the GitHub Release (or print in dry run mode) +# --------------------------------------------------------------------------- + +if [[ "$DRY_RUN" == "true" ]]; then + echo "" + echo "==> DRY RUN: Would create GitHub Release with the following:" + echo "" + echo " Tag: ${CURRENT_TAG}" + echo " Name: Digital Collections ${CURRENT_TAG}" + echo "" + echo "--- RELEASE BODY ---" + echo "${RELEASE_BODY}" + echo "--- END RELEASE BODY ---" + echo "" + echo "==> DRY RUN complete. No release was created." + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "current_tag=${CURRENT_TAG}" + echo "release_url=DRY_RUN" + echo "release_body<<__RELEASE_BODY__" + echo "${RELEASE_BODY}" + echo "__RELEASE_BODY__" + } >> "$GITHUB_OUTPUT" + fi + exit 0 +fi + +echo "==> Creating GitHub Release for ${CURRENT_TAG}..." + +RELEASE_PAYLOAD=$(jq -n \ + --arg tag "$CURRENT_TAG" \ + --arg name "Digital Collections ${CURRENT_TAG}" \ + --arg body "$RELEASE_BODY" \ + --argjson draft "$([[ "$DRAFT_RELEASE" == "true" ]] && echo "true" || echo "false")" \ + '{ + tag_name: $tag, + name: $name, + body: $body, + draft: $draft, + prerelease: false + }') + +RELEASE_RESPONSE=$(curl -s \ + -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${REPO}/releases" \ + -d "$RELEASE_PAYLOAD") + +RELEASE_URL=$(echo "$RELEASE_RESPONSE" | jq -r '.html_url') + +if [[ "$RELEASE_URL" == "null" || -z "$RELEASE_URL" ]]; then + echo "ERROR: Failed to create release. Response:" + echo "$RELEASE_RESPONSE" | jq . + exit 1 +fi + +echo "==> Release created successfully: ${RELEASE_URL}" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "current_tag=${CURRENT_TAG}" + echo "release_url=${RELEASE_URL}" + echo "release_body<<__RELEASE_BODY__" + echo "${RELEASE_BODY}" + echo "__RELEASE_BODY__" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.github/workflows/next_version.yml b/.github/workflows/next_version.yml index 9e0bcb3a..06f186d1 100644 --- a/.github/workflows/next_version.yml +++ b/.github/workflows/next_version.yml @@ -8,6 +8,7 @@ concurrency: permissions: contents: write # create/push tag + push bump commit to deploy/staging pull-requests: write # open the next release PR + actions: write # dispatch release_notes.yml jobs: version: # allow an escape hatch for non-release pushes to main @@ -47,6 +48,23 @@ jobs: echo "tagged=true" >> "$GITHUB_OUTPUT" fi + - name: Generate Release Notes + if: ${{ steps.tag.outputs.tagged == 'true' }} + uses: actions/github-script@v7 + with: + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release_notes.yml', + ref: 'main', + inputs: { + current_tag: 'v${{ steps.ver.outputs.version }}', + previous_tag: '', + draft: 'false', + }, + }) + - name: Bump patch on deploy/staging & open next PR # only runs when a brand-new release tag was just created; any non-release # push to main (version unchanged → tag already exists) is a complete no-op diff --git a/.github/workflows/release_notes.yml b/.github/workflows/release_notes.yml new file mode 100644 index 00000000..b5df4000 --- /dev/null +++ b/.github/workflows/release_notes.yml @@ -0,0 +1,101 @@ +name: Generate Release Notes +on: + workflow_dispatch: + inputs: + current_tag: + description: "Tag being released (e.g. v1.2.3)" + required: true + previous_tag: + description: "Previous release tag (leave empty to auto-detect)" + required: false + default: "" + draft: + description: "Create as draft release" + type: boolean + default: false +permissions: + id-token: write + contents: write + actions: write +jobs: + generate: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + actions: write + environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - uses: aws-actions/configure-aws-credentials@master + with: + role-to-assume: arn:aws:iam::${{ secrets.AwsAccount }}:role/github-actions-role + aws-region: us-east-1 + + - name: Generate Release Notes + id: generate + run: .github/scripts/generate_release_notes.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT_TAG: ${{ inputs.current_tag }} + PREVIOUS_TAG: ${{ inputs.previous_tag }} + DRAFT_RELEASE: ${{ inputs.draft }} + AWS_REGION: us-east-1 + + - name: Notify Teams + if: steps.generate.outputs.release_body != '' + env: + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} + RELEASE_URL: ${{ steps.generate.outputs.release_url }} + RELEASE_BODY: ${{ steps.generate.outputs.release_body }} + CURRENT_TAG: ${{ steps.generate.outputs.current_tag }} + IS_DRAFT: ${{ inputs.draft }} + run: | + if [[ "$IS_DRAFT" == "true" ]]; then + TITLE="🚀 [DRAFT] Digital Collections ${CURRENT_TAG}" + else + TITLE="🚀 Digital Collections ${CURRENT_TAG} Released" + fi + + PAYLOAD=$(jq -n \ + --arg title "$TITLE" \ + --arg body "$RELEASE_BODY" \ + --arg url "$RELEASE_URL" \ + '{ + type: "message", + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.2", + body: [ + {type: "TextBlock", size: "large", weight: "bolder", text: $title, wrap: true}, + {type: "TextBlock", text: $body, wrap: true} + ], + actions: [ + {type: "Action.OpenUrl", title: "View Release", url: $url} + ] + } + }] + }') + + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "$TEAMS_WEBHOOK_URL") + + if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "202" ]]; then + echo "WARNING: Teams notification returned HTTP ${HTTP_STATUS}" + else + echo "==> Teams notification sent" + fi diff --git a/lib/queries/builder.ts b/lib/queries/builder.ts index 0b137a51..6a593232 100644 --- a/lib/queries/builder.ts +++ b/lib/queries/builder.ts @@ -16,29 +16,13 @@ type BuildQueryProps = { urlFacets: UrlFacets; }; -const searchPipeline = { - phase_results_processors: [ - { - "normalization-processor": { - combination: { - parameters: { - weights: [0.25, 0.75], - }, - technique: "arithmetic_mean", - }, - normalization: { - technique: "l2", - }, - }, - }, - ], -}; - export function buildQuery(obj: BuildQueryProps, isAI: boolean) { const { aggs, aggsFilterValue, size, term, urlFacets } = obj; const must: QueryDslQueryContainer[] = []; let queryValue; + const facetFilters = buildFacetFilters(urlFacets); + // Build the "must" part of the query if (term) must.push(buildSearchPart(term)); @@ -80,6 +64,7 @@ export function buildQuery(obj: BuildQueryProps, isAI: boolean) { }, }, ], + ...(!aggs && { filter: facetFilters }), }, }, { @@ -88,12 +73,11 @@ export function buildQuery(obj: BuildQueryProps, isAI: boolean) { filter: { bool: !aggs ? { - filter: buildFacetFilters(urlFacets), + filter: facetFilters, } : {}, }, k: AI_K_VALUE, - model_id: process.env.NEXT_PUBLIC_OPENSEARCH_MODEL_ID, query_text: term, // if term has no value, the API returns a 400 error }, }, @@ -108,16 +92,15 @@ export function buildQuery(obj: BuildQueryProps, isAI: boolean) { ...(queryValue && { query: queryValue, }), - ...(isAI && { - search_pipeline: searchPipeline, - }), ...(aggs && { aggs: buildAggs(aggs, aggsFilterValue, urlFacets) }), ...(typeof size !== "undefined" && { size: size }), - post_filter: { - bool: { - must: buildFacetFilters(urlFacets), + ...(!isAI && { + post_filter: { + bool: { + must: facetFilters, + }, }, - }, + }), } as ApiSearchRequestBody; return requestBody; diff --git a/package-lock.json b/package-lock.json index e701430f..001993ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dc-nextjs", - "version": "3.1.2", + "version": "3.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dc-nextjs", - "version": "3.1.2", + "version": "3.1.3", "dependencies": { "@honeybadger-io/js": "^5.1.1", "@honeybadger-io/webpack": "^5.1.0", diff --git a/package.json b/package.json index f301e323..670ece31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dc-nextjs", - "version": "3.1.2", + "version": "3.1.3", "private": true, "scripts": { "analyze": "ANALYZE=true npm run build",