--- /dev/null
+#! /usr/bin/perl
+
+#################################################################
+# add_commit_links.pl -- add commit links to the release notes
+#
+# Copyright (c) 2024, PostgreSQL Global Development Group
+#
+# src/tools/add_commit_links.pl
+#################################################################
+
+#
+# This script adds commit links to the release notes.
+#
+# Usage: cd to top of source tree and issue
+# src/tools/add_commit_links.pl release_notes_file
+#
+# The script can add links for release note items that lack them, and update
+# those that have them. The script is sensitive to the release note file being
+# in a specific format:
+#
+# * File name contains the major version number preceded by a dash
+# and followed by a period
+# * Commit text is generated by src/tools/git_changelog
+# * SGML comments around commit text start in the first column
+# * The commit item title ends with an attribution that ends with
+# a closing parentheses
+# * previously added URL link text is unmodified
+# * a "<para>" follows the commit item title
+#
+# The major version number is used to select the commit hash for minor
+# releases. An error will be generated if valid commits are found but
+# no proper location for the commit links is found.
+
+use strict;
+use warnings FATAL => 'all';
+
+sub process_file
+{
+ my $file = shift;
+
+ my $in_comment = 0;
+ my $prev_line_ended_with_paren = 0;
+ my $prev_leading_space = '';
+ my $lineno = 0;
+
+ my @hashes = ();
+
+ my $tmpfile = $file . '.tmp';
+
+ # Get major version number from the file name.
+ $file =~ m/-(\d+)\./;
+ my $major_version = $1;
+
+ open(my $fh, '<', $file) || die "could not open file %s: $!\n", $file;
+ open(my $tfh, '>', $tmpfile) || die "could not open file %s: $!\n",
+ $tmpfile;
+
+ while (<$fh>)
+ {
+ $lineno++;
+
+ $in_comment = 1 if (m/^<!--/);
+
+ # skip over commit links because we will add them below
+ next
+ if (!$in_comment &&
+ m{^\s*<ulink url="&commit_baseurl;[\da-f]+">§</ulink>\s*$});
+
+ if ($in_comment && m/\[([\da-f]+)\]/)
+ {
+ my $hash = $1;
+
+ # major release item
+ (!m/^Branch:/) && push(@hashes, $hash);
+
+ # minor release item
+ m/^Branch:/ &&
+ defined($major_version) &&
+ m/_${major_version}_/ &&
+ push(@hashes, $hash);
+ }
+
+ if (!$in_comment && m{</para>})
+ {
+ if (@hashes)
+ {
+ if ($prev_line_ended_with_paren)
+ {
+ for my $hash (@hashes)
+ {
+ print({$tfh}
+ "$prev_leading_space<ulink url=\"&commit_baseurl;$hash\">§</ulink>\n"
+ );
+ }
+ @hashes = ();
+ }
+ else
+ {
+ printf(
+ "hashes found but no matching text found for placement on line %s\n",
+ $lineno);
+ exit(1);
+ }
+ }
+ }
+
+ print({$tfh} $_);
+
+ $prev_line_ended_with_paren = m/\)\s*$/;
+
+ m/^(\s*)/;
+ $prev_leading_space = $1;
+
+ $in_comment = 0 if (m/^-->/);
+ }
+
+ close($fh);
+ close($tfh);
+
+ rename($tmpfile, $file) || die "could not rename %s to %s: $!\n",
+ $tmpfile,
+ $file;
+
+ return;
+}
+
+if (@ARGV == 0)
+{
+ printf(STDERR "Usage: %s release_notes_file [...]\n", $0);
+ exit(1);
+}
+
+for my $file (@ARGV)
+{
+ process_file($file);
+}